Compare commits
41 Commits
feature/de
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
52c0c95f22 | ||
| 91134e2f3f | |||
|
|
663e0bb149 | ||
| 5c6b407f47 | |||
|
|
f300e44c74 | ||
|
|
946bdc65b6 | ||
|
|
bc5ee3fa1a | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
ec024d8236 | ||
|
|
225295030b | ||
|
|
86e85aa1c6 | ||
| 5aa6013c52 | |||
|
|
4b4323f707 | ||
|
|
356bdf5bec | ||
|
|
f9a45e6deb | ||
|
|
cfc0f038f9 | ||
|
|
c999285895 | ||
|
|
e517741c97 | ||
| 837a0845ec | |||
|
|
4bf458f1b8 | ||
|
|
099286b078 | ||
|
|
d39c7d3319 | ||
|
|
8c5fdf1e9c | ||
|
|
7f5573f076 | ||
|
|
82d6bdafba | ||
|
|
36d93697bc | ||
| 2f57719b21 | |||
| bffc06c9b1 | |||
| f1ef2648b1 | |||
|
|
6b762068fd | ||
|
|
0e7bcff6b0 | ||
|
|
ce0ee150ec | ||
|
|
41a9e36166 | ||
|
|
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`
|
||||||
57
api_tokens_db.py
Normal file
57
api_tokens_db.py
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
api_tokens_db.py — DB migration for personal API tokens + user webhooks
|
||||||
|
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
logger = logging.getLogger("turf_saas.api_tokens_db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db() -> sqlite3.Connection:
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_api_tokens_tables() -> None:
|
||||||
|
"""Idempotent migration: create user_api_tokens and user_webhooks."""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
token_hash TEXT NOT NULL UNIQUE,
|
||||||
|
token_prefix TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
last_used_at DATETIME,
|
||||||
|
revoked INTEGER NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_webhooks (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL UNIQUE,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
secret TEXT NOT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id);
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info(
|
||||||
|
"[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
migrate_api_tokens_tables()
|
||||||
|
print("[api_tokens_db] Migration complete.")
|
||||||
63
api_v1/__init__.py
Normal file
63
api_v1/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
#!/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
|
||||||
|
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||||
|
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||||
|
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||||
|
|
||||||
|
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/user/ — config utilisateur, alertes Telegram (premium+)
|
||||||
|
/api/v1/user/api-token — Personal API token (Pro)
|
||||||
|
/api/v1/user/webhook — Webhook config (Pro)
|
||||||
|
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||||
|
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||||
|
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||||
|
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||||
|
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
from .routes.user import user_bp
|
||||||
|
from .routes.user_tokens import user_tokens_bp
|
||||||
|
from .routes.history import history_bp
|
||||||
|
from .routes.org import org_bp
|
||||||
|
from .routes.ml_feedback import ml_feedback_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)
|
||||||
|
app.register_blueprint(user_bp)
|
||||||
|
app.register_blueprint(user_tokens_bp)
|
||||||
|
app.register_blueprint(history_bp)
|
||||||
|
app.register_blueprint(org_bp)
|
||||||
|
app.register_blueprint(ml_feedback_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, jsonify, request
|
||||||
|
|
||||||
|
from saas_auth import require_auth as 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):
|
||||||
|
"""Return the most recent active subscription row for a user."""
|
||||||
|
return db.execute(
|
||||||
|
"""SELECT * FROM saas_subscriptions
|
||||||
|
WHERE user_id = ?
|
||||||
|
ORDER BY start_date DESC
|
||||||
|
LIMIT 1""",
|
||||||
|
(str(user_id),),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _upsert_subscription(db, user_id, **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 saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||||
|
else:
|
||||||
|
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||||
|
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||||
|
values = [str(user_id)] + list(fields.values())
|
||||||
|
db.execute(
|
||||||
|
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _update_user_plan(db, user_id, plan: str):
|
||||||
|
"""Sync saas_users.plan field to match active subscription."""
|
||||||
|
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(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 = request.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 = request.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 = request.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 saas_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 = str(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 = str(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 = str(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
|
||||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -0,0 +1,212 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
History routes for API v1.
|
||||||
|
|
||||||
|
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||||
|
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||||
|
|
||||||
|
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
|
from api_v1.utils import (
|
||||||
|
get_db,
|
||||||
|
table_exists,
|
||||||
|
internal_error,
|
||||||
|
bad_request,
|
||||||
|
forbidden,
|
||||||
|
get_pagination_params,
|
||||||
|
paginate_query,
|
||||||
|
)
|
||||||
|
from auth import jwt_required_middleware
|
||||||
|
|
||||||
|
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Plan limits (days of history accessible; None = unlimited)
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
HISTORY_DAYS = {
|
||||||
|
"free": 7,
|
||||||
|
"premium": 90,
|
||||||
|
"pro": None, # illimité
|
||||||
|
}
|
||||||
|
|
||||||
|
# Fallback for unknown plans: treat like free
|
||||||
|
_DEFAULT_LIMIT = 7
|
||||||
|
|
||||||
|
|
||||||
|
def _get_plan_max_days(plan: str):
|
||||||
|
"""Return the max history days allowed for the given plan, or default."""
|
||||||
|
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_date(date_str: str, param_name: str):
|
||||||
|
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||||
|
try:
|
||||||
|
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/history
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@history_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def get_history():
|
||||||
|
"""
|
||||||
|
Historique des prédictions ML avec filtre date range
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Historique
|
||||||
|
summary: |
|
||||||
|
Historique des prédictions sur une plage de dates.
|
||||||
|
Limite selon le plan :
|
||||||
|
- Free : 7 derniers jours
|
||||||
|
- Premium : 90 derniers jours
|
||||||
|
- Pro : illimité
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- name: start
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||||
|
- name: end
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
format: date
|
||||||
|
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||||
|
- name: limit
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 50
|
||||||
|
description: Nombre de résultats par page (max 500)
|
||||||
|
- name: offset
|
||||||
|
in: query
|
||||||
|
type: integer
|
||||||
|
default: 0
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Historique des prédictions ML
|
||||||
|
400:
|
||||||
|
description: Paramètre de date invalide
|
||||||
|
401:
|
||||||
|
description: Token invalide ou manquant
|
||||||
|
403:
|
||||||
|
description: Plage de dates hors limite du plan — upgrade requis
|
||||||
|
"""
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
|
||||||
|
plan = user.get("plan", "free")
|
||||||
|
today = datetime.now().date()
|
||||||
|
max_days = _get_plan_max_days(plan)
|
||||||
|
|
||||||
|
# ── Parse end date ────────────────────────────────────────
|
||||||
|
end_str = request.args.get("end", today.isoformat())
|
||||||
|
try:
|
||||||
|
end_date = _parse_date(end_str, "end")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Parse start date ─────────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
default_start = today - timedelta(days=max_days - 1)
|
||||||
|
else:
|
||||||
|
# Pro: default to 30 days back when no start provided
|
||||||
|
default_start = today - timedelta(days=29)
|
||||||
|
|
||||||
|
start_str = request.args.get("start", default_start.isoformat())
|
||||||
|
try:
|
||||||
|
start_date = _parse_date(start_str, "start")
|
||||||
|
except ValueError as exc:
|
||||||
|
return bad_request(str(exc))
|
||||||
|
|
||||||
|
# ── Validate ordering ─────────────────────────────────────
|
||||||
|
if start_date > end_date:
|
||||||
|
return bad_request(
|
||||||
|
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Enforce plan window ───────────────────────────────────
|
||||||
|
if max_days is not None:
|
||||||
|
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||||
|
if start_date < earliest_allowed:
|
||||||
|
return forbidden(
|
||||||
|
message=(
|
||||||
|
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||||
|
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||||
|
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||||
|
),
|
||||||
|
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||||
|
current_plan=plan,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ── Pagination ────────────────────────────────────────────
|
||||||
|
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||||
|
|
||||||
|
# ── Query ─────────────────────────────────────────────────
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
if not table_exists(conn, "ml_predictions_cache"):
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": [],
|
||||||
|
**paginate_query([], 0, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
count_row = conn.execute(
|
||||||
|
"""SELECT COUNT(*) as cnt
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?""",
|
||||||
|
(start_date.isoformat(), end_date.isoformat()),
|
||||||
|
).fetchone()
|
||||||
|
total = count_row["cnt"] if count_row else 0
|
||||||
|
|
||||||
|
sql = """
|
||||||
|
SELECT
|
||||||
|
id, date, horse_name, prob_top1, prob_top3,
|
||||||
|
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date >= ? AND date <= ?
|
||||||
|
ORDER BY date DESC, ml_score DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
"""
|
||||||
|
rows = conn.execute(
|
||||||
|
sql,
|
||||||
|
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
history = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"plan": plan,
|
||||||
|
"history_limit_days": max_days,
|
||||||
|
"start": start_date.isoformat(),
|
||||||
|
"end": end_date.isoformat(),
|
||||||
|
"history": history,
|
||||||
|
**paginate_query(history, total, limit, offset),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
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()
|
||||||
191
api_v1/routes/ml_feedback.py
Normal file
191
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||||
|
|
||||||
|
Routes:
|
||||||
|
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||||
|
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||||
|
|
||||||
|
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||||
|
ou plan "pro" en fallback pour les stats.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request, g
|
||||||
|
|
||||||
|
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||||
|
|
||||||
|
from api_v1.utils import get_db, internal_error, bad_request
|
||||||
|
from auth import jwt_required_middleware, plan_required
|
||||||
|
|
||||||
|
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||||
|
|
||||||
|
# Token admin interne — configurable via variable d'environnement
|
||||||
|
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||||
|
|
||||||
|
|
||||||
|
def _check_admin(req):
|
||||||
|
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||||
|
# 1. Token interne (scheduler/cron)
|
||||||
|
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||||
|
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
if user and user.get("plan") == "pro":
|
||||||
|
return True, None
|
||||||
|
|
||||||
|
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||||
|
|
||||||
|
|
||||||
|
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
def feedback_run():
|
||||||
|
"""
|
||||||
|
Déclenche le feedback loop ML pour une date donnée.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- ML Feedback
|
||||||
|
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
- AdminToken: []
|
||||||
|
parameters:
|
||||||
|
- name: body
|
||||||
|
in: body
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
properties:
|
||||||
|
date:
|
||||||
|
type: string
|
||||||
|
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||||
|
example: "2026-04-25"
|
||||||
|
mode:
|
||||||
|
type: string
|
||||||
|
description: "run (défaut) ou backfill"
|
||||||
|
enum: [run, backfill]
|
||||||
|
example: run
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Feedback loop exécuté avec succès
|
||||||
|
400:
|
||||||
|
description: Paramètre invalide
|
||||||
|
403:
|
||||||
|
description: Accès refusé
|
||||||
|
500:
|
||||||
|
description: Erreur interne
|
||||||
|
"""
|
||||||
|
# Vérification admin
|
||||||
|
user = getattr(g, "current_user", None)
|
||||||
|
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||||
|
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||||
|
user and user.get("plan") == "pro"
|
||||||
|
)
|
||||||
|
if not is_admin:
|
||||||
|
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||||
|
mode = body.get("mode", "run")
|
||||||
|
|
||||||
|
# Validation date
|
||||||
|
try:
|
||||||
|
datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||||
|
|
||||||
|
if mode not in ("run", "backfill"):
|
||||||
|
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||||
|
|
||||||
|
try:
|
||||||
|
import ml_feedback_saas
|
||||||
|
|
||||||
|
if mode == "backfill":
|
||||||
|
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||||
|
total_inseres = inseres
|
||||||
|
else:
|
||||||
|
result = ml_feedback_saas.run(date_str)
|
||||||
|
total_inseres = sum(result["inseres"].values())
|
||||||
|
maj = result["maj"]
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"date": date_str,
|
||||||
|
"mode": mode,
|
||||||
|
"paris_inseres": total_inseres,
|
||||||
|
"paris_mis_a_jour": maj,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return internal_error(str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def feedback_stats():
|
||||||
|
"""
|
||||||
|
Stats performances ML par stratégie.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- ML Feedback
|
||||||
|
summary: Stats paris ML par stratégie (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- name: date_debut
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
description: Date de début YYYY-MM-DD
|
||||||
|
- name: date_fin
|
||||||
|
in: query
|
||||||
|
type: string
|
||||||
|
description: Date de fin YYYY-MM-DD
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Stats par stratégie
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant (premium ou pro requis)
|
||||||
|
"""
|
||||||
|
date_debut = request.args.get("date_debut")
|
||||||
|
date_fin = request.args.get("date_fin")
|
||||||
|
|
||||||
|
# Validation optionnelle des dates
|
||||||
|
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||||
|
if d_str:
|
||||||
|
try:
|
||||||
|
datetime.strptime(d_str, "%Y-%m-%d")
|
||||||
|
except ValueError:
|
||||||
|
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
import ml_feedback_saas
|
||||||
|
|
||||||
|
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"strategies": stats,
|
||||||
|
"filters": {
|
||||||
|
"date_debut": date_debut,
|
||||||
|
"date_fin": date_fin,
|
||||||
|
},
|
||||||
|
"total_strategies": len(stats),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
return internal_error(str(e))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org Blueprint — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||||
|
GET /api/v1/org — infos org courante
|
||||||
|
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||||
|
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||||
|
GET /api/v1/org/members — liste des membres
|
||||||
|
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||||
|
|
||||||
|
Plan enforcement:
|
||||||
|
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||||
|
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import secrets
|
||||||
|
import logging
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from saas_auth import require_auth as jwt_required_middleware
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.org")
|
||||||
|
|
||||||
|
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||||
|
|
||||||
|
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Decorator: plan Pro requis
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _require_pro(fn):
|
||||||
|
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||||
|
from functools import wraps
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
user = getattr(request, "current_user", None)
|
||||||
|
if not user:
|
||||||
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
|
if user.get("plan") != "pro":
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Plan insuffisant",
|
||||||
|
"required": "pro",
|
||||||
|
"current_plan": user.get("plan", "free"),
|
||||||
|
"upgrade_url": "/api/v1/billing/checkout",
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers DB
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_owner(db, owner_id: str):
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_org_by_id(db, org_id: str):
|
||||||
|
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _get_member_org(db, user_id: str):
|
||||||
|
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||||
|
row = db.execute(
|
||||||
|
"""SELECT o.* FROM organizations o
|
||||||
|
JOIN org_members m ON m.org_id = o.id
|
||||||
|
WHERE m.user_id = ?
|
||||||
|
LIMIT 1""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _count_org_members(db, org_id: str) -> int:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||||
|
).fetchone()
|
||||||
|
return row["cnt"] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def _get_user_by_email(db, email: str):
|
||||||
|
"""Lookup dans saas_users par email."""
|
||||||
|
return db.execute(
|
||||||
|
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
|
||||||
|
def _org_to_dict(org) -> dict:
|
||||||
|
return {
|
||||||
|
"id": org["id"],
|
||||||
|
"owner_id": org["owner_id"],
|
||||||
|
"name": org["name"],
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
"created_at": org["created_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _member_to_dict(m) -> dict:
|
||||||
|
return {
|
||||||
|
"id": m["id"],
|
||||||
|
"org_id": m["org_id"],
|
||||||
|
"user_id": m["user_id"],
|
||||||
|
"role": m["role"],
|
||||||
|
"invited_at": m["invited_at"],
|
||||||
|
"joined_at": m["joined_at"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org — créer une organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def create_org():
|
||||||
|
"""
|
||||||
|
Crée une organisation.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [name]
|
||||||
|
properties:
|
||||||
|
name:
|
||||||
|
type: string
|
||||||
|
description: Nom de l'organisation (1-100 caractères)
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Organisation créée
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
409:
|
||||||
|
description: L'utilisateur possède déjà une organisation
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
owner_id = user["id"]
|
||||||
|
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
name = (data.get("name") or "").strip()
|
||||||
|
if not name or len(name) > 100:
|
||||||
|
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# 1 org max par owner
|
||||||
|
existing = _get_org_by_owner(db, owner_id)
|
||||||
|
if existing:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Vous possédez déjà une organisation",
|
||||||
|
"org_id": existing["id"],
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
org_id = secrets.token_hex(16)
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||||
|
"VALUES (?, ?, ?, ?, ?)",
|
||||||
|
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||||
|
)
|
||||||
|
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'owner', ?, ?)",
|
||||||
|
(org_id, owner_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
org = _get_org_by_id(db, org_id)
|
||||||
|
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||||
|
return jsonify({"org": _org_to_dict(org)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("create_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org — infos org courante
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def get_org():
|
||||||
|
"""
|
||||||
|
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Infos de l'organisation
|
||||||
|
404:
|
||||||
|
description: Aucune organisation trouvée
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
member_count = _count_org_members(db, org["id"])
|
||||||
|
result = _org_to_dict(org)
|
||||||
|
result["member_count"] = member_count
|
||||||
|
return jsonify({"org": result}), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org — supprimer l'organisation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def delete_org():
|
||||||
|
"""
|
||||||
|
Supprime l'organisation (owner uniquement).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Organisation supprimée
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut supprimer l'organisation
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||||
|
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||||
|
db.commit()
|
||||||
|
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||||
|
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("delete_org error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# POST /api/v1/org/invite — inviter un membre par email
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/invite", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def invite_member():
|
||||||
|
"""
|
||||||
|
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||||
|
Limite : 5 membres totaux (owner inclus).
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
requestBody:
|
||||||
|
required: true
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema:
|
||||||
|
type: object
|
||||||
|
required: [email]
|
||||||
|
properties:
|
||||||
|
email:
|
||||||
|
type: string
|
||||||
|
description: Email de l'utilisateur à inviter
|
||||||
|
responses:
|
||||||
|
201:
|
||||||
|
description: Membre ajouté
|
||||||
|
400:
|
||||||
|
description: Paramètre manquant ou invalide
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||||
|
404:
|
||||||
|
description: Utilisateur introuvable ou organisation inexistante
|
||||||
|
409:
|
||||||
|
description: L'utilisateur est déjà membre
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
email = (data.get("email") or "").strip().lower()
|
||||||
|
|
||||||
|
if not email or "@" not in email:
|
||||||
|
return jsonify({"error": "Email invalide"}), 400
|
||||||
|
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
# Vérifier que l'appelant est bien owner d'une org
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# Vérifier la limite de membres
|
||||||
|
current_count = _count_org_members(db, org["id"])
|
||||||
|
if current_count >= org["max_members"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||||
|
"current_count": current_count,
|
||||||
|
}
|
||||||
|
), 403
|
||||||
|
|
||||||
|
# Résoudre l'utilisateur cible
|
||||||
|
target_user = _get_user_by_email(db, email)
|
||||||
|
if not target_user:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||||
|
|
||||||
|
target_id = target_user["id"]
|
||||||
|
|
||||||
|
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||||
|
existing_member = db.execute(
|
||||||
|
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
if existing_member:
|
||||||
|
return jsonify(
|
||||||
|
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
now = datetime.now(timezone.utc).isoformat()
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?, ?, 'member', ?, ?)",
|
||||||
|
(org["id"], target_id, now, now),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
member_row = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_id),
|
||||||
|
).fetchone()
|
||||||
|
logger.info(
|
||||||
|
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("invite_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# GET /api/v1/org/members — liste des membres
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def list_members():
|
||||||
|
"""
|
||||||
|
Liste les membres de l'organisation courante.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Liste des membres
|
||||||
|
404:
|
||||||
|
description: Organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||||
|
|
||||||
|
members = db.execute(
|
||||||
|
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||||
|
"FROM org_members m "
|
||||||
|
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||||
|
"WHERE m.org_id = ? "
|
||||||
|
"ORDER BY m.invited_at ASC",
|
||||||
|
(org["id"],),
|
||||||
|
).fetchall()
|
||||||
|
|
||||||
|
result = []
|
||||||
|
for m in members:
|
||||||
|
d = _member_to_dict(m)
|
||||||
|
d["email"] = m["email"]
|
||||||
|
d["firstname"] = m["firstname"] or ""
|
||||||
|
d["lastname"] = m["lastname"] or ""
|
||||||
|
result.append(d)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"org_id": org["id"],
|
||||||
|
"members": result,
|
||||||
|
"count": len(result),
|
||||||
|
"max_members": org["max_members"],
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@_require_pro
|
||||||
|
def remove_member(target_user_id: str):
|
||||||
|
"""
|
||||||
|
Retire un membre de l'organisation (owner uniquement).
|
||||||
|
L'owner ne peut pas se retirer lui-même.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Organisation
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: path
|
||||||
|
name: user_id
|
||||||
|
type: string
|
||||||
|
required: true
|
||||||
|
description: ID de l'utilisateur à retirer
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Membre retiré
|
||||||
|
400:
|
||||||
|
description: Tentative de retirer l'owner lui-même
|
||||||
|
403:
|
||||||
|
description: Seul l'owner peut retirer des membres
|
||||||
|
404:
|
||||||
|
description: Membre ou organisation introuvable
|
||||||
|
"""
|
||||||
|
user = request.current_user
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
org = _get_org_by_owner(db, user["id"])
|
||||||
|
if not org:
|
||||||
|
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||||
|
|
||||||
|
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||||
|
if target_user_id == user["id"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||||
|
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||||
|
}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
member = db.execute(
|
||||||
|
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
).fetchone()
|
||||||
|
if not member:
|
||||||
|
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||||
|
|
||||||
|
db.execute(
|
||||||
|
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||||
|
(org["id"], target_user_id),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
logger.info(
|
||||||
|
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||||
|
)
|
||||||
|
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
db.rollback()
|
||||||
|
logger.error("remove_member error: %s", e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# On-import : migration idempotente
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_org_tables()
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||||
226
api_v1/routes/predictions.py
Normal file
226
api_v1/routes/predictions.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
#!/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, include_weather: bool = False
|
||||||
|
):
|
||||||
|
"""Shared helper — returns rows from ml_predictions_cache.
|
||||||
|
|
||||||
|
include_weather=True adds terrain_condition and weather_impact columns
|
||||||
|
via LEFT JOIN on pmu_meteo (premium routes only).
|
||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
if (
|
||||||
|
include_weather
|
||||||
|
and table_exists(conn, "pmu_meteo")
|
||||||
|
and table_exists(conn, "pmu_courses")
|
||||||
|
):
|
||||||
|
sql = """SELECT
|
||||||
|
m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||||
|
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||||
|
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
|
||||||
|
c.penetrometre_intitule,
|
||||||
|
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
|
||||||
|
FROM ml_predictions_cache m
|
||||||
|
LEFT JOIN pmu_courses c
|
||||||
|
ON c.date_programme = m.date
|
||||||
|
AND c.num_reunion = m.num_reunion
|
||||||
|
AND c.num_course = m.num_course
|
||||||
|
LEFT JOIN pmu_meteo mt
|
||||||
|
ON mt.date_programme = m.date
|
||||||
|
AND mt.num_reunion = m.num_reunion
|
||||||
|
WHERE m.date = ?
|
||||||
|
ORDER BY m.ml_score DESC"""
|
||||||
|
else:
|
||||||
|
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()
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for r in rows:
|
||||||
|
row_dict = dict(r)
|
||||||
|
if include_weather:
|
||||||
|
# Compute derived fields from raw columns
|
||||||
|
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||||
|
# Import inline to avoid circular dependency at module level
|
||||||
|
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||||
|
|
||||||
|
terrain_condition = (
|
||||||
|
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||||
|
)
|
||||||
|
weather_data = None
|
||||||
|
if (
|
||||||
|
row_dict.get("nebulositecode") is not None
|
||||||
|
or row_dict.get("temperature") is not None
|
||||||
|
):
|
||||||
|
weather_data = {
|
||||||
|
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||||
|
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||||
|
"temperature": row_dict.pop("temperature", None),
|
||||||
|
"force_vent": row_dict.pop("force_vent", None),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Remove raw meteo columns even if NULL
|
||||||
|
row_dict.pop("nebulositecode", None)
|
||||||
|
row_dict.pop("nebulosite_court", None)
|
||||||
|
row_dict.pop("temperature", None)
|
||||||
|
row_dict.pop("force_vent", None)
|
||||||
|
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||||
|
row_dict["terrain_condition"] = terrain_condition
|
||||||
|
row_dict["weather_impact"] = weather_impact
|
||||||
|
results.append(row_dict)
|
||||||
|
|
||||||
|
return results, 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, include_weather=True
|
||||||
|
)
|
||||||
|
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()
|
||||||
216
api_v1/routes/user.py
Normal file
216
api_v1/routes/user.py
Normal file
@@ -0,0 +1,216 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
User route for API v1 — Telegram alert configuration
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||||
|
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||||
|
|
||||||
|
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from flask import Blueprint, jsonify, request
|
||||||
|
|
||||||
|
from api_v1.utils import internal_error, bad_request
|
||||||
|
from auth import jwt_required_middleware, plan_required
|
||||||
|
|
||||||
|
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||||
|
|
||||||
|
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["GET"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def get_telegram_config():
|
||||||
|
"""
|
||||||
|
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Lire la config alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration Telegram courante
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
nullable: true
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": row["telegram_chat_id"],
|
||||||
|
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||||
|
"alert_top1": bool(row["alert_top1"]),
|
||||||
|
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
# Colonnes absentes : migration non appliquée
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"telegram_chat_id": None,
|
||||||
|
"alert_value_bets": True,
|
||||||
|
"alert_top1": True,
|
||||||
|
"alert_quinte_only": False,
|
||||||
|
"_warning": "Migration Telegram non appliquée",
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@user_bp.route("/telegram-config", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("premium", "pro")
|
||||||
|
def update_telegram_config():
|
||||||
|
"""
|
||||||
|
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||||
|
---
|
||||||
|
tags:
|
||||||
|
- Utilisateur
|
||||||
|
summary: Configurer les alertes Telegram (premium+)
|
||||||
|
security:
|
||||||
|
- Bearer: []
|
||||||
|
parameters:
|
||||||
|
- in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
properties:
|
||||||
|
telegram_chat_id:
|
||||||
|
type: string
|
||||||
|
description: Chat ID Telegram (ou null pour désactiver)
|
||||||
|
alert_value_bets:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_top1:
|
||||||
|
type: boolean
|
||||||
|
default: true
|
||||||
|
alert_quinte_only:
|
||||||
|
type: boolean
|
||||||
|
default: false
|
||||||
|
responses:
|
||||||
|
200:
|
||||||
|
description: Configuration mise à jour
|
||||||
|
400:
|
||||||
|
description: Paramètres invalides
|
||||||
|
401:
|
||||||
|
description: Token invalide
|
||||||
|
403:
|
||||||
|
description: Plan insuffisant
|
||||||
|
"""
|
||||||
|
user_id = request.user_id # injecté par jwt_required_middleware
|
||||||
|
|
||||||
|
data = request.get_json(silent=True)
|
||||||
|
if not data:
|
||||||
|
return bad_request("Corps JSON requis")
|
||||||
|
|
||||||
|
# Validation et extraction des champs
|
||||||
|
telegram_chat_id = data.get("telegram_chat_id")
|
||||||
|
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||||
|
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||||
|
if isinstance(telegram_chat_id, str):
|
||||||
|
telegram_chat_id = telegram_chat_id.strip() or None
|
||||||
|
|
||||||
|
alert_value_bets = data.get("alert_value_bets", True)
|
||||||
|
alert_top1 = data.get("alert_top1", True)
|
||||||
|
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||||
|
|
||||||
|
if not isinstance(alert_value_bets, bool):
|
||||||
|
return bad_request("alert_value_bets doit être un booléen")
|
||||||
|
if not isinstance(alert_top1, bool):
|
||||||
|
return bad_request("alert_top1 doit être un booléen")
|
||||||
|
if not isinstance(alert_quinte_only, bool):
|
||||||
|
return bad_request("alert_quinte_only doit être un booléen")
|
||||||
|
|
||||||
|
conn = _get_db()
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE users
|
||||||
|
SET telegram_chat_id = ?,
|
||||||
|
alert_value_bets = ?,
|
||||||
|
alert_top1 = ?,
|
||||||
|
alert_quinte_only = ?
|
||||||
|
WHERE id = ?
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
telegram_chat_id,
|
||||||
|
int(alert_value_bets),
|
||||||
|
int(alert_top1),
|
||||||
|
int(alert_quinte_only),
|
||||||
|
user_id,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"telegram_chat_id": telegram_chat_id,
|
||||||
|
"alert_value_bets": alert_value_bets,
|
||||||
|
"alert_top1": alert_top1,
|
||||||
|
"alert_quinte_only": alert_quinte_only,
|
||||||
|
}
|
||||||
|
), 200
|
||||||
|
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Migration Telegram non appliquée — contacter le support",
|
||||||
|
"detail": str(exc),
|
||||||
|
}
|
||||||
|
), 500
|
||||||
|
except Exception as exc:
|
||||||
|
return internal_error(str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
|
||||||
|
HRT-80
|
||||||
|
|
||||||
|
Endpoints:
|
||||||
|
POST /api/v1/user/api-token
|
||||||
|
DELETE /api/v1/user/api-token
|
||||||
|
POST /api/v1/user/webhook
|
||||||
|
DELETE /api/v1/user/webhook
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
from flask import Blueprint, g, jsonify, request
|
||||||
|
|
||||||
|
from api_tokens_db import get_db, migrate_api_tokens_tables
|
||||||
|
from auth import jwt_required_middleware, plan_required
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.user_tokens")
|
||||||
|
|
||||||
|
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
|
||||||
|
|
||||||
|
try:
|
||||||
|
migrate_api_tokens_tables()
|
||||||
|
except Exception as _e:
|
||||||
|
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
|
||||||
|
|
||||||
|
|
||||||
|
def _hash_token(raw: str) -> str:
|
||||||
|
return hashlib.sha256(raw.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
@user_tokens_bp.route("/api-token", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("pro")
|
||||||
|
def create_api_token():
|
||||||
|
user = g.current_user
|
||||||
|
user_id = str(user["id"])
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id, token_prefix, created_at FROM user_api_tokens "
|
||||||
|
"WHERE user_id = ? AND revoked = 0",
|
||||||
|
(user_id,),
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
|
||||||
|
"existing_prefix": existing["token_prefix"],
|
||||||
|
"created_at": existing["created_at"],
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
raw_token = "trf_" + secrets.token_urlsafe(40)
|
||||||
|
token_hash = _hash_token(raw_token)
|
||||||
|
token_prefix = raw_token[:12]
|
||||||
|
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
|
||||||
|
(user_id, token_hash, token_prefix),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
|
||||||
|
(token_hash,),
|
||||||
|
).fetchone()
|
||||||
|
created_at = row["created_at"] if row else None
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("create_api_token error for user %s: %s", user_id, e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"token": raw_token,
|
||||||
|
"prefix": token_prefix,
|
||||||
|
"created_at": created_at,
|
||||||
|
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
|
||||||
|
}
|
||||||
|
), 201
|
||||||
|
|
||||||
|
|
||||||
|
@user_tokens_bp.route("/api-token", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("pro")
|
||||||
|
def revoke_api_token():
|
||||||
|
user = g.current_user
|
||||||
|
user_id = str(user["id"])
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
result = conn.execute(
|
||||||
|
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
|
||||||
|
(user_id,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
revoked_count = result.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("revoke_api_token error for user %s: %s", user_id, e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if revoked_count == 0:
|
||||||
|
return jsonify({"error": "Aucun token actif trouvé"}), 404
|
||||||
|
|
||||||
|
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
|
||||||
|
return jsonify({"revoked": True, "count": revoked_count}), 200
|
||||||
|
|
||||||
|
|
||||||
|
@user_tokens_bp.route("/webhook", methods=["POST"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("pro")
|
||||||
|
def create_webhook():
|
||||||
|
user = g.current_user
|
||||||
|
user_id = str(user["id"])
|
||||||
|
data = request.get_json(silent=True) or {}
|
||||||
|
url = (data.get("url") or "").strip()
|
||||||
|
|
||||||
|
if not url:
|
||||||
|
return jsonify({"error": "URL du webhook manquante"}), 400
|
||||||
|
if not url.startswith("https://"):
|
||||||
|
return jsonify(
|
||||||
|
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
|
||||||
|
|
||||||
|
conn = get_db()
|
||||||
|
existing = None
|
||||||
|
try:
|
||||||
|
existing = conn.execute(
|
||||||
|
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
|
||||||
|
).fetchone()
|
||||||
|
if existing:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
|
||||||
|
"WHERE user_id = ?",
|
||||||
|
(url, secret, user_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
|
||||||
|
(user_id, url, secret),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("create_webhook error for user %s: %s", user_id, e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
action = "mis à jour" if existing else "configuré"
|
||||||
|
logger.info("Webhook %s for user %s: %s", action, user_id, url)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"webhook_url": url,
|
||||||
|
"secret": secret,
|
||||||
|
"message": f"Webhook {action} avec succès",
|
||||||
|
}
|
||||||
|
), 201
|
||||||
|
|
||||||
|
|
||||||
|
@user_tokens_bp.route("/webhook", methods=["DELETE"])
|
||||||
|
@jwt_required_middleware
|
||||||
|
@plan_required("pro")
|
||||||
|
def delete_webhook():
|
||||||
|
user = g.current_user
|
||||||
|
user_id = str(user["id"])
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
|
||||||
|
conn.commit()
|
||||||
|
deleted_count = result.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.error("delete_webhook error for user %s: %s", user_id, e)
|
||||||
|
return jsonify({"error": "Erreur interne"}), 500
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if deleted_count == 0:
|
||||||
|
return jsonify({"error": "Aucun webhook configuré"}), 404
|
||||||
|
|
||||||
|
logger.info("Webhook deleted for user %s", user_id)
|
||||||
|
return jsonify({"deleted": True}), 200
|
||||||
166
api_v1/routes/valuebets.py
Normal file
166
api_v1/routes/valuebets.py
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
#!/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 avec météo et terrain (HRT-83)
|
||||||
|
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_raw = []
|
||||||
|
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
|
||||||
|
|
||||||
|
# LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
|
||||||
|
has_courses = table_exists(conn, "pmu_courses")
|
||||||
|
has_meteo = table_exists(conn, "pmu_meteo")
|
||||||
|
|
||||||
|
if has_courses and has_meteo:
|
||||||
|
rows_raw = conn.execute(
|
||||||
|
"""SELECT m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||||
|
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||||
|
m.ml_score, m.recommendation, m.risque_label, m.risque_score,
|
||||||
|
c.penetrometre_intitule,
|
||||||
|
mt.nebulositecode, mt.nebulosite_court,
|
||||||
|
mt.temperature, mt.force_vent
|
||||||
|
FROM ml_predictions_cache m
|
||||||
|
LEFT JOIN pmu_courses c
|
||||||
|
ON c.date_programme = m.date
|
||||||
|
AND c.num_reunion = m.num_reunion
|
||||||
|
AND c.num_course = m.num_course
|
||||||
|
LEFT JOIN pmu_meteo mt
|
||||||
|
ON mt.date_programme = m.date
|
||||||
|
AND mt.num_reunion = m.num_reunion
|
||||||
|
WHERE m.date = ? AND m.is_value_bet = 1 AND m.odds >= ?
|
||||||
|
ORDER BY m.ml_score DESC
|
||||||
|
LIMIT ? OFFSET ?""",
|
||||||
|
(date_param, min_odds, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows_raw = 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()
|
||||||
|
|
||||||
|
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||||
|
|
||||||
|
valuebets_list = []
|
||||||
|
for r in rows_raw:
|
||||||
|
row_dict = dict(r)
|
||||||
|
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||||
|
terrain_condition = (
|
||||||
|
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||||
|
)
|
||||||
|
weather_data = None
|
||||||
|
if (
|
||||||
|
row_dict.get("nebulositecode") is not None
|
||||||
|
or row_dict.get("temperature") is not None
|
||||||
|
):
|
||||||
|
weather_data = {
|
||||||
|
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||||
|
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||||
|
"temperature": row_dict.pop("temperature", None),
|
||||||
|
"force_vent": row_dict.pop("force_vent", None),
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
row_dict.pop("nebulositecode", None)
|
||||||
|
row_dict.pop("nebulosite_court", None)
|
||||||
|
row_dict.pop("temperature", None)
|
||||||
|
row_dict.pop("force_vent", None)
|
||||||
|
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||||
|
row_dict["terrain_condition"] = terrain_condition
|
||||||
|
row_dict["weather_impact"] = weather_impact
|
||||||
|
valuebets_list.append(row_dict)
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
80
api_v1/utils_webhook.py
Normal file
80
api_v1/utils_webhook.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256)
|
||||||
|
HRT-80
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import hmac
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
from api_tokens_db import get_db
|
||||||
|
|
||||||
|
logger = logging.getLogger("turf_saas.webhook")
|
||||||
|
|
||||||
|
EVENT_NEW_PREDICTION = "new_prediction"
|
||||||
|
EVENT_VALUE_BET = "value_bet"
|
||||||
|
|
||||||
|
|
||||||
|
def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None:
|
||||||
|
"""
|
||||||
|
Send HMAC-signed webhook POST to URL configured by user.
|
||||||
|
Fire-and-forget: errors logged, never re-raised. Timeout: 5s.
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT url, secret FROM user_webhooks WHERE user_id = ?",
|
||||||
|
(str(user_id),),
|
||||||
|
).fetchone()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e)
|
||||||
|
return
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not row:
|
||||||
|
return
|
||||||
|
|
||||||
|
url = row["url"]
|
||||||
|
secret = row["secret"]
|
||||||
|
body = json.dumps(
|
||||||
|
{"event": event_type, "data": payload},
|
||||||
|
ensure_ascii=False,
|
||||||
|
separators=(",", ":"),
|
||||||
|
)
|
||||||
|
signature = hmac.new(
|
||||||
|
secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256
|
||||||
|
).hexdigest()
|
||||||
|
headers = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Turf-Signature": f"sha256={signature}",
|
||||||
|
"X-Turf-Event": event_type,
|
||||||
|
"User-Agent": "TurfSaaS-Webhook/1.0",
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, data=body, headers=headers, timeout=5)
|
||||||
|
logger.info(
|
||||||
|
"Webhook dispatched to user %s (event=%s, status=%s)",
|
||||||
|
user_id,
|
||||||
|
event_type,
|
||||||
|
resp.status_code,
|
||||||
|
)
|
||||||
|
except requests.exceptions.Timeout:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook failed for user %s (event=%s): %s", user_id, event_type, e
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(
|
||||||
|
"Webhook unexpected error for user %s (event=%s): %s",
|
||||||
|
user_id,
|
||||||
|
event_type,
|
||||||
|
e,
|
||||||
|
)
|
||||||
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)
|
||||||
408
auth.py
Normal file
408
auth.py
Normal file
@@ -0,0 +1,408 @@
|
|||||||
|
#!/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 validate_api_key(raw_key: str):
|
||||||
|
"""
|
||||||
|
Validate a personal API token (X-API-Key header).
|
||||||
|
Returns user dict or None. Updates last_used_at on success.
|
||||||
|
HRT-80: Personal API token support.
|
||||||
|
"""
|
||||||
|
if not raw_key:
|
||||||
|
return None
|
||||||
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||||
|
db = get_db()
|
||||||
|
try:
|
||||||
|
row = db.execute(
|
||||||
|
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||||
|
"JOIN users u ON CAST(t.user_id AS INTEGER) = u.id "
|
||||||
|
"WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1",
|
||||||
|
(key_hash,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
db.execute(
|
||||||
|
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||||
|
"WHERE token_hash = ?",
|
||||||
|
(key_hash,),
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
return dict(row) if row else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("validate_api_key error: %s", e)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def jwt_required_middleware(fn):
|
||||||
|
"""
|
||||||
|
Decorator: require a valid Bearer JWT access token OR X-API-Key personal token.
|
||||||
|
HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only).
|
||||||
|
"""
|
||||||
|
|
||||||
|
@wraps(fn)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
# 1. Try Bearer JWT (existing flow — unchanged)
|
||||||
|
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
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
except (JWTExtendedException, PyJWTError) as e:
|
||||||
|
logger.debug("JWT auth failed: %s", e)
|
||||||
|
|
||||||
|
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||||
|
api_key = request.headers.get("X-API-Key", "").strip()
|
||||||
|
if api_key:
|
||||||
|
user = validate_api_key(api_key)
|
||||||
|
if user:
|
||||||
|
g.current_user = user
|
||||||
|
g.current_user_id = user.get("id")
|
||||||
|
return fn(*args, **kwargs)
|
||||||
|
|
||||||
|
return jsonify({"error": "Token invalide ou expiré"}), 401
|
||||||
|
|
||||||
|
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
|
||||||
99
auth_db.py
Normal file
99
auth_db.py
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Auth DB — users and subscriptions schema for turf_saas.db
|
||||||
|
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||||
|
HRT-79: migration Telegram columns
|
||||||
|
"""
|
||||||
|
|
||||||
|
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.")
|
||||||
|
|
||||||
|
# Apply Telegram columns migration (idempotent)
|
||||||
|
migrate_telegram_columns()
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_telegram_columns():
|
||||||
|
"""
|
||||||
|
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||||
|
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||||
|
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||||
|
HRT-79
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
columns = [
|
||||||
|
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||||
|
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_top1", "INTEGER DEFAULT 1"),
|
||||||
|
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||||
|
]
|
||||||
|
for col, definition in columns:
|
||||||
|
try:
|
||||||
|
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||||
|
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
# Column already exists — safe to ignore
|
||||||
|
pass
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
print("[auth_db] Migration Telegram columns OK.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_auth_tables()
|
||||||
143
billing_db.py
Normal file
143
billing_db.py
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
#!/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 TEXT,
|
||||||
|
payload TEXT,
|
||||||
|
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
plan TEXT NOT NULL DEFAULT 'free',
|
||||||
|
start_date DATETIME DEFAULT (datetime('now')),
|
||||||
|
end_date DATETIME,
|
||||||
|
stripe_customer_id TEXT,
|
||||||
|
stripe_subscription_id TEXT,
|
||||||
|
status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
grace_period_end DATETIME,
|
||||||
|
current_period_end DATETIME
|
||||||
|
);
|
||||||
|
|
||||||
|
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_saas_subs_user ON saas_subscriptions(user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
|
||||||
|
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
|
||||||
|
)
|
||||||
214
combined_api.py
214
combined_api.py
@@ -3614,5 +3614,219 @@ def api_predictions_analysis():
|
|||||||
return jsonify({"stats": stats, "period": {"start": start_date, "end": end_date}})
|
return jsonify({"stats": stats, "period": {"start": start_date, "end": end_date}})
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
# /api/v1/predictions — Ensemble model endpoint (Sprint 6-7 ML Upgrade)
|
||||||
|
# ─────────────────────────────────────────────────────────────────────────────
|
||||||
|
_predict_v2 = None
|
||||||
|
|
||||||
|
|
||||||
|
def _load_predict_v2():
|
||||||
|
"""Lazy import of predict_v2 module (ensemble model)."""
|
||||||
|
global _predict_v2
|
||||||
|
if _predict_v2 is None:
|
||||||
|
try:
|
||||||
|
import importlib.util, sys
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
_predict_v2 = mod
|
||||||
|
except Exception as e:
|
||||||
|
import logging
|
||||||
|
|
||||||
|
logging.error(f"[v1/predictions] predict_v2 import failed: {e}")
|
||||||
|
return _predict_v2
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/predictions", methods=["GET"])
|
||||||
|
@app.route("/turf/api/v1/predictions", methods=["GET"])
|
||||||
|
def api_v1_predictions():
|
||||||
|
"""
|
||||||
|
Ensemble ML predictions using XGBoost + LightGBM + MLP (Optuna-tuned).
|
||||||
|
Query params:
|
||||||
|
- date: YYYY-MM-DD (default: today / latest available)
|
||||||
|
- reunion: int (default: all)
|
||||||
|
- course: int (default: all)
|
||||||
|
"""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
t0 = _time.perf_counter()
|
||||||
|
|
||||||
|
mod = _load_predict_v2()
|
||||||
|
if mod is None:
|
||||||
|
# Graceful fallback: redirect to legacy ml_predictions
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Ensemble model not available yet",
|
||||||
|
"fallback": "/api/ml_predictions",
|
||||||
|
"message": "Model is still training. Use /api/ml_predictions for legacy XGBoost predictions.",
|
||||||
|
}
|
||||||
|
), 503
|
||||||
|
|
||||||
|
ensemble = mod.load_ensemble()
|
||||||
|
if ensemble is None:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"error": "Ensemble model file not found",
|
||||||
|
"model_path": str(mod.ENSEMBLE_PATH),
|
||||||
|
"message": "Run train_ensemble.py to generate the model.",
|
||||||
|
"fallback": "/api/ml_predictions",
|
||||||
|
}
|
||||||
|
), 503
|
||||||
|
|
||||||
|
date_param = request.args.get("date", None)
|
||||||
|
reunion_param = request.args.get("reunion", None)
|
||||||
|
course_param = request.args.get("course", None)
|
||||||
|
|
||||||
|
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
|
||||||
|
# Determine date to use
|
||||||
|
if date_param:
|
||||||
|
date_used = date_param
|
||||||
|
else:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT MAX(date_programme) as d FROM pmu_partants"
|
||||||
|
).fetchone()
|
||||||
|
date_used = (
|
||||||
|
row["d"] if row and row["d"] else datetime.now().strftime("%Y-%m-%d")
|
||||||
|
)
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
where_clauses = ["p.date_programme = ?"]
|
||||||
|
params = [date_used]
|
||||||
|
if reunion_param:
|
||||||
|
where_clauses.append("p.num_reunion = ?")
|
||||||
|
params.append(int(reunion_param))
|
||||||
|
if course_param:
|
||||||
|
where_clauses.append("p.num_course = ?")
|
||||||
|
params.append(int(course_param))
|
||||||
|
|
||||||
|
query = f"""
|
||||||
|
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||||
|
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
|
||||||
|
c.libelle as course_libelle, c.libelle_court as hippodrome,
|
||||||
|
c.heure_depart_str, c.parcours
|
||||||
|
FROM pmu_partants p
|
||||||
|
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
|
||||||
|
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
|
||||||
|
WHERE {" AND ".join(where_clauses)}
|
||||||
|
ORDER BY p.num_reunion, p.num_course, p.num_pmu
|
||||||
|
"""
|
||||||
|
rows = conn.execute(query, params).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"date": date_used,
|
||||||
|
"model_version": mod.get_model_version(),
|
||||||
|
"predictions": [],
|
||||||
|
"message": f"No partants found for date {date_used}",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert to list of dicts
|
||||||
|
partants = [dict(r) for r in rows]
|
||||||
|
|
||||||
|
# Run ensemble prediction
|
||||||
|
preds = mod.predict_top3(partants, model=ensemble)
|
||||||
|
|
||||||
|
# Group by race
|
||||||
|
races = {}
|
||||||
|
for pred in preds:
|
||||||
|
key = f"R{pred.get('num_reunion', 0)}C{pred.get('num_course', 0)}"
|
||||||
|
if key not in races:
|
||||||
|
# Find race metadata from partants
|
||||||
|
for p in partants:
|
||||||
|
if p.get("num_reunion") == pred.get("num_reunion") and p.get(
|
||||||
|
"num_course"
|
||||||
|
) == pred.get("num_course"):
|
||||||
|
races[key] = {
|
||||||
|
"reunion": pred.get("num_reunion"),
|
||||||
|
"course": pred.get("num_course"),
|
||||||
|
"label": key,
|
||||||
|
"race_name": p.get("course_libelle", ""),
|
||||||
|
"hippodrome": p.get("hippodrome", ""),
|
||||||
|
"heure": p.get("heure_depart_str", ""),
|
||||||
|
"discipline": p.get("discipline", ""),
|
||||||
|
"distance": p.get("distance", 0),
|
||||||
|
"horses": [],
|
||||||
|
}
|
||||||
|
break
|
||||||
|
if key in races:
|
||||||
|
races[key]["horses"].append(pred)
|
||||||
|
|
||||||
|
latency_ms = (_time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"date": date_used,
|
||||||
|
"model_version": mod.get_model_version(),
|
||||||
|
"latency_ms": round(latency_ms, 1),
|
||||||
|
"total_horses": len(preds),
|
||||||
|
"races": list(races.values()),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/model/invalidate-cache", methods=["POST"])
|
||||||
|
@app.route("/turf/api/v1/model/invalidate-cache", methods=["POST"])
|
||||||
|
def api_v1_invalidate_cache():
|
||||||
|
"""Force reload of ensemble model on next prediction call."""
|
||||||
|
mod = _load_predict_v2()
|
||||||
|
if mod:
|
||||||
|
mod.invalidate_model_cache()
|
||||||
|
return jsonify({"status": "ok", "message": "Model cache invalidated"})
|
||||||
|
return jsonify({"status": "error", "message": "predict_v2 module not loaded"}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/v1/model/status", methods=["GET"])
|
||||||
|
@app.route("/turf/api/v1/model/status", methods=["GET"])
|
||||||
|
def api_v1_model_status():
|
||||||
|
"""Return ensemble model status and version."""
|
||||||
|
import os as _os
|
||||||
|
from pathlib import Path as _Path
|
||||||
|
|
||||||
|
ensemble_path = _Path("/home/h3r7/turf_saas/models/ensemble_top3.pkl")
|
||||||
|
benchmark_path = _Path("/home/h3r7/turf_saas/models/benchmark_report.json")
|
||||||
|
|
||||||
|
status = {
|
||||||
|
"ensemble_available": ensemble_path.exists(),
|
||||||
|
"ensemble_path": str(ensemble_path),
|
||||||
|
}
|
||||||
|
if ensemble_path.exists():
|
||||||
|
mtime = _os.path.getmtime(str(ensemble_path))
|
||||||
|
status["last_trained"] = datetime.fromtimestamp(mtime).isoformat()
|
||||||
|
|
||||||
|
if benchmark_path.exists():
|
||||||
|
try:
|
||||||
|
with open(benchmark_path) as f:
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
report = _json.load(f)
|
||||||
|
status["benchmark"] = {
|
||||||
|
"baseline_precision_at3": report.get("baseline", {}).get(
|
||||||
|
"precision_at3"
|
||||||
|
),
|
||||||
|
"ensemble_precision_at3": report.get("ensemble", {}).get(
|
||||||
|
"precision_at3"
|
||||||
|
),
|
||||||
|
"delta": report.get("delta_precision_at3"),
|
||||||
|
"deployed": report.get("deploy"),
|
||||||
|
"run_date": report.get("run_date"),
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
mod = _load_predict_v2()
|
||||||
|
if mod and ensemble_path.exists():
|
||||||
|
status["model_version"] = mod.get_model_version()
|
||||||
|
|
||||||
|
return jsonify(status)
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
app.run(host="0.0.0.0", port=8790, debug=False)
|
app.run(host="0.0.0.0", port=8790, debug=False)
|
||||||
|
|||||||
1266
dashboard_saas.html
1266
dashboard_saas.html
File diff suppressed because it is too large
Load Diff
21
infra/turf-saas-leadhunter.service
Normal file
21
infra/turf-saas-leadhunter.service
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
[Unit]
|
||||||
|
Description=H3R7Tech LeadHunter API (Port 8775)
|
||||||
|
Documentation=https://portal-kolifee.duckdns.org
|
||||||
|
After=network.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
User=h3r7
|
||||||
|
WorkingDirectory=/home/h3r7/turf_saas
|
||||||
|
|
||||||
|
# Charger les variables d'environnement depuis /home/h3r7/.env
|
||||||
|
# (notamment GOOGLE_PLACES_API_KEY)
|
||||||
|
EnvironmentFile=/home/h3r7/.env
|
||||||
|
|
||||||
|
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/leadhunter_api.py
|
||||||
|
Restart=always
|
||||||
|
RestartSec=10
|
||||||
|
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
303
leadhunter_api.py
Normal file
303
leadhunter_api.py
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter API
|
||||||
|
===========================
|
||||||
|
Service Flask sur port 8775 exposant les endpoints LeadHunter.
|
||||||
|
|
||||||
|
Endpoints :
|
||||||
|
GET /api/leads — Liste les leads (filtres: status, limit, offset)
|
||||||
|
POST /api/leads/scrape — Lance un job de scraping asynchrone
|
||||||
|
GET /api/leads/stats — Statistiques globales du CRM
|
||||||
|
GET /api/leads/export — Export CSV des leads
|
||||||
|
PATCH /api/leads/<id>/status — Met à jour le statut d'un lead
|
||||||
|
|
||||||
|
Port : 8775 (8769 occupé par depenses_trello/app.py, 8770 occupé par turf_scraper/crm_api.py — corrigé HRT-66)
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import threading
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from flask import Flask, jsonify, request, Response
|
||||||
|
from flask_cors import CORS
|
||||||
|
|
||||||
|
# Import des modules LeadHunter
|
||||||
|
from leadhunter_crm import (
|
||||||
|
init_db,
|
||||||
|
insert_leads,
|
||||||
|
get_leads,
|
||||||
|
get_lead_by_id,
|
||||||
|
update_lead_status,
|
||||||
|
get_stats,
|
||||||
|
export_csv,
|
||||||
|
VALID_STATUSES,
|
||||||
|
DB_PATH,
|
||||||
|
)
|
||||||
|
from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY
|
||||||
|
from leadhunter_scorer import LeadScorer
|
||||||
|
|
||||||
|
# ─── Assertions au démarrage ─────────────────────────────────────────────────
|
||||||
|
# Vérification obligatoire : la clé API doit être présente au démarrage
|
||||||
|
assert os.environ.get("GOOGLE_PLACES_API_KEY"), (
|
||||||
|
"GOOGLE_PLACES_API_KEY manquante. "
|
||||||
|
"Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx"
|
||||||
|
)
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.api")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── App Flask ───────────────────────────────────────────────────────────────
|
||||||
|
app = Flask(__name__)
|
||||||
|
CORS(app)
|
||||||
|
|
||||||
|
# Scorer singleton
|
||||||
|
scorer = LeadScorer()
|
||||||
|
|
||||||
|
# État global du job de scraping (simple flag — pas de celery nécessaire pour le POC)
|
||||||
|
_scrape_job = {
|
||||||
|
"running": False,
|
||||||
|
"last_run": None,
|
||||||
|
"last_count": 0,
|
||||||
|
"last_error": None,
|
||||||
|
}
|
||||||
|
_scrape_lock = threading.Lock()
|
||||||
|
|
||||||
|
# ─── Init DB ─────────────────────────────────────────────────────────────────
|
||||||
|
init_db(DB_PATH)
|
||||||
|
logger.info("LeadHunter API démarrée — DB initialisée.")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None:
|
||||||
|
"""Job de scraping exécuté dans un thread séparé."""
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["running"] = True
|
||||||
|
_scrape_job["last_error"] = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
leads_raw = run_scraping(
|
||||||
|
max_leads=max_leads,
|
||||||
|
use_google=use_google,
|
||||||
|
use_osm=use_osm,
|
||||||
|
)
|
||||||
|
leads_scored = scorer.score_leads(leads_raw)
|
||||||
|
inserted_ids = insert_leads(leads_scored)
|
||||||
|
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["last_count"] = len(inserted_ids)
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
_scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z"
|
||||||
|
|
||||||
|
logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Scrape job erreur : {e}")
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["last_error"] = str(e)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
with _scrape_lock:
|
||||||
|
_scrape_job["running"] = False
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Routes ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads", methods=["GET"])
|
||||||
|
def api_get_leads():
|
||||||
|
"""
|
||||||
|
Liste les leads du CRM.
|
||||||
|
|
||||||
|
Query params :
|
||||||
|
- status (str, optional) : filtre sur new/contacted/closed/rejected
|
||||||
|
- limit (int, default=50) : pagination
|
||||||
|
- offset (int, default=0) : pagination
|
||||||
|
"""
|
||||||
|
status = request.args.get("status")
|
||||||
|
try:
|
||||||
|
limit = int(request.args.get("limit", 50))
|
||||||
|
offset = int(request.args.get("offset", 0))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "limit et offset doivent être des entiers"}), 400
|
||||||
|
|
||||||
|
if status and status not in VALID_STATUSES:
|
||||||
|
return jsonify(
|
||||||
|
{"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"}
|
||||||
|
), 400
|
||||||
|
|
||||||
|
leads = get_leads(status=status, limit=limit, offset=offset)
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"leads": leads,
|
||||||
|
"count": len(leads),
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
"status_filter": status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/scrape", methods=["POST"])
|
||||||
|
def api_scrape():
|
||||||
|
"""
|
||||||
|
Lance un job de scraping asynchrone.
|
||||||
|
|
||||||
|
Body JSON (optionnel) :
|
||||||
|
- max_leads (int, default=100)
|
||||||
|
- use_google (bool, default=true)
|
||||||
|
- use_osm (bool, default=true)
|
||||||
|
|
||||||
|
Retourne immédiatement avec le statut du job.
|
||||||
|
"""
|
||||||
|
with _scrape_lock:
|
||||||
|
if _scrape_job["running"]:
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "already_running",
|
||||||
|
"message": "Un job de scraping est déjà en cours.",
|
||||||
|
}
|
||||||
|
), 409
|
||||||
|
|
||||||
|
body = request.get_json(silent=True) or {}
|
||||||
|
max_leads = int(body.get("max_leads", 100))
|
||||||
|
use_google = bool(body.get("use_google", True))
|
||||||
|
use_osm = bool(body.get("use_osm", True))
|
||||||
|
|
||||||
|
thread = threading.Thread(
|
||||||
|
target=_run_scrape_job,
|
||||||
|
args=(max_leads, use_google, use_osm),
|
||||||
|
daemon=True,
|
||||||
|
)
|
||||||
|
thread.start()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"Job de scraping lancé (max_leads={max_leads}, "
|
||||||
|
f"use_google={use_google}, use_osm={use_osm})"
|
||||||
|
)
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "started",
|
||||||
|
"message": "Job de scraping démarré en arrière-plan.",
|
||||||
|
"params": {
|
||||||
|
"max_leads": max_leads,
|
||||||
|
"use_google": use_google,
|
||||||
|
"use_osm": use_osm,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
), 202
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/scrape/status", methods=["GET"])
|
||||||
|
def api_scrape_status():
|
||||||
|
"""Retourne l'état courant du job de scraping."""
|
||||||
|
with _scrape_lock:
|
||||||
|
return jsonify(dict(_scrape_job))
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/stats", methods=["GET"])
|
||||||
|
def api_stats():
|
||||||
|
"""
|
||||||
|
Statistiques globales du CRM LeadHunter.
|
||||||
|
|
||||||
|
Retourne : total, by_status, by_source, avg_score, top_leads_count
|
||||||
|
"""
|
||||||
|
stats = get_stats()
|
||||||
|
if not stats:
|
||||||
|
return jsonify({"error": "Impossible de calculer les statistiques"}), 500
|
||||||
|
return jsonify(stats)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/export", methods=["GET"])
|
||||||
|
def api_export():
|
||||||
|
"""
|
||||||
|
Export CSV de tous les leads (ou filtrés par status).
|
||||||
|
|
||||||
|
Query params :
|
||||||
|
- status (str, optional)
|
||||||
|
"""
|
||||||
|
status = request.args.get("status")
|
||||||
|
if status and status not in VALID_STATUSES:
|
||||||
|
return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400
|
||||||
|
|
||||||
|
csv_content = export_csv(status=status)
|
||||||
|
filename = f"leadhunter_leads{'_' + status if status else ''}.csv"
|
||||||
|
|
||||||
|
return Response(
|
||||||
|
csv_content,
|
||||||
|
mimetype="text/csv",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": f"attachment; filename={filename}",
|
||||||
|
"Content-Type": "text/csv; charset=utf-8",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/api/leads/<int:lead_id>/status", methods=["PATCH"])
|
||||||
|
def api_update_status(lead_id: int):
|
||||||
|
"""
|
||||||
|
Met à jour le statut d'un lead.
|
||||||
|
|
||||||
|
Body JSON :
|
||||||
|
- status (str) : new | contacted | closed | rejected
|
||||||
|
"""
|
||||||
|
body = request.get_json(silent=True)
|
||||||
|
if not body or "status" not in body:
|
||||||
|
return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400
|
||||||
|
|
||||||
|
new_status = body["status"]
|
||||||
|
if new_status not in VALID_STATUSES:
|
||||||
|
return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400
|
||||||
|
|
||||||
|
lead = get_lead_by_id(lead_id)
|
||||||
|
if not lead:
|
||||||
|
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||||
|
|
||||||
|
success = update_lead_status(lead_id, new_status)
|
||||||
|
if not success:
|
||||||
|
return jsonify({"error": "Mise à jour échouée"}), 500
|
||||||
|
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"success": True,
|
||||||
|
"lead_id": lead_id,
|
||||||
|
"new_status": new_status,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@app.route("/health", methods=["GET"])
|
||||||
|
def health():
|
||||||
|
"""Healthcheck pour systemd / monitoring."""
|
||||||
|
return jsonify(
|
||||||
|
{
|
||||||
|
"status": "ok",
|
||||||
|
"service": "leadhunter-api",
|
||||||
|
"port": 8775,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
app.run(host="0.0.0.0", port=8775, debug=False)
|
||||||
349
leadhunter_crm.py
Normal file
349
leadhunter_crm.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter CRM (SQLite)
|
||||||
|
=====================================
|
||||||
|
Couche de persistance SQLite pour les leads LeadHunter.
|
||||||
|
|
||||||
|
Schéma validé CTO (HRT-66) :
|
||||||
|
CREATE TABLE leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL, -- 'google_places' ou 'osm'
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
rating REAL,
|
||||||
|
reviews_count INTEGER,
|
||||||
|
website TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
rgpd_ok BOOLEAN DEFAULT 1,
|
||||||
|
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'new' -- new, contacted, closed, rejected
|
||||||
|
);
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import logging
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from contextlib import contextmanager
|
||||||
|
from datetime import datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.crm")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||||
|
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||||
|
|
||||||
|
# Statuts valides pour un lead
|
||||||
|
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def init_db(db_path: str = DB_PATH) -> None:
|
||||||
|
"""
|
||||||
|
Crée la base SQLite et la table leads si elle n'existe pas.
|
||||||
|
Idempotent — peut être appelé au démarrage de l'API.
|
||||||
|
"""
|
||||||
|
with sqlite3.connect(db_path) as conn:
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
source TEXT NOT NULL,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
address TEXT,
|
||||||
|
phone TEXT,
|
||||||
|
rating REAL,
|
||||||
|
reviews_count INTEGER,
|
||||||
|
website TEXT,
|
||||||
|
score INTEGER,
|
||||||
|
rgpd_ok BOOLEAN DEFAULT 1,
|
||||||
|
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
status TEXT DEFAULT 'new'
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
conn.commit()
|
||||||
|
logger.info(f"DB initialisée : {db_path}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Context manager ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def _get_conn(db_path: str = DB_PATH):
|
||||||
|
"""Fournit une connexion SQLite avec row_factory."""
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
try:
|
||||||
|
yield conn
|
||||||
|
conn.commit()
|
||||||
|
except Exception as e:
|
||||||
|
conn.rollback()
|
||||||
|
logger.warning(f"DB transaction rollback : {e}")
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def insert_lead(lead: dict, db_path: str = DB_PATH) -> Optional[int]:
|
||||||
|
"""
|
||||||
|
Insère un lead normalisé dans la DB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict avec les champs normalisés (source, name, address, ...)
|
||||||
|
db_path: chemin vers la DB SQLite.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
L'id SQLite du lead inséré, ou None en cas d'erreur.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO leads
|
||||||
|
(source, name, address, phone, rating, reviews_count,
|
||||||
|
website, score, rgpd_ok, status)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
lead.get("source", "unknown"),
|
||||||
|
lead.get("name", ""),
|
||||||
|
lead.get("address", ""),
|
||||||
|
lead.get("phone", ""),
|
||||||
|
lead.get("rating"),
|
||||||
|
lead.get("reviews_count"),
|
||||||
|
lead.get("website", ""),
|
||||||
|
lead.get("score"),
|
||||||
|
1 if lead.get("rgpd_ok", True) else 0,
|
||||||
|
lead.get("status", "new"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
lead_id = cursor.lastrowid
|
||||||
|
logger.info(f"Lead inséré id={lead_id} : {lead.get('name')}")
|
||||||
|
return lead_id
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"insert_lead error : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def insert_leads(leads: list[dict], db_path: str = DB_PATH) -> list[int]:
|
||||||
|
"""
|
||||||
|
Insère une liste de leads en batch.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste des ids insérés.
|
||||||
|
"""
|
||||||
|
ids = []
|
||||||
|
for lead in leads:
|
||||||
|
lead_id = insert_lead(lead, db_path)
|
||||||
|
if lead_id is not None:
|
||||||
|
ids.append(lead_id)
|
||||||
|
logger.info(f"insert_leads : {len(ids)}/{len(leads)} insérés.")
|
||||||
|
return ids
|
||||||
|
|
||||||
|
|
||||||
|
def get_leads(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
limit: int = 100,
|
||||||
|
offset: int = 0,
|
||||||
|
db_path: str = DB_PATH,
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Récupère les leads avec filtre optionnel sur le statut.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: filtre sur le champ 'status' (new, contacted, closed, rejected).
|
||||||
|
limit: pagination — nombre de résultats max.
|
||||||
|
offset: pagination — décalage.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de dicts (tous les champs de la table leads).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
if status:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM leads WHERE status = ? ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(status, limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
else:
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT * FROM leads ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||||
|
(limit, offset),
|
||||||
|
).fetchall()
|
||||||
|
return [dict(r) for r in rows]
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_leads error : {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
|
||||||
|
def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
||||||
|
"""Récupère un lead par son id."""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT * FROM leads WHERE id = ?", (lead_id,)
|
||||||
|
).fetchone()
|
||||||
|
return dict(row) if row else None
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_lead_by_id error : {e}")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||||
|
"""
|
||||||
|
Met à jour le statut d'un lead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead_id: id du lead.
|
||||||
|
status: nouveau statut ('new', 'contacted', 'closed', 'rejected').
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True si mise à jour réussie, False sinon.
|
||||||
|
"""
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
logger.warning(f"update_lead_status : statut invalide '{status}'")
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE leads SET status = ? WHERE id = ?",
|
||||||
|
(status, lead_id),
|
||||||
|
)
|
||||||
|
logger.info(f"Lead id={lead_id} statut → {status}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"update_lead_status error : {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def get_stats(db_path: str = DB_PATH) -> dict:
|
||||||
|
"""
|
||||||
|
Retourne les statistiques globales du CRM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict avec total, by_status, by_source, avg_score, top_leads_count
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with _get_conn(db_path) as conn:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||||
|
|
||||||
|
by_status_rows = conn.execute(
|
||||||
|
"SELECT status, COUNT(*) as cnt FROM leads GROUP BY status"
|
||||||
|
).fetchall()
|
||||||
|
by_status = {r["status"]: r["cnt"] for r in by_status_rows}
|
||||||
|
|
||||||
|
by_source_rows = conn.execute(
|
||||||
|
"SELECT source, COUNT(*) as cnt FROM leads GROUP BY source"
|
||||||
|
).fetchall()
|
||||||
|
by_source = {r["source"]: r["cnt"] for r in by_source_rows}
|
||||||
|
|
||||||
|
avg_score_row = conn.execute(
|
||||||
|
"SELECT AVG(score) FROM leads WHERE score IS NOT NULL"
|
||||||
|
).fetchone()
|
||||||
|
avg_score = round(avg_score_row[0] or 0, 2)
|
||||||
|
|
||||||
|
# Leads "chauds" = score ≥ 5
|
||||||
|
top_count = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM leads WHERE score >= 5"
|
||||||
|
).fetchone()[0]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"by_status": by_status,
|
||||||
|
"by_source": by_source,
|
||||||
|
"avg_score": avg_score,
|
||||||
|
"top_leads_count": top_count,
|
||||||
|
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"get_stats error : {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
|
||||||
|
def export_csv(
|
||||||
|
status: Optional[str] = None,
|
||||||
|
db_path: str = DB_PATH,
|
||||||
|
) -> str:
|
||||||
|
"""
|
||||||
|
Exporte les leads en CSV (string).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
status: filtre optionnel sur le statut.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Contenu CSV en string UTF-8.
|
||||||
|
"""
|
||||||
|
leads = get_leads(status=status, limit=10000, db_path=db_path)
|
||||||
|
|
||||||
|
output = io.StringIO()
|
||||||
|
fieldnames = [
|
||||||
|
"id",
|
||||||
|
"source",
|
||||||
|
"name",
|
||||||
|
"address",
|
||||||
|
"phone",
|
||||||
|
"rating",
|
||||||
|
"reviews_count",
|
||||||
|
"website",
|
||||||
|
"score",
|
||||||
|
"rgpd_ok",
|
||||||
|
"scraped_at",
|
||||||
|
"status",
|
||||||
|
]
|
||||||
|
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||||
|
writer.writeheader()
|
||||||
|
writer.writerows(leads)
|
||||||
|
|
||||||
|
logger.info(f"export_csv : {len(leads)} leads exportés.")
|
||||||
|
return output.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
init_db()
|
||||||
|
|
||||||
|
# Test insertion
|
||||||
|
test_lead = {
|
||||||
|
"source": "google_places",
|
||||||
|
"name": "Restaurant Test",
|
||||||
|
"address": "10 rue de la Paix, 59000 Lille",
|
||||||
|
"phone": "+33 3 20 00 00 01",
|
||||||
|
"rating": 4.5,
|
||||||
|
"reviews_count": 120,
|
||||||
|
"website": "",
|
||||||
|
"score": 8,
|
||||||
|
"rgpd_ok": True,
|
||||||
|
"status": "new",
|
||||||
|
}
|
||||||
|
lead_id = insert_lead(test_lead)
|
||||||
|
print(f"Lead inséré : id={lead_id}")
|
||||||
|
|
||||||
|
leads = get_leads()
|
||||||
|
print(f"Leads en DB : {len(leads)}")
|
||||||
|
|
||||||
|
stats = get_stats()
|
||||||
|
print(f"Stats : {stats}")
|
||||||
193
leadhunter_scorer.py
Normal file
193
leadhunter_scorer.py
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter Scorer
|
||||||
|
================================
|
||||||
|
Moteur de scoring des leads restaurants MEL.
|
||||||
|
|
||||||
|
Critères (ordre de priorité métier) :
|
||||||
|
1. [+3] Site web absent ← CRITIQUE : raison d'être du produit
|
||||||
|
2. [+2] Nombre d'avis élevé (≥ 50) : forte activité = bon prospect de vente
|
||||||
|
3. [+2] Note Google élevée (≥ 4.0) : établissement sérieux
|
||||||
|
4. [+1] Téléphone présent : facilite la prise de contact
|
||||||
|
5. [-1] Note faible (< 3.0) : risque reputationnel pour la prestation web
|
||||||
|
|
||||||
|
Score maximum théorique : 8
|
||||||
|
Score minimum : 0 (leads avec site web ne doivent pas passer ici)
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.scorer")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024,
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Scorer ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class LeadScorer:
|
||||||
|
"""
|
||||||
|
Calcule le score de priorité d'un lead.
|
||||||
|
|
||||||
|
Le score sert à trier les leads dans le CRM :
|
||||||
|
- Score élevé = prospect chaud (sans site + actif + bien noté)
|
||||||
|
- Score faible = prospect froid (peut être ignoré ou traité en dernier)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def _calculate_score(self, lead: dict) -> int:
|
||||||
|
"""
|
||||||
|
Calcule le score d'un lead.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict avec les champs normalisés du scraper
|
||||||
|
(name, website, rating, reviews_count, phone, ...)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Score entier (0–8)
|
||||||
|
"""
|
||||||
|
score = 0
|
||||||
|
|
||||||
|
# ── Critère 1 : site web absent [CRITIQUE — logique métier centrale] ──
|
||||||
|
# C'est le critère n°1 : on cherche des restaurants SANS site web
|
||||||
|
# pour leur proposer une création de site à 800–1500€.
|
||||||
|
website = lead.get("website", "")
|
||||||
|
if not website or not website.strip():
|
||||||
|
score += 3
|
||||||
|
logger.debug(f"{lead.get('name')}: +3 (site web absent)")
|
||||||
|
else:
|
||||||
|
# Si le lead a un site web, score = 0 immédiatement.
|
||||||
|
# Ce cas ne devrait pas se produire (filtre scraper),
|
||||||
|
# mais on reste défensif.
|
||||||
|
logger.warning(
|
||||||
|
f"{lead.get('name')}: site web présent ({website}), "
|
||||||
|
"lead ignoré pour scoring."
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
# ── Critère 2 : nombre d'avis élevé (≥ 50) ──────────────────────────
|
||||||
|
reviews = lead.get("reviews_count")
|
||||||
|
if reviews is not None:
|
||||||
|
try:
|
||||||
|
reviews = int(reviews)
|
||||||
|
if reviews >= 50:
|
||||||
|
score += 2
|
||||||
|
logger.debug(f"{lead.get('name')}: +2 (avis ≥ 50 : {reviews})")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
logger.warning(f"reviews_count invalide pour {lead.get('name')}: {e}")
|
||||||
|
|
||||||
|
# ── Critère 3 : bonne note Google (≥ 4.0) ───────────────────────────
|
||||||
|
rating = lead.get("rating")
|
||||||
|
if rating is not None:
|
||||||
|
try:
|
||||||
|
rating = float(rating)
|
||||||
|
if rating >= 4.0:
|
||||||
|
score += 2
|
||||||
|
logger.debug(f"{lead.get('name')}: +2 (note ≥ 4.0 : {rating})")
|
||||||
|
elif rating < 3.0:
|
||||||
|
score -= 1
|
||||||
|
logger.debug(f"{lead.get('name')}: -1 (note < 3.0 : {rating})")
|
||||||
|
except (TypeError, ValueError) as e:
|
||||||
|
logger.warning(f"rating invalide pour {lead.get('name')}: {e}")
|
||||||
|
|
||||||
|
# ── Critère 4 : téléphone présent ────────────────────────────────────
|
||||||
|
phone = lead.get("phone", "")
|
||||||
|
if phone and phone.strip():
|
||||||
|
score += 1
|
||||||
|
logger.debug(f"{lead.get('name')}: +1 (téléphone présent)")
|
||||||
|
|
||||||
|
# Plancher à 0
|
||||||
|
score = max(0, score)
|
||||||
|
logger.info(f"Score calculé pour '{lead.get('name')}' : {score}/8")
|
||||||
|
return score
|
||||||
|
|
||||||
|
def score_lead(self, lead: dict) -> dict:
|
||||||
|
"""
|
||||||
|
Enrichit un lead avec son score.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
lead: dict normalisé du scraper.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Même dict avec le champ 'score' ajouté/mis à jour.
|
||||||
|
"""
|
||||||
|
lead = dict(lead) # copie défensive
|
||||||
|
lead["score"] = self._calculate_score(lead)
|
||||||
|
return lead
|
||||||
|
|
||||||
|
def score_leads(self, leads: list[dict]) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Score et trie une liste de leads (score décroissant).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
leads: liste de dicts normalisés.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste triée par score décroissant.
|
||||||
|
"""
|
||||||
|
scored = [self.score_lead(lead) for lead in leads]
|
||||||
|
scored.sort(key=lambda l: l.get("score", 0), reverse=True)
|
||||||
|
logger.info(
|
||||||
|
f"score_leads terminé : {len(scored)} leads scorés. "
|
||||||
|
f"Score max = {scored[0]['score'] if scored else 0}, "
|
||||||
|
f"Score min = {scored[-1]['score'] if scored else 0}"
|
||||||
|
)
|
||||||
|
return scored
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Exemple de test rapide sans appel API
|
||||||
|
test_leads = [
|
||||||
|
{
|
||||||
|
"name": "Restaurant A",
|
||||||
|
"website": "",
|
||||||
|
"rating": 4.5,
|
||||||
|
"reviews_count": 120,
|
||||||
|
"phone": "+33 3 20 00 00 01",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Restaurant B",
|
||||||
|
"website": "",
|
||||||
|
"rating": 3.8,
|
||||||
|
"reviews_count": 30,
|
||||||
|
"phone": "",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Café C",
|
||||||
|
"website": "",
|
||||||
|
"rating": 2.5,
|
||||||
|
"reviews_count": 5,
|
||||||
|
"phone": "+33 3 20 00 00 03",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bar D avec site",
|
||||||
|
"website": "https://bar-d.fr",
|
||||||
|
"rating": 4.2,
|
||||||
|
"reviews_count": 80,
|
||||||
|
"phone": "+33 3 20 00 00 04",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
scorer = LeadScorer()
|
||||||
|
results = scorer.score_leads(test_leads)
|
||||||
|
|
||||||
|
print("\n=== Résultats scoring ===")
|
||||||
|
for r in results:
|
||||||
|
print(f" [{r['score']:2d}/8] {r['name']}")
|
||||||
397
leadhunter_scraper.py
Normal file
397
leadhunter_scraper.py
Normal file
@@ -0,0 +1,397 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
H3R7Tech — LeadHunter Scraper
|
||||||
|
================================
|
||||||
|
Agent de scraping pour la détection de restaurants sans site web
|
||||||
|
dans la MEL (Métropole Européenne de Lille).
|
||||||
|
|
||||||
|
Sources :
|
||||||
|
- Google Places API (primary)
|
||||||
|
- OpenStreetMap / Overpass API (fallback)
|
||||||
|
|
||||||
|
Quota Google Places Free Tier :
|
||||||
|
- 28 500 requêtes/mois ≈ 950/jour
|
||||||
|
- Compteur persistent dans /home/h3r7/leadhunter_quota.json
|
||||||
|
|
||||||
|
Auteur: H3R7Tech Backend Engineer
|
||||||
|
Issue: HRT-66
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
import requests
|
||||||
|
from datetime import date, datetime
|
||||||
|
from logging.handlers import RotatingFileHandler
|
||||||
|
|
||||||
|
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||||
|
logger = logging.getLogger("leadhunter.scraper")
|
||||||
|
|
||||||
|
_handler = RotatingFileHandler(
|
||||||
|
"/home/h3r7/leadhunter.log",
|
||||||
|
maxBytes=5 * 1024 * 1024, # 5 MB
|
||||||
|
backupCount=3,
|
||||||
|
)
|
||||||
|
_handler.setFormatter(
|
||||||
|
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||||
|
)
|
||||||
|
logger.setLevel(logging.INFO)
|
||||||
|
if not logger.handlers:
|
||||||
|
logger.addHandler(_handler)
|
||||||
|
logger.addHandler(logging.StreamHandler())
|
||||||
|
|
||||||
|
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||||
|
GOOGLE_PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY")
|
||||||
|
|
||||||
|
# Quota journalier Google Places Free Tier
|
||||||
|
DAILY_QUOTA_FILE = "/home/h3r7/leadhunter_quota.json"
|
||||||
|
DAILY_QUOTA_LIMIT = 900 # marge de sécurité vs les 950 théoriques
|
||||||
|
|
||||||
|
# Délai entre requêtes Places pour éviter rate-limiting
|
||||||
|
PLACES_SLEEP_S = 0.5
|
||||||
|
|
||||||
|
# Bounding box MEL (Métropole Européenne de Lille)
|
||||||
|
MEL_CENTER_LAT = 50.6292
|
||||||
|
MEL_CENTER_LNG = 3.0573
|
||||||
|
MEL_RADIUS_M = 20000 # 20 km autour de Lille
|
||||||
|
|
||||||
|
# Types de lieux ciblés
|
||||||
|
TARGET_TYPES = ["restaurant", "cafe", "bar", "bakery", "food"]
|
||||||
|
|
||||||
|
# Overpass API endpoint
|
||||||
|
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||||
|
|
||||||
|
# Requête Overpass MEL — bounding box directe (50.4,2.8,50.8,3.3) couvrant la MEL
|
||||||
|
# Fix HRT-72 : la résolution area["name"=...] échoue silencieusement sur l'API Overpass publique
|
||||||
|
OVERPASS_MEL_QUERY = """
|
||||||
|
[out:json][timeout:60];
|
||||||
|
(
|
||||||
|
node["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||||
|
way["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||||
|
);
|
||||||
|
out center 200;
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Quota Manager ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _load_quota() -> dict:
|
||||||
|
"""Charge le compteur quotidien depuis le fichier JSON."""
|
||||||
|
today = str(date.today())
|
||||||
|
if os.path.exists(DAILY_QUOTA_FILE):
|
||||||
|
try:
|
||||||
|
with open(DAILY_QUOTA_FILE, "r") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
if data.get("date") == today:
|
||||||
|
return data
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossible de lire le fichier quota : {e}")
|
||||||
|
return {"date": today, "count": 0}
|
||||||
|
|
||||||
|
|
||||||
|
def _save_quota(data: dict) -> None:
|
||||||
|
"""Persiste le compteur quotidien."""
|
||||||
|
try:
|
||||||
|
with open(DAILY_QUOTA_FILE, "w") as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Impossible d'écrire le fichier quota : {e}")
|
||||||
|
|
||||||
|
|
||||||
|
def _increment_quota(n: int = 1) -> int:
|
||||||
|
"""Incrémente le compteur et retourne le total du jour."""
|
||||||
|
quota = _load_quota()
|
||||||
|
quota["count"] += n
|
||||||
|
_save_quota(quota)
|
||||||
|
return quota["count"]
|
||||||
|
|
||||||
|
|
||||||
|
def _quota_remaining() -> int:
|
||||||
|
"""Retourne le nombre de requêtes restantes pour aujourd'hui."""
|
||||||
|
quota = _load_quota()
|
||||||
|
return max(0, DAILY_QUOTA_LIMIT - quota["count"])
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Google Places Scraper ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class GooglePlacesScraper:
|
||||||
|
"""
|
||||||
|
Scraping via Google Places API (Nearby Search + Place Details).
|
||||||
|
Filtre les lieux sans site web côté API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
BASE_URL = "https://maps.googleapis.com/maps/api/place"
|
||||||
|
|
||||||
|
def __init__(self):
|
||||||
|
if not GOOGLE_PLACES_API_KEY:
|
||||||
|
raise EnvironmentError(
|
||||||
|
"GOOGLE_PLACES_API_KEY non définie. "
|
||||||
|
"Ajouter dans /home/h3r7/.env et relancer."
|
||||||
|
)
|
||||||
|
self.api_key = GOOGLE_PLACES_API_KEY
|
||||||
|
|
||||||
|
def _nearby_search(self, place_type: str, page_token: str = None) -> dict:
|
||||||
|
"""Appel Nearby Search — 1 requête comptabilisée."""
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"location": f"{MEL_CENTER_LAT},{MEL_CENTER_LNG}",
|
||||||
|
"radius": MEL_RADIUS_M,
|
||||||
|
"type": place_type,
|
||||||
|
}
|
||||||
|
if page_token:
|
||||||
|
params["pagetoken"] = page_token
|
||||||
|
|
||||||
|
_increment_quota()
|
||||||
|
time.sleep(PLACES_SLEEP_S)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{self.BASE_URL}/nearbysearch/json",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"NearbySearch error (type={place_type}): {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def _place_details(self, place_id: str) -> dict:
|
||||||
|
"""Place Details pour récupérer website, phone, rating, etc. — 1 requête."""
|
||||||
|
params = {
|
||||||
|
"key": self.api_key,
|
||||||
|
"place_id": place_id,
|
||||||
|
"fields": "name,formatted_address,formatted_phone_number,website,rating,user_ratings_total",
|
||||||
|
}
|
||||||
|
|
||||||
|
_increment_quota()
|
||||||
|
time.sleep(PLACES_SLEEP_S)
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.get(
|
||||||
|
f"{self.BASE_URL}/details/json",
|
||||||
|
params=params,
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return resp.json().get("result", {})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"PlaceDetails error (place_id={place_id}): {e}")
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def scrape(self, max_leads: int = 50) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Scrape les restaurants/cafés/bars MEL sans site web.
|
||||||
|
|
||||||
|
Retourne une liste de dicts normalisés compatibles LeadHunter CRM :
|
||||||
|
source, name, address, phone, rating, reviews_count, website, rgpd_ok
|
||||||
|
"""
|
||||||
|
leads = []
|
||||||
|
seen_ids = set()
|
||||||
|
|
||||||
|
for place_type in TARGET_TYPES:
|
||||||
|
if _quota_remaining() < 10:
|
||||||
|
logger.warning(
|
||||||
|
"Quota journalier presque épuisé — arrêt scraping Google Places."
|
||||||
|
)
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"Scraping Google Places — type={place_type}")
|
||||||
|
page_token = None
|
||||||
|
|
||||||
|
while True:
|
||||||
|
if _quota_remaining() < 5:
|
||||||
|
logger.warning("Quota insuffisant pour continuer la pagination.")
|
||||||
|
break
|
||||||
|
|
||||||
|
data = self._nearby_search(place_type, page_token)
|
||||||
|
results = data.get("results", [])
|
||||||
|
|
||||||
|
for place in results:
|
||||||
|
if len(leads) >= max_leads:
|
||||||
|
break
|
||||||
|
|
||||||
|
place_id = place.get("place_id", "")
|
||||||
|
if not place_id or place_id in seen_ids:
|
||||||
|
continue
|
||||||
|
seen_ids.add(place_id)
|
||||||
|
|
||||||
|
if _quota_remaining() < 2:
|
||||||
|
logger.warning("Quota épuisé avant details.")
|
||||||
|
break
|
||||||
|
|
||||||
|
details = self._place_details(place_id)
|
||||||
|
|
||||||
|
# Filtre : on ne garde que les lieux SANS site web
|
||||||
|
if details.get("website"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
lead = {
|
||||||
|
"source": "google_places",
|
||||||
|
"name": details.get("name") or place.get("name", ""),
|
||||||
|
"address": details.get("formatted_address")
|
||||||
|
or place.get("vicinity", ""),
|
||||||
|
"phone": details.get("formatted_phone_number", ""),
|
||||||
|
"rating": details.get("rating") or place.get("rating"),
|
||||||
|
"reviews_count": details.get("user_ratings_total")
|
||||||
|
or place.get("user_ratings_total"),
|
||||||
|
"website": "",
|
||||||
|
"rgpd_ok": True, # Données publiques Google Places uniquement
|
||||||
|
}
|
||||||
|
leads.append(lead)
|
||||||
|
logger.info(f"Lead trouvé (Google Places) : {lead['name']}")
|
||||||
|
|
||||||
|
if len(leads) >= max_leads:
|
||||||
|
break
|
||||||
|
|
||||||
|
page_token = data.get("next_page_token")
|
||||||
|
if not page_token:
|
||||||
|
break
|
||||||
|
|
||||||
|
# L'API Google Places nécessite un délai avant d'utiliser next_page_token
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
logger.info(f"Google Places : {len(leads)} leads collectés.")
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Overpass / OSM Fallback ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class OverpassScraper:
|
||||||
|
"""
|
||||||
|
Fallback OSM via Overpass API.
|
||||||
|
Cible les nœuds/ways dans la boundary MEL sans attribut 'website'.
|
||||||
|
Données publiques ODbL — RGPD OK.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def scrape(self, max_leads: int = 100) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Scrape via Overpass API — retourne des leads normalisés.
|
||||||
|
"""
|
||||||
|
logger.info("Scraping Overpass OSM — boundary MEL")
|
||||||
|
leads = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
OVERPASS_URL,
|
||||||
|
data={"data": OVERPASS_MEL_QUERY},
|
||||||
|
headers={
|
||||||
|
"Content-Type": "application/x-www-form-urlencoded", # Fix HRT-72 Bug2
|
||||||
|
"User-Agent": "H3R7Tech-LeadHunter/1.0 (contact@h3r7tech.fr)", # Fix HRT-72 Bug3: overpass-api.de blocks python-requests UA
|
||||||
|
},
|
||||||
|
timeout=90,
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"Overpass API error : {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
elements = data.get("elements", [])
|
||||||
|
logger.info(f"Overpass : {len(elements)} éléments bruts reçus.")
|
||||||
|
|
||||||
|
for el in elements[:max_leads]:
|
||||||
|
tags = el.get("tags", {})
|
||||||
|
|
||||||
|
# Coordonnées (pour les ways, Overpass retourne 'center')
|
||||||
|
lat = el.get("lat") or (el.get("center") or {}).get("lat")
|
||||||
|
lon = el.get("lon") or (el.get("center") or {}).get("lon")
|
||||||
|
|
||||||
|
name = tags.get("name", "")
|
||||||
|
if not name:
|
||||||
|
continue # Ignorer les lieux sans nom
|
||||||
|
|
||||||
|
addr_parts = [
|
||||||
|
tags.get("addr:housenumber", ""),
|
||||||
|
tags.get("addr:street", ""),
|
||||||
|
tags.get("addr:city", ""),
|
||||||
|
tags.get("addr:postcode", ""),
|
||||||
|
]
|
||||||
|
address = " ".join(p for p in addr_parts if p).strip()
|
||||||
|
if not address and lat and lon:
|
||||||
|
address = f"{lat:.4f},{lon:.4f}"
|
||||||
|
|
||||||
|
lead = {
|
||||||
|
"source": "osm",
|
||||||
|
"name": name,
|
||||||
|
"address": address,
|
||||||
|
"phone": tags.get("phone", tags.get("contact:phone", "")),
|
||||||
|
"rating": None,
|
||||||
|
"reviews_count": None,
|
||||||
|
"website": "",
|
||||||
|
"rgpd_ok": True, # Données publiques ODbL
|
||||||
|
}
|
||||||
|
leads.append(lead)
|
||||||
|
logger.info(f"Lead trouvé (OSM) : {lead['name']}")
|
||||||
|
|
||||||
|
logger.info(f"Overpass : {len(leads)} leads collectés.")
|
||||||
|
return leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Orchestrateur ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def run_scraping(
|
||||||
|
max_leads: int = 100, use_google: bool = True, use_osm: bool = True
|
||||||
|
) -> list[dict]:
|
||||||
|
"""
|
||||||
|
Lance le scraping Google Places + fallback OSM.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
max_leads: nombre maximum de leads à collecter au total.
|
||||||
|
use_google: activer Google Places (nécessite GOOGLE_PLACES_API_KEY).
|
||||||
|
use_osm: activer le fallback Overpass OSM.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Liste de leads normalisés (dédupliqués par nom + adresse).
|
||||||
|
"""
|
||||||
|
all_leads = []
|
||||||
|
seen_keys = set()
|
||||||
|
|
||||||
|
def _dedup_key(lead: dict) -> str:
|
||||||
|
return f"{lead['name'].lower().strip()}|{lead['address'].lower().strip()[:40]}"
|
||||||
|
|
||||||
|
if use_google:
|
||||||
|
try:
|
||||||
|
scraper = GooglePlacesScraper()
|
||||||
|
google_leads = scraper.scrape(max_leads=max_leads)
|
||||||
|
for lead in google_leads:
|
||||||
|
k = _dedup_key(lead)
|
||||||
|
if k not in seen_keys:
|
||||||
|
seen_keys.add(k)
|
||||||
|
all_leads.append(lead)
|
||||||
|
except EnvironmentError as e:
|
||||||
|
logger.warning(f"Google Places désactivé : {e}")
|
||||||
|
use_google = False
|
||||||
|
|
||||||
|
remaining = max_leads - len(all_leads)
|
||||||
|
if use_osm and remaining > 0:
|
||||||
|
osm_leads = OverpassScraper().scrape(max_leads=remaining)
|
||||||
|
for lead in osm_leads:
|
||||||
|
k = _dedup_key(lead)
|
||||||
|
if k not in seen_keys:
|
||||||
|
seen_keys.add(k)
|
||||||
|
all_leads.append(lead)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"run_scraping terminé — {len(all_leads)} leads uniques "
|
||||||
|
f"(Google={use_google}, OSM={use_osm}). "
|
||||||
|
f"Quota restant aujourd'hui : {_quota_remaining()}"
|
||||||
|
)
|
||||||
|
return all_leads
|
||||||
|
|
||||||
|
|
||||||
|
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
assert GOOGLE_PLACES_API_KEY, (
|
||||||
|
"GOOGLE_PLACES_API_KEY manquante — "
|
||||||
|
"ajouter 'export GOOGLE_PLACES_API_KEY=xxx' dans /home/h3r7/.env"
|
||||||
|
)
|
||||||
|
leads = run_scraping(max_leads=10)
|
||||||
|
for i, l in enumerate(leads, 1):
|
||||||
|
print(f"{i:02d}. [{l['source']}] {l['name']} — {l['address']}")
|
||||||
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
|
||||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||||
|
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||||
|
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||||
|
|
||||||
|
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||||
|
|
||||||
|
Stratégies :
|
||||||
|
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||||
|
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||||
|
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||||
|
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||||
|
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||||
|
python3 ml_feedback_saas.py --date 2026-04-25
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
|
|
||||||
|
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||||
|
|
||||||
|
logging.basicConfig(
|
||||||
|
level=logging.INFO,
|
||||||
|
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||||
|
handlers=[
|
||||||
|
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||||
|
logging.StreamHandler(),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# UTILITAIRES
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||||
|
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM paris
|
||||||
|
WHERE date_course = ? AND source_reco = ?
|
||||||
|
AND type_pari = ? AND numero1 = ?
|
||||||
|
AND race_label = ?
|
||||||
|
""",
|
||||||
|
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||||
|
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id FROM paris
|
||||||
|
WHERE date_course = ? AND source_reco = ?
|
||||||
|
AND race_label = ?
|
||||||
|
""",
|
||||||
|
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||||
|
)
|
||||||
|
return cursor.fetchone() is not None
|
||||||
|
|
||||||
|
|
||||||
|
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||||
|
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||||
|
ml_score, odds, recommendation, is_value_bet,
|
||||||
|
race_label, race_name, hippodrome, heure,
|
||||||
|
discipline, distance
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
AND ml_score >= ?
|
||||||
|
ORDER BY num_reunion, num_course, ml_score DESC
|
||||||
|
""",
|
||||||
|
(date, min_score),
|
||||||
|
)
|
||||||
|
rows = cursor.fetchall()
|
||||||
|
|
||||||
|
courses = {}
|
||||||
|
for r in rows:
|
||||||
|
key = (r["num_reunion"], r["num_course"])
|
||||||
|
if key not in courses:
|
||||||
|
courses[key] = []
|
||||||
|
if len(courses[key]) < n:
|
||||||
|
courses[key].append(dict(r))
|
||||||
|
return courses
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_sg(conn, date):
|
||||||
|
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
cheval = chevaux[0]
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
num_reunion,
|
||||||
|
num_course,
|
||||||
|
cheval["horse_number"],
|
||||||
|
"simple_gagnant",
|
||||||
|
"xgboost_sg",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
cheval.get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
cheval.get("hippodrome") or "",
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_number"],
|
||||||
|
cheval["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_value(conn, date):
|
||||||
|
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||||
|
ml_score, odds, race_label, race_name, hippodrome
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ? AND is_value_bet = 1
|
||||||
|
ORDER BY num_reunion, num_course, ml_score DESC
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
rows = [dict(r) for r in cursor.fetchall()]
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for r in rows:
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
r["num_reunion"],
|
||||||
|
r["num_course"],
|
||||||
|
r["horse_number"],
|
||||||
|
"simple_gagnant",
|
||||||
|
"xgboost_value",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
r.get("race_name") or "",
|
||||||
|
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||||
|
r.get("hippodrome") or "",
|
||||||
|
r["horse_name"],
|
||||||
|
r["horse_name"],
|
||||||
|
r["horse_number"],
|
||||||
|
r["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_sp(conn, date):
|
||||||
|
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
cheval = chevaux[0]
|
||||||
|
if pari_existe(
|
||||||
|
cursor,
|
||||||
|
date,
|
||||||
|
num_reunion,
|
||||||
|
num_course,
|
||||||
|
cheval["horse_number"],
|
||||||
|
"simple_place",
|
||||||
|
"xgboost_sp",
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
cheval.get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
cheval.get("hippodrome") or "",
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_name"],
|
||||||
|
cheval["horse_number"],
|
||||||
|
cheval["odds"],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def save_ml_paris_2sur4(conn, date):
|
||||||
|
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||||
|
inseres = 0
|
||||||
|
|
||||||
|
for (num_reunion, num_course), chevaux in courses.items():
|
||||||
|
if len(chevaux) < 4:
|
||||||
|
continue
|
||||||
|
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||||
|
continue
|
||||||
|
|
||||||
|
top4 = chevaux[:4]
|
||||||
|
nums = [str(c["horse_number"]) for c in top4]
|
||||||
|
noms = [c["horse_name"] for c in top4]
|
||||||
|
chevaux_str = "/".join(noms)
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
INSERT INTO paris
|
||||||
|
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||||
|
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||||
|
statut, gain, source_reco, model_source, commentaire)
|
||||||
|
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||||
|
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||||
|
""",
|
||||||
|
(
|
||||||
|
date,
|
||||||
|
date,
|
||||||
|
top4[0].get("race_name") or "",
|
||||||
|
f"R{num_reunion}C{num_course}",
|
||||||
|
top4[0].get("hippodrome") or "",
|
||||||
|
chevaux_str,
|
||||||
|
top4[0]["horse_name"],
|
||||||
|
top4[0]["horse_number"],
|
||||||
|
f"top4 ML: {'/'.join(nums)}",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
inseres += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||||
|
return inseres
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# UPDATE RÉSULTATS + DIVIDENDES
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def update_ml_paris_results(conn, date):
|
||||||
|
"""
|
||||||
|
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||||
|
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||||
|
"""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||||
|
FROM paris
|
||||||
|
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||||
|
AND source_reco LIKE 'xgboost%'
|
||||||
|
""",
|
||||||
|
(date,),
|
||||||
|
)
|
||||||
|
paris = [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
if not paris:
|
||||||
|
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
maj = 0
|
||||||
|
for pari in paris:
|
||||||
|
pari_id = pari["id"]
|
||||||
|
race_label = pari["race_label"] or ""
|
||||||
|
type_pari = pari["type_pari"]
|
||||||
|
numero1 = pari["numero1"]
|
||||||
|
mise = pari["mise"]
|
||||||
|
|
||||||
|
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||||
|
try:
|
||||||
|
parts = race_label.replace("R", "").split("C")
|
||||||
|
num_reunion = int(parts[0])
|
||||||
|
num_course = int(parts[1])
|
||||||
|
except Exception:
|
||||||
|
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
if type_pari == "simple_gagnant":
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT ordre_arrivee FROM pmu_partants
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND num_pmu = ?
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gagne = row["ordre_arrivee"] == 1
|
||||||
|
gain = 0.0
|
||||||
|
if gagne:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||||
|
AND CAST(combinaison AS INTEGER) = ?
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
div = cursor.fetchone()
|
||||||
|
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
elif type_pari == "simple_place":
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT ordre_arrivee FROM pmu_partants
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND num_pmu = ?
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
if not row or not row["ordre_arrivee"]:
|
||||||
|
continue
|
||||||
|
|
||||||
|
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||||
|
gain = 0.0
|
||||||
|
if gagne:
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||||
|
AND CAST(combinaison AS INTEGER) = ?
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course, numero1),
|
||||||
|
)
|
||||||
|
div = cursor.fetchone()
|
||||||
|
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
elif type_pari == "deux_sur_quatre":
|
||||||
|
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||||
|
try:
|
||||||
|
nums_str = (
|
||||||
|
pari["commentaire"].split(": ")[1]
|
||||||
|
if pari.get("commentaire")
|
||||||
|
else ""
|
||||||
|
)
|
||||||
|
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||||
|
except Exception:
|
||||||
|
nums_top4 = []
|
||||||
|
|
||||||
|
if len(nums_top4) < 4:
|
||||||
|
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT horse_number FROM ml_predictions_cache
|
||||||
|
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||||
|
ORDER BY ml_score DESC LIMIT 4
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course),
|
||||||
|
)
|
||||||
|
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
if len(nums_top4) < 2:
|
||||||
|
continue
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||||
|
WHERE date_programme = ? AND num_reunion = ?
|
||||||
|
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||||
|
AND libelle NOT LIKE '%NP%'
|
||||||
|
""",
|
||||||
|
(date, num_reunion, num_course),
|
||||||
|
)
|
||||||
|
rapports = [dict(r) for r in cursor.fetchall()]
|
||||||
|
gain_total = 0.0
|
||||||
|
|
||||||
|
for rap in rapports:
|
||||||
|
try:
|
||||||
|
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
if n1 in nums_top4 and n2 in nums_top4:
|
||||||
|
gain_total += rap["dividende_euro"]
|
||||||
|
|
||||||
|
gagne = gain_total > 0
|
||||||
|
cursor.execute(
|
||||||
|
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||||
|
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||||
|
)
|
||||||
|
maj += 1
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||||
|
return maj
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# STATS PAR STRATÉGIE
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||||
|
"""Stats performances ML par stratégie (source_reco)."""
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"""
|
||||||
|
SELECT source_reco,
|
||||||
|
COUNT(*) as n_paris,
|
||||||
|
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||||
|
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||||
|
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||||
|
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||||
|
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||||
|
ROUND(SUM(gain), 2) as gain_total,
|
||||||
|
ROUND(SUM(mise), 2) as mise_totale,
|
||||||
|
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||||
|
FROM paris
|
||||||
|
WHERE source_reco LIKE 'xgboost%'
|
||||||
|
AND (:debut IS NULL OR date_course >= :debut)
|
||||||
|
AND (:fin IS NULL OR date_course <= :fin)
|
||||||
|
GROUP BY source_reco
|
||||||
|
ORDER BY source_reco
|
||||||
|
""",
|
||||||
|
{"debut": date_debut, "fin": date_fin},
|
||||||
|
)
|
||||||
|
return [dict(r) for r in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# PIPELINE COMPLET
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def run(date):
|
||||||
|
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||||
|
conn = get_db()
|
||||||
|
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||||
|
|
||||||
|
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||||
|
sg = save_ml_paris_sg(conn, date)
|
||||||
|
vb = save_ml_paris_value(conn, date)
|
||||||
|
sp = save_ml_paris_sp(conn, date)
|
||||||
|
s4 = save_ml_paris_2sur4(conn, date)
|
||||||
|
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||||
|
|
||||||
|
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||||
|
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||||
|
"%Y-%m-%d"
|
||||||
|
)
|
||||||
|
maj = update_ml_paris_results(conn, yesterday)
|
||||||
|
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||||
|
|
||||||
|
|
||||||
|
def backfill(date):
|
||||||
|
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||||
|
conn = get_db()
|
||||||
|
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||||
|
|
||||||
|
sg = save_ml_paris_sg(conn, date)
|
||||||
|
vb = save_ml_paris_value(conn, date)
|
||||||
|
sp = save_ml_paris_sp(conn, date)
|
||||||
|
s4 = save_ml_paris_2sur4(conn, date)
|
||||||
|
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||||
|
|
||||||
|
maj = update_ml_paris_results(conn, date)
|
||||||
|
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
return sg + vb + sp + s4, maj
|
||||||
|
|
||||||
|
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
# MAIN
|
||||||
|
# ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if "--backfill" in sys.argv:
|
||||||
|
idx = sys.argv.index("--backfill")
|
||||||
|
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||||
|
if not date:
|
||||||
|
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||||
|
sys.exit(1)
|
||||||
|
inseres, maj = backfill(date)
|
||||||
|
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||||
|
|
||||||
|
elif "--date" in sys.argv:
|
||||||
|
idx = sys.argv.index("--date")
|
||||||
|
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||||
|
if not date:
|
||||||
|
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||||
|
sys.exit(1)
|
||||||
|
result = run(date)
|
||||||
|
total = sum(result["inseres"].values())
|
||||||
|
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||||
|
|
||||||
|
else:
|
||||||
|
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||||
|
total = sum(result["inseres"].values())
|
||||||
|
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||||
174
models/benchmark_report.json
Normal file
174
models/benchmark_report.json
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
{
|
||||||
|
"run_date": "2026-04-25T19:09:46.629142",
|
||||||
|
"dataset": {
|
||||||
|
"db_path": "/home/h3r7/turf_saas/turf.db",
|
||||||
|
"total_rows": 10899,
|
||||||
|
"train_rows": 8719,
|
||||||
|
"holdout_rows": 2180,
|
||||||
|
"train_date_range": [
|
||||||
|
"2026-03-31",
|
||||||
|
"2026-04-19"
|
||||||
|
],
|
||||||
|
"holdout_date_range": [
|
||||||
|
"2026-04-19",
|
||||||
|
"2026-04-24"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"baseline": {
|
||||||
|
"model": "XGBoost (baseline)",
|
||||||
|
"precision_at3": 0.5286821705426358,
|
||||||
|
"auc": 0.7254057665061495
|
||||||
|
},
|
||||||
|
"individual_models": {
|
||||||
|
"xgboost": {
|
||||||
|
"model": "xgboost",
|
||||||
|
"auc": 0.7856,
|
||||||
|
"accuracy": 0.6917,
|
||||||
|
"precision": 0.4865,
|
||||||
|
"recall": 0.7229,
|
||||||
|
"precision_at3": 0.5783,
|
||||||
|
"latency_ms_per_row": 0.0112
|
||||||
|
},
|
||||||
|
"lightgbm": {
|
||||||
|
"model": "lightgbm",
|
||||||
|
"auc": 0.7833,
|
||||||
|
"accuracy": 0.6995,
|
||||||
|
"precision": 0.4951,
|
||||||
|
"recall": 0.709,
|
||||||
|
"precision_at3": 0.5736,
|
||||||
|
"latency_ms_per_row": 0.0041
|
||||||
|
},
|
||||||
|
"mlp": {
|
||||||
|
"model": "mlp",
|
||||||
|
"auc": 0.7743,
|
||||||
|
"accuracy": 0.7445,
|
||||||
|
"precision": 0.5743,
|
||||||
|
"recall": 0.5325,
|
||||||
|
"precision_at3": 0.5643,
|
||||||
|
"latency_ms_per_row": 0.0052
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"ensemble": {
|
||||||
|
"model": "ensemble",
|
||||||
|
"auc": 0.784,
|
||||||
|
"accuracy": 0.7147,
|
||||||
|
"precision": 0.5142,
|
||||||
|
"recall": 0.6718,
|
||||||
|
"precision_at3": 0.5814,
|
||||||
|
"latency_ms_per_row": 0.0208
|
||||||
|
},
|
||||||
|
"delta_precision_at3": 0.0527,
|
||||||
|
"deploy": true,
|
||||||
|
"optuna": {
|
||||||
|
"n_trials": 100,
|
||||||
|
"xgboost_best_params": {
|
||||||
|
"n_estimators": 141,
|
||||||
|
"max_depth": 5,
|
||||||
|
"learning_rate": 0.016298172447266404,
|
||||||
|
"subsample": 0.7660470794373848,
|
||||||
|
"colsample_bytree": 0.471124415020467,
|
||||||
|
"min_child_weight": 14,
|
||||||
|
"reg_alpha": 1.9364166463791586,
|
||||||
|
"reg_lambda": 6.018030083488602,
|
||||||
|
"gamma": 4.614943551368141
|
||||||
|
},
|
||||||
|
"lightgbm_best_params": {
|
||||||
|
"n_estimators": 186,
|
||||||
|
"max_depth": 4,
|
||||||
|
"learning_rate": 0.012915117465216954,
|
||||||
|
"num_leaves": 141,
|
||||||
|
"subsample": 0.6193119116922561,
|
||||||
|
"colsample_bytree": 0.539310022549326,
|
||||||
|
"min_child_samples": 9,
|
||||||
|
"reg_alpha": 0.6864583098112754,
|
||||||
|
"reg_lambda": 0.0549259590914184
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"total": 43,
|
||||||
|
"selected_by_shap": 31,
|
||||||
|
"feature_list": [
|
||||||
|
"age",
|
||||||
|
"sexe_enc",
|
||||||
|
"nombre_courses",
|
||||||
|
"nombre_victoires",
|
||||||
|
"nombre_places",
|
||||||
|
"tx_victoire",
|
||||||
|
"tx_place",
|
||||||
|
"forme_recente",
|
||||||
|
"tendance_num",
|
||||||
|
"gains_annee_en_cours",
|
||||||
|
"cote_direct",
|
||||||
|
"cote_reference",
|
||||||
|
"distance",
|
||||||
|
"nb_partants",
|
||||||
|
"discipline_enc",
|
||||||
|
"specialite_enc",
|
||||||
|
"oeilleres_enc",
|
||||||
|
"tendance_cote_enc",
|
||||||
|
"penetrometre_intitule_enc",
|
||||||
|
"form_1",
|
||||||
|
"form_2",
|
||||||
|
"form_3",
|
||||||
|
"form_4",
|
||||||
|
"form_5",
|
||||||
|
"form_weighted",
|
||||||
|
"form_avg",
|
||||||
|
"form_best",
|
||||||
|
"form_worst",
|
||||||
|
"win_ratio",
|
||||||
|
"place_ratio",
|
||||||
|
"implied_prob",
|
||||||
|
"win_rate_adj",
|
||||||
|
"place_rate_adj",
|
||||||
|
"earnings_per_race",
|
||||||
|
"cote_diff",
|
||||||
|
"cote_ratio",
|
||||||
|
"rang_cote",
|
||||||
|
"ratio_cote_field",
|
||||||
|
"distance_cat",
|
||||||
|
"age_win_interact",
|
||||||
|
"is_favorite",
|
||||||
|
"poids",
|
||||||
|
"prize_norm"
|
||||||
|
],
|
||||||
|
"shap_selected": [
|
||||||
|
"rang_cote",
|
||||||
|
"implied_prob",
|
||||||
|
"cote_direct",
|
||||||
|
"ratio_cote_field",
|
||||||
|
"nb_partants",
|
||||||
|
"cote_diff",
|
||||||
|
"cote_ratio",
|
||||||
|
"specialite_enc",
|
||||||
|
"earnings_per_race",
|
||||||
|
"nombre_courses",
|
||||||
|
"cote_reference",
|
||||||
|
"distance",
|
||||||
|
"discipline_enc",
|
||||||
|
"is_favorite",
|
||||||
|
"prize_norm",
|
||||||
|
"win_ratio",
|
||||||
|
"place_rate_adj",
|
||||||
|
"gains_annee_en_cours",
|
||||||
|
"poids",
|
||||||
|
"tx_place",
|
||||||
|
"penetrometre_intitule_enc",
|
||||||
|
"age_win_interact",
|
||||||
|
"nombre_places",
|
||||||
|
"tendance_num",
|
||||||
|
"age",
|
||||||
|
"form_avg",
|
||||||
|
"form_weighted",
|
||||||
|
"place_ratio",
|
||||||
|
"form_3",
|
||||||
|
"oeilleres_enc",
|
||||||
|
"form_5"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"ensemble_weights": {
|
||||||
|
"xgboost": 0.23161801824035544,
|
||||||
|
"lightgbm": 0.23415467282905,
|
||||||
|
"mlp": 0.21290370528252356
|
||||||
|
}
|
||||||
|
}
|
||||||
68
models/benchmark_report.md
Normal file
68
models/benchmark_report.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# Benchmark ML Ensemble — Turf Prédictions
|
||||||
|
|
||||||
|
**Date:** 2026-04-25
|
||||||
|
**Dataset:** 10,899 partants
|
||||||
|
**Holdout:** 2,180 lignes (2026-04-19 → 2026-04-24)
|
||||||
|
|
||||||
|
## Résultats
|
||||||
|
|
||||||
|
| Modèle | Precision@3 | AUC | Latence/prédiction |
|
||||||
|
|--------|-------------|-----|-------------------|
|
||||||
|
| XGBoost (baseline) | 0.5287 | 0.7254 | — |
|
||||||
|
| xgboost | 0.5783 | 0.7856 | 0.01 ms |
|
||||||
|
| lightgbm | 0.5736 | 0.7833 | 0.00 ms |
|
||||||
|
| mlp | 0.5643 | 0.7743 | 0.01 ms |
|
||||||
|
| **Ensemble** | **0.5814** | **0.7840** | **0.02 ms** |
|
||||||
|
|
||||||
|
## Décision de déploiement
|
||||||
|
|
||||||
|
- Delta Precision@3 : **+0.0527** (+5.3%)
|
||||||
|
- Seuil requis : **+5%**
|
||||||
|
- Résultat : **✅ DEPLOIEMENT RECOMMANDE**
|
||||||
|
|
||||||
|
## Optimisation Optuna
|
||||||
|
|
||||||
|
- Trials XGBoost : 100
|
||||||
|
- Trials LightGBM : 100
|
||||||
|
- Pruning : MedianPruner
|
||||||
|
|
||||||
|
### Meilleurs hyperparamètres XGBoost
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"n_estimators": 141,
|
||||||
|
"max_depth": 5,
|
||||||
|
"learning_rate": 0.016298172447266404,
|
||||||
|
"subsample": 0.7660470794373848,
|
||||||
|
"colsample_bytree": 0.471124415020467,
|
||||||
|
"min_child_weight": 14,
|
||||||
|
"reg_alpha": 1.9364166463791586,
|
||||||
|
"reg_lambda": 6.018030083488602,
|
||||||
|
"gamma": 4.614943551368141
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Meilleurs hyperparamètres LightGBM
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"n_estimators": 186,
|
||||||
|
"max_depth": 4,
|
||||||
|
"learning_rate": 0.012915117465216954,
|
||||||
|
"num_leaves": 141,
|
||||||
|
"subsample": 0.6193119116922561,
|
||||||
|
"colsample_bytree": 0.539310022549326,
|
||||||
|
"min_child_samples": 9,
|
||||||
|
"reg_alpha": 0.6864583098112754,
|
||||||
|
"reg_lambda": 0.0549259590914184
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Total features : 43
|
||||||
|
- Retenues par SHAP : 31
|
||||||
|
|
||||||
|
## Poids de l'ensemble
|
||||||
|
|
||||||
|
- xgboost : 0.2316
|
||||||
|
- lightgbm : 0.2342
|
||||||
|
- mlp : 0.2129
|
||||||
@@ -15,7 +15,7 @@ import sqlite3
|
|||||||
import re
|
import re
|
||||||
import os
|
import os
|
||||||
|
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||||
|
|||||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Org DB — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Migration idempotente : crée les tables organizations et org_members
|
||||||
|
dans turf_saas.db si elles n'existent pas.
|
||||||
|
|
||||||
|
Run une seule fois :
|
||||||
|
./venv/bin/python org_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.org_db")
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
conn.execute("PRAGMA foreign_keys = ON")
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def migrate_org_tables():
|
||||||
|
"""
|
||||||
|
Migration idempotente : crée organizations + org_members.
|
||||||
|
|
||||||
|
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||||
|
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||||
|
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||||
|
"""
|
||||||
|
conn = get_db()
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
c.executescript("""
|
||||||
|
CREATE TABLE IF NOT EXISTS organizations (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
owner_id TEXT NOT NULL UNIQUE,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
max_members INTEGER NOT NULL DEFAULT 5,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS org_members (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||||
|
user_id TEXT NOT NULL,
|
||||||
|
role TEXT NOT NULL DEFAULT 'member'
|
||||||
|
CHECK(role IN ('owner', 'member')),
|
||||||
|
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||||
|
joined_at DATETIME,
|
||||||
|
UNIQUE(org_id, user_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||||
|
""")
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||||
|
print("[org_db] Migration OK: organizations, org_members.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
migrate_org_tables()
|
||||||
@@ -38,7 +38,7 @@ from pathlib import Path
|
|||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
# CONFIG
|
# CONFIG
|
||||||
# ─────────────────────────────────────────────────────────
|
# ─────────────────────────────────────────────────────────
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
|
OUTPUT_DIR = Path("/home/h3r7/turf_scraper")
|
||||||
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
|
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ import json
|
|||||||
import requests
|
import requests
|
||||||
import subprocess
|
import subprocess
|
||||||
import db
|
import db
|
||||||
|
from middleware import rate_limit_middleware, access_log_middleware
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
rate_limit_middleware(app)
|
||||||
|
access_log_middleware(app)
|
||||||
|
|
||||||
DASHBOARD_API_URL = "http://localhost:8791"
|
DASHBOARD_API_URL = "http://localhost:8791"
|
||||||
COMBINED_API_URL = "http://localhost:8790"
|
COMBINED_API_URL = "http://localhost:8790"
|
||||||
@@ -740,19 +743,29 @@ def pod_static(filename=""):
|
|||||||
@app.route("/turf/api/")
|
@app.route("/turf/api/")
|
||||||
@app.route("/turf/api/<path:api_path>")
|
@app.route("/turf/api/<path:api_path>")
|
||||||
def api_proxy(api_path=""):
|
def api_proxy(api_path=""):
|
||||||
if api_path.startswith("vitesse"):
|
# Routes servies par combined_api.py (port 8790) :
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
|
||||||
elif api_path.startswith("n8n-proxy"):
|
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
|
||||||
elif api_path.startswith("backtest"):
|
COMBINED_ROUTES = (
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"backtest",
|
||||||
elif api_path.startswith("stats"):
|
"stats",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"parisroi",
|
||||||
elif api_path.startswith("predictions_analysis"):
|
"paris",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"predictions_analysis",
|
||||||
elif api_path.startswith("parisroi"):
|
"vitesse",
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
"n8n-proxy",
|
||||||
elif api_path.startswith("paris"):
|
"races",
|
||||||
|
"race/",
|
||||||
|
"scores",
|
||||||
|
"ask",
|
||||||
|
"brave-search",
|
||||||
|
"execute-sql",
|
||||||
|
"send-email",
|
||||||
|
"report",
|
||||||
|
"ideas",
|
||||||
|
)
|
||||||
|
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
|
||||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||||
elif api_path.startswith("scoring"):
|
elif api_path.startswith("scoring"):
|
||||||
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
||||||
@@ -767,11 +780,17 @@ def api_proxy(api_path=""):
|
|||||||
if fwd_method in ("POST", "PUT", "PATCH")
|
if fwd_method in ("POST", "PUT", "PATCH")
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
|
||||||
fwd_headers = {"Content-Type": "application/json"}
|
fwd_headers = {"Content-Type": "application/json"}
|
||||||
if request.headers.get("Authorization"):
|
incoming_auth = request.headers.get("Authorization")
|
||||||
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
if incoming_auth:
|
||||||
|
fwd_headers["Authorization"] = incoming_auth
|
||||||
resp = requests.request(
|
resp = requests.request(
|
||||||
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
|
method=fwd_method,
|
||||||
|
url=url,
|
||||||
|
json=fwd_json,
|
||||||
|
timeout=30,
|
||||||
|
headers=fwd_headers,
|
||||||
)
|
)
|
||||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
387
predict_v2.py
Normal file
387
predict_v2.py
Normal file
@@ -0,0 +1,387 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Ensemble prediction module for /api/v1/predictions.
|
||||||
|
|
||||||
|
Loads the trained ensemble model and provides a high-level predict_top3()
|
||||||
|
function compatible with the existing combined_api.py interface.
|
||||||
|
|
||||||
|
Cache: model is loaded once at import time (or on first call).
|
||||||
|
Invalidation: reload if models/ensemble_top3.pkl mtime changes.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
from sklearn.preprocessing import LabelEncoder
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||||
|
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||||
|
|
||||||
|
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||||
|
_model_cache = {
|
||||||
|
"ensemble": None,
|
||||||
|
"mtime": None,
|
||||||
|
"lock": threading.Lock(),
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Feature list (must match train_ensemble.py FEATURE_COLS) ─────────────────
|
||||||
|
FEATURE_COLS = [
|
||||||
|
"age",
|
||||||
|
"sexe_enc",
|
||||||
|
"nombre_courses",
|
||||||
|
"nombre_victoires",
|
||||||
|
"nombre_places",
|
||||||
|
"tx_victoire",
|
||||||
|
"tx_place",
|
||||||
|
"forme_recente",
|
||||||
|
"tendance_num",
|
||||||
|
"gains_annee_en_cours",
|
||||||
|
"cote_direct",
|
||||||
|
"cote_reference",
|
||||||
|
"distance",
|
||||||
|
"nb_partants",
|
||||||
|
"discipline_enc",
|
||||||
|
"specialite_enc",
|
||||||
|
"oeilleres_enc",
|
||||||
|
"tendance_cote_enc",
|
||||||
|
"penetrometre_intitule_enc",
|
||||||
|
"form_1",
|
||||||
|
"form_2",
|
||||||
|
"form_3",
|
||||||
|
"form_4",
|
||||||
|
"form_5",
|
||||||
|
"form_weighted",
|
||||||
|
"form_avg",
|
||||||
|
"form_best",
|
||||||
|
"form_worst",
|
||||||
|
"win_ratio",
|
||||||
|
"place_ratio",
|
||||||
|
"implied_prob",
|
||||||
|
"win_rate_adj",
|
||||||
|
"place_rate_adj",
|
||||||
|
"earnings_per_race",
|
||||||
|
"cote_diff",
|
||||||
|
"cote_ratio",
|
||||||
|
"rang_cote",
|
||||||
|
"ratio_cote_field",
|
||||||
|
"distance_cat",
|
||||||
|
"age_win_interact",
|
||||||
|
"is_favorite",
|
||||||
|
"poids",
|
||||||
|
"prize_norm",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
# ── Encoders (built per-prediction batch for live data) ──────────────────────
|
||||||
|
def _fit_encoder(values, default):
|
||||||
|
le = LabelEncoder()
|
||||||
|
unique = list(set(str(v) if v else default for v in values)) + [default]
|
||||||
|
le.fit(unique)
|
||||||
|
return le
|
||||||
|
|
||||||
|
|
||||||
|
def _safe_transform(le: LabelEncoder, value, default: str):
|
||||||
|
v = str(value) if value else default
|
||||||
|
if v not in le.classes_:
|
||||||
|
v = default
|
||||||
|
return int(le.transform([v])[0])
|
||||||
|
|
||||||
|
|
||||||
|
# ── Model loading with auto-invalidation ─────────────────────────────────────
|
||||||
|
def load_ensemble(force: bool = False) -> Optional[object]:
|
||||||
|
"""Load ensemble model, reload if file changed."""
|
||||||
|
with _model_cache["lock"]:
|
||||||
|
if not ENSEMBLE_PATH.exists():
|
||||||
|
return None
|
||||||
|
mtime = ENSEMBLE_PATH.stat().st_mtime
|
||||||
|
if force or _model_cache["ensemble"] is None or mtime != _model_cache["mtime"]:
|
||||||
|
try:
|
||||||
|
with open(ENSEMBLE_PATH, "rb") as f:
|
||||||
|
_model_cache["ensemble"] = pickle.load(f)
|
||||||
|
_model_cache["mtime"] = mtime
|
||||||
|
logger.info(f"[predict_v2] Loaded ensemble model from {ENSEMBLE_PATH}")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[predict_v2] Failed to load ensemble: {e}")
|
||||||
|
return None
|
||||||
|
return _model_cache["ensemble"]
|
||||||
|
|
||||||
|
|
||||||
|
def invalidate_model_cache():
|
||||||
|
"""Force reload on next prediction call."""
|
||||||
|
with _model_cache["lock"]:
|
||||||
|
_model_cache["mtime"] = None
|
||||||
|
|
||||||
|
|
||||||
|
# ── Feature engineering for live pmu_partants rows ───────────────────────────
|
||||||
|
def _parse_musique(musique) -> list:
|
||||||
|
if not musique or pd.isna(str(musique)):
|
||||||
|
return [0, 0, 0, 0, 0]
|
||||||
|
try:
|
||||||
|
clean = re.sub(r"\(\d+\)", "", str(musique))
|
||||||
|
numbers = re.findall(r"\d+", clean)
|
||||||
|
result = [int(n) for n in numbers[:5]]
|
||||||
|
result += [0] * (5 - len(result))
|
||||||
|
return result[:5]
|
||||||
|
except Exception:
|
||||||
|
return [0, 0, 0, 0, 0]
|
||||||
|
|
||||||
|
|
||||||
|
def build_feature_df(partants: list) -> pd.DataFrame:
|
||||||
|
"""
|
||||||
|
Convert a list of pmu_partants dicts to a feature DataFrame.
|
||||||
|
|
||||||
|
Expected keys (same as pmu_partants columns):
|
||||||
|
date_programme, num_reunion, num_course, num_pmu,
|
||||||
|
age, sexe, musique, nombre_courses, nombre_victoires, nombre_places,
|
||||||
|
gains_annee_en_cours, handicap_poids, oeilleres, cote_direct,
|
||||||
|
cote_reference, tendance_cote, favoris, tx_victoire, tx_place,
|
||||||
|
forme_recente, tendance_forme, indicateur_inedit,
|
||||||
|
distance, discipline, specialite, nb_declares_partants,
|
||||||
|
montant_prix, penetrometre_intitule
|
||||||
|
"""
|
||||||
|
if not partants:
|
||||||
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
df = pd.DataFrame(partants)
|
||||||
|
|
||||||
|
# ── Categorical encoders fitted on this batch ─────────────────────────────
|
||||||
|
le_sexe = _fit_encoder(df.get("sexe", ["U"]), "U")
|
||||||
|
le_oeilleres = _fit_encoder(df.get("oeilleres", ["SANS"]), "SANS")
|
||||||
|
le_discipline = _fit_encoder(df.get("discipline", ["UNKNOWN"]), "UNKNOWN")
|
||||||
|
le_specialite = _fit_encoder(df.get("specialite", ["UNKNOWN"]), "UNKNOWN")
|
||||||
|
le_tendance = _fit_encoder(df.get("tendance_cote", ["STABLE"]), "STABLE")
|
||||||
|
le_penet = _fit_encoder(df.get("penetrometre_intitule", ["BON"]), "BON")
|
||||||
|
|
||||||
|
df["sexe_enc"] = df["sexe"].apply(lambda v: _safe_transform(le_sexe, v, "U"))
|
||||||
|
df["oeilleres_enc"] = df["oeilleres"].apply(
|
||||||
|
lambda v: _safe_transform(le_oeilleres, v, "SANS")
|
||||||
|
)
|
||||||
|
df["discipline_enc"] = df.get("discipline", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||||
|
lambda v: _safe_transform(le_discipline, v, "UNKNOWN")
|
||||||
|
)
|
||||||
|
df["specialite_enc"] = df.get("specialite", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||||
|
lambda v: _safe_transform(le_specialite, v, "UNKNOWN")
|
||||||
|
)
|
||||||
|
df["tendance_cote_enc"] = df.get(
|
||||||
|
"tendance_cote", pd.Series(["STABLE"] * len(df))
|
||||||
|
).apply(lambda v: _safe_transform(le_tendance, v, "STABLE"))
|
||||||
|
df["penetrometre_intitule_enc"] = df.get(
|
||||||
|
"penetrometre_intitule", pd.Series(["BON"] * len(df))
|
||||||
|
).apply(lambda v: _safe_transform(le_penet, v, "BON"))
|
||||||
|
|
||||||
|
# ── Musique ────────────────────────────────────────────────────────────────
|
||||||
|
music_parsed = df["musique"].apply(_parse_musique)
|
||||||
|
for i in range(5):
|
||||||
|
df[f"form_{i + 1}"] = music_parsed.apply(lambda x: x[i])
|
||||||
|
weights = np.array([0.4, 0.25, 0.15, 0.12, 0.08])
|
||||||
|
df["form_weighted"] = music_parsed.apply(
|
||||||
|
lambda x: sum(w * v for w, v in zip(weights, x))
|
||||||
|
)
|
||||||
|
df["form_avg"] = music_parsed.apply(np.mean)
|
||||||
|
df["form_best"] = music_parsed.apply(min)
|
||||||
|
df["form_worst"] = music_parsed.apply(max)
|
||||||
|
|
||||||
|
# ── Numeric features ───────────────────────────────────────────────────────
|
||||||
|
for col in [
|
||||||
|
"nombre_courses",
|
||||||
|
"nombre_victoires",
|
||||||
|
"nombre_places",
|
||||||
|
"tx_victoire",
|
||||||
|
"tx_place",
|
||||||
|
"forme_recente",
|
||||||
|
"tendance_forme",
|
||||||
|
"gains_annee_en_cours",
|
||||||
|
"cote_direct",
|
||||||
|
"cote_reference",
|
||||||
|
"distance",
|
||||||
|
"handicap_poids",
|
||||||
|
"age",
|
||||||
|
"montant_prix",
|
||||||
|
"nb_declares_partants",
|
||||||
|
]:
|
||||||
|
if col not in df.columns:
|
||||||
|
df[col] = 0.0
|
||||||
|
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
||||||
|
|
||||||
|
df["tendance_num"] = df["tendance_forme"].fillna(0)
|
||||||
|
df["win_ratio"] = df["nombre_victoires"] / df["nombre_courses"].replace(0, 1)
|
||||||
|
df["place_ratio"] = df["nombre_places"] / df["nombre_courses"].replace(0, 1)
|
||||||
|
df["implied_prob"] = 1.0 / df["cote_direct"].replace(0, np.nan)
|
||||||
|
df["win_rate_adj"] = df["tx_victoire"] * np.log1p(df["nombre_courses"])
|
||||||
|
df["place_rate_adj"] = df["tx_place"] * np.log1p(df["nombre_courses"])
|
||||||
|
df["earnings_per_race"] = df["gains_annee_en_cours"] / df["nombre_courses"].replace(
|
||||||
|
0, 1
|
||||||
|
)
|
||||||
|
df["cote_diff"] = (df["cote_direct"] - df["cote_reference"]).fillna(0)
|
||||||
|
df["cote_ratio"] = (
|
||||||
|
df["cote_direct"] / df["cote_reference"].replace(0, np.nan)
|
||||||
|
).fillna(1)
|
||||||
|
|
||||||
|
# ── Per-race rank features ─────────────────────────────────────────────────
|
||||||
|
if "num_reunion" in df.columns and "num_course" in df.columns:
|
||||||
|
grp = ["date_programme", "num_reunion", "num_course"]
|
||||||
|
# Some fields may be missing
|
||||||
|
for g in grp:
|
||||||
|
if g not in df.columns:
|
||||||
|
df[g] = 0
|
||||||
|
df["rang_cote"] = df.groupby(grp)["cote_direct"].rank(
|
||||||
|
method="min", na_option="bottom"
|
||||||
|
)
|
||||||
|
race_mean = df.groupby(grp)["cote_direct"].transform("mean")
|
||||||
|
df["ratio_cote_field"] = df["cote_direct"] / race_mean.replace(0, np.nan)
|
||||||
|
df["nb_partants"] = df.groupby(grp)["cote_direct"].transform("count")
|
||||||
|
else:
|
||||||
|
df["rang_cote"] = 1.0
|
||||||
|
df["ratio_cote_field"] = 1.0
|
||||||
|
df["nb_partants"] = df.get("nb_declares_partants", pd.Series([10] * len(df)))
|
||||||
|
|
||||||
|
df["distance_cat"] = pd.cut(
|
||||||
|
df["distance"].fillna(1600),
|
||||||
|
bins=[0, 1400, 1800, 2200, 2600, 10000],
|
||||||
|
labels=[1, 2, 3, 4, 5],
|
||||||
|
).astype(float)
|
||||||
|
df["age_win_interact"] = df["age"] * df["tx_victoire"]
|
||||||
|
df["is_favorite"] = (
|
||||||
|
df.get("favoris", pd.Series([0] * len(df))).fillna(0).astype(int)
|
||||||
|
)
|
||||||
|
df["poids"] = df["handicap_poids"].fillna(60)
|
||||||
|
df["prize_norm"] = np.log1p(df["montant_prix"].fillna(0))
|
||||||
|
|
||||||
|
return df
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main prediction function ───────────────────────────────────────────────────
|
||||||
|
def predict_top3(partants: list, model=None) -> list:
|
||||||
|
"""
|
||||||
|
Given a list of partant dicts (from pmu_partants), return predictions.
|
||||||
|
|
||||||
|
Returns list of {horse_name, num_pmu, prob_top3, prob_top1_approx, ...}
|
||||||
|
sorted by prob_top3 descending.
|
||||||
|
|
||||||
|
Falls back to empty list if model not available.
|
||||||
|
"""
|
||||||
|
t_start = time.perf_counter()
|
||||||
|
|
||||||
|
if model is None:
|
||||||
|
model = load_ensemble()
|
||||||
|
if model is None:
|
||||||
|
logger.warning("[predict_v2] Ensemble model not available — no predictions")
|
||||||
|
return []
|
||||||
|
|
||||||
|
df = build_feature_df(partants)
|
||||||
|
if df.empty:
|
||||||
|
return []
|
||||||
|
|
||||||
|
available = [c for c in FEATURE_COLS if c in df.columns]
|
||||||
|
X = df[available].fillna(0)
|
||||||
|
|
||||||
|
try:
|
||||||
|
proba = model.predict_proba(X)[:, 1]
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[predict_v2] predict_proba failed: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
latency_ms = (time.perf_counter() - t_start) * 1000
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for i, (p, row) in enumerate(zip(proba, partants)):
|
||||||
|
results.append(
|
||||||
|
{
|
||||||
|
"horse_name": row.get("nom", row.get("horse_name", f"H{i}")),
|
||||||
|
"num_pmu": row.get("num_pmu", i + 1),
|
||||||
|
"num_reunion": row.get("num_reunion"),
|
||||||
|
"num_course": row.get("num_course"),
|
||||||
|
"prob_top3": round(float(p) * 100, 1),
|
||||||
|
# approx top1 from top3 score (divide by ~2.5 empirically)
|
||||||
|
"prob_top1": round(float(p) / 2.5 * 100, 1),
|
||||||
|
"ml_score": round(float(p) * 100, 1),
|
||||||
|
"recommendation": "top3"
|
||||||
|
if p >= 0.40
|
||||||
|
else ("watch" if p >= 0.28 else "pass"),
|
||||||
|
"is_value_bet": int(
|
||||||
|
p >= 0.35 and float(row.get("cote_direct", 0) or 0) > 10
|
||||||
|
),
|
||||||
|
"model_version": getattr(model, "version", "ensemble_v1"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
results.sort(key=lambda x: x["prob_top3"], reverse=True)
|
||||||
|
|
||||||
|
# Mark top-3 predicted
|
||||||
|
for i, r in enumerate(results[:3]):
|
||||||
|
r["predicted_rank"] = i + 1
|
||||||
|
|
||||||
|
if results:
|
||||||
|
logger.info(
|
||||||
|
f"[predict_v2] {len(results)} horses predicted in {latency_ms:.1f} ms "
|
||||||
|
f"({latency_ms / len(results):.2f} ms/horse)"
|
||||||
|
)
|
||||||
|
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
# ── API-compatible wrapper keeping model_version & structure ──────────────────
|
||||||
|
def get_model_version() -> str:
|
||||||
|
m = load_ensemble()
|
||||||
|
if m is None:
|
||||||
|
return "ensemble_v1_not_loaded"
|
||||||
|
return getattr(m, "version", "ensemble_v1")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
# Quick self-test
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||||
|
rows = conn.execute(
|
||||||
|
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||||
|
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||||
|
FROM pmu_partants p
|
||||||
|
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||||
|
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||||
|
WHERE p.date_programme=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||||
|
AND p.num_reunion=1 AND p.num_course=1
|
||||||
|
LIMIT 20"""
|
||||||
|
).fetchall()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
if not rows:
|
||||||
|
print("No data found for self-test")
|
||||||
|
else:
|
||||||
|
cols = [d[0] for d in conn.description] if hasattr(conn, "description") else []
|
||||||
|
# Fallback column list
|
||||||
|
import sqlite3 as sq3
|
||||||
|
|
||||||
|
conn2 = sq3.connect("/home/h3r7/turf_saas/turf.db")
|
||||||
|
cur = conn2.execute(
|
||||||
|
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||||
|
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||||
|
FROM pmu_partants p
|
||||||
|
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||||
|
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||||
|
WHERE p.date_programme=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||||
|
AND p.num_reunion=1 AND p.num_course=1
|
||||||
|
LIMIT 20"""
|
||||||
|
)
|
||||||
|
cols = [d[0] for d in cur.description]
|
||||||
|
rows2 = cur.fetchall()
|
||||||
|
conn2.close()
|
||||||
|
|
||||||
|
partants = [dict(zip(cols, row)) for row in rows2]
|
||||||
|
preds = predict_top3(partants)
|
||||||
|
print(f"Self-test: {len(preds)} predictions")
|
||||||
|
for p in preds[:5]:
|
||||||
|
print(
|
||||||
|
f" {p['horse_name']:20s} prob_top3={p['prob_top3']}% rec={p['recommendation']}"
|
||||||
|
)
|
||||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[pytest]
|
||||||
|
asyncio_mode = auto
|
||||||
|
testpaths = tests
|
||||||
|
python_files = test_*.py
|
||||||
|
python_classes = Test*
|
||||||
|
python_functions = test_*
|
||||||
|
addopts = --tb=short -v
|
||||||
|
markers =
|
||||||
|
e2e: Tests End-to-End Playwright
|
||||||
|
load: Tests de charge Locust
|
||||||
|
security: Tests de sécurité
|
||||||
|
smoke: Tests rapides de smoke
|
||||||
|
integration: Tests d'intégration DB et pipeline ML
|
||||||
182
rebuild_ensemble.py
Normal file
182
rebuild_ensemble.py
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Rebuild ensemble using known best Optuna params (from completed study).
|
||||||
|
Skips the 100-trial Optuna search and goes straight to training + pickling.
|
||||||
|
"""
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/home/h3r7/turf_saas')
|
||||||
|
|
||||||
|
from train_ensemble import (
|
||||||
|
load_data, engineer_features, temporal_split, get_features_and_target,
|
||||||
|
evaluate_baseline, train_xgboost, train_lightgbm, train_mlp,
|
||||||
|
shap_feature_selection, compute_ensemble_weights,
|
||||||
|
evaluate_model, compute_precision_at3, TurfEnsemble,
|
||||||
|
MODELS_DIR, DEPLOY_THRESHOLD, _write_markdown_report
|
||||||
|
)
|
||||||
|
import json, pickle, numpy as np
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
DB_PATH = '/home/h3r7/turf_saas/turf.db'
|
||||||
|
|
||||||
|
# Best params from the 100-trial Optuna run
|
||||||
|
XGB_BEST = {
|
||||||
|
'n_estimators': 141, 'max_depth': 5,
|
||||||
|
'learning_rate': 0.016298172447266404,
|
||||||
|
'subsample': 0.7660470794373848,
|
||||||
|
'colsample_bytree': 0.471124415020467,
|
||||||
|
'min_child_weight': 14,
|
||||||
|
'reg_alpha': 1.9364166463791586,
|
||||||
|
'reg_lambda': 6.018030083488602,
|
||||||
|
'gamma': 4.614943551368141,
|
||||||
|
}
|
||||||
|
LGB_BEST = {
|
||||||
|
'n_estimators': 186, 'max_depth': 4,
|
||||||
|
'learning_rate': 0.012915117465216954,
|
||||||
|
'num_leaves': 141,
|
||||||
|
'subsample': 0.6193119116922561,
|
||||||
|
'colsample_bytree': 0.539310022549326,
|
||||||
|
'min_child_samples': 9,
|
||||||
|
'reg_alpha': 0.6864583098112754,
|
||||||
|
'reg_lambda': 0.0549259590914184,
|
||||||
|
}
|
||||||
|
|
||||||
|
print("=" * 65)
|
||||||
|
print("TURF ENSEMBLE REBUILD (using pre-computed Optuna params)")
|
||||||
|
print("=" * 65)
|
||||||
|
|
||||||
|
print("\n[1/7] Loading data...")
|
||||||
|
df = load_data(DB_PATH)
|
||||||
|
df = engineer_features(df)
|
||||||
|
|
||||||
|
print("\n[2/7] Temporal split...")
|
||||||
|
train_df, holdout_df = temporal_split(df)
|
||||||
|
X_train, y_train, feat_cols = get_features_and_target(train_df)
|
||||||
|
X_holdout, y_holdout, _ = get_features_and_target(holdout_df)
|
||||||
|
|
||||||
|
n = len(X_train); n_val = int(n * 0.15)
|
||||||
|
X_tr = X_train.iloc[:n-n_val]; y_tr = y_train.iloc[:n-n_val]
|
||||||
|
X_val = X_train.iloc[n-n_val:]; y_val = y_train.iloc[n-n_val:]
|
||||||
|
|
||||||
|
print("\n[3/7] Evaluating baseline XGBoost...")
|
||||||
|
baseline = evaluate_baseline(holdout_df, '/home/h3r7/turf_saas/xgboost_models.pkl')
|
||||||
|
print(f" Baseline P@3={baseline['precision_at3']:.4f} AUC={baseline['auc']:.4f}")
|
||||||
|
|
||||||
|
print("\n[4/7] Training models with best params...")
|
||||||
|
print(" XGBoost...")
|
||||||
|
xgb_model = train_xgboost(X_tr, y_tr, XGB_BEST)
|
||||||
|
print(" LightGBM...")
|
||||||
|
lgb_model = train_lightgbm(X_tr, y_tr, LGB_BEST)
|
||||||
|
print(" MLP...")
|
||||||
|
mlp_model = train_mlp(X_tr.values, y_tr)
|
||||||
|
|
||||||
|
print("\n[5/7] SHAP analysis...")
|
||||||
|
selected_features, shap_df = shap_feature_selection(xgb_model, X_tr)
|
||||||
|
|
||||||
|
print("\n[6/7] Computing ensemble weights...")
|
||||||
|
class WrappedMLP:
|
||||||
|
def __init__(self, pipeline, cols):
|
||||||
|
self.pipeline = pipeline
|
||||||
|
self.feature_cols = cols
|
||||||
|
def predict_proba(self, X):
|
||||||
|
import pandas as pd
|
||||||
|
available = [c for c in self.feature_cols if c in X.columns]
|
||||||
|
return self.pipeline.predict_proba(X[available].values)
|
||||||
|
|
||||||
|
class WrappedTree:
|
||||||
|
def __init__(self, model, cols):
|
||||||
|
self.model = model
|
||||||
|
self.feature_cols = cols
|
||||||
|
def predict_proba(self, X):
|
||||||
|
available = [c for c in self.feature_cols if c in X.columns]
|
||||||
|
return self.model.predict_proba(X[available])
|
||||||
|
|
||||||
|
wrapped_xgb = WrappedTree(xgb_model, feat_cols)
|
||||||
|
wrapped_lgb = WrappedTree(lgb_model, feat_cols)
|
||||||
|
wrapped_mlp = WrappedMLP(mlp_model, feat_cols)
|
||||||
|
model_dict = {'xgboost': wrapped_xgb, 'lightgbm': wrapped_lgb, 'mlp': wrapped_mlp}
|
||||||
|
|
||||||
|
weights = compute_ensemble_weights(model_dict, X_val, y_val, feat_cols)
|
||||||
|
print(" Weights:", weights)
|
||||||
|
|
||||||
|
print("\n[7/7] Evaluating + saving ensemble...")
|
||||||
|
ensemble = TurfEnsemble(xgb_model, lgb_model, mlp_model, weights, feat_cols)
|
||||||
|
|
||||||
|
results = {}
|
||||||
|
for name, wrapped in model_dict.items():
|
||||||
|
res = evaluate_model(wrapped, X_holdout, y_holdout, holdout_df, name)
|
||||||
|
results[name] = res
|
||||||
|
print(f" {name:12s} P@3={res['precision_at3']:.4f} AUC={res['auc']:.4f}")
|
||||||
|
|
||||||
|
ens_res = evaluate_model(ensemble, X_holdout, y_holdout, holdout_df, "ensemble")
|
||||||
|
results["ensemble"] = ens_res
|
||||||
|
print(f" {'ensemble':12s} P@3={ens_res['precision_at3']:.4f} AUC={ens_res['auc']:.4f}")
|
||||||
|
|
||||||
|
delta = ens_res['precision_at3'] - baseline['precision_at3']
|
||||||
|
deploy = delta >= DEPLOY_THRESHOLD
|
||||||
|
print(f"\n Delta: {delta:+.4f} ({delta*100:+.1f}%) Deploy={'YES' if deploy else 'NO'}")
|
||||||
|
|
||||||
|
# Save ensemble
|
||||||
|
ensemble_path = MODELS_DIR / "ensemble_top3.pkl"
|
||||||
|
with open(ensemble_path, "wb") as f:
|
||||||
|
pickle.dump(ensemble, f)
|
||||||
|
print(f"\n ✅ ensemble_top3.pkl saved ({ensemble_path.stat().st_size//1024} KB)")
|
||||||
|
|
||||||
|
# Save individual models
|
||||||
|
for name, model in [("xgboost_optimized", xgb_model), ("lightgbm", lgb_model)]:
|
||||||
|
path = MODELS_DIR / f"{name}_top3.pkl"
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
pickle.dump({"model": model, "feature_cols": feat_cols}, f)
|
||||||
|
print(f" ✅ {name}_top3.pkl saved")
|
||||||
|
|
||||||
|
mlp_path = MODELS_DIR / "mlp_top3.pkl"
|
||||||
|
with open(mlp_path, "wb") as f:
|
||||||
|
pickle.dump({"pipeline": mlp_model, "feature_cols": feat_cols}, f)
|
||||||
|
print(f" ✅ mlp_top3.pkl saved")
|
||||||
|
|
||||||
|
# Benchmark report
|
||||||
|
report = {
|
||||||
|
"run_date": datetime.now().isoformat(),
|
||||||
|
"dataset": {
|
||||||
|
"db_path": DB_PATH,
|
||||||
|
"total_rows": len(df),
|
||||||
|
"train_rows": len(X_train),
|
||||||
|
"holdout_rows": len(X_holdout),
|
||||||
|
"train_date_range": [str(train_df["date_programme"].min()), str(train_df["date_programme"].max())],
|
||||||
|
"holdout_date_range": [str(holdout_df["date_programme"].min()), str(holdout_df["date_programme"].max())],
|
||||||
|
},
|
||||||
|
"baseline": baseline,
|
||||||
|
"individual_models": {k: v for k, v in results.items() if k != "ensemble"},
|
||||||
|
"ensemble": ens_res,
|
||||||
|
"delta_precision_at3": round(delta, 4),
|
||||||
|
"deploy": deploy,
|
||||||
|
"optuna": {
|
||||||
|
"n_trials": 100,
|
||||||
|
"xgboost_best_params": XGB_BEST,
|
||||||
|
"lightgbm_best_params": LGB_BEST,
|
||||||
|
},
|
||||||
|
"features": {
|
||||||
|
"total": len(feat_cols),
|
||||||
|
"selected_by_shap": len(selected_features),
|
||||||
|
"feature_list": feat_cols,
|
||||||
|
"shap_selected": selected_features,
|
||||||
|
},
|
||||||
|
"ensemble_weights": weights,
|
||||||
|
}
|
||||||
|
|
||||||
|
report_path = MODELS_DIR / "benchmark_report.json"
|
||||||
|
with open(report_path, "w") as f:
|
||||||
|
json.dump(report, f, indent=2)
|
||||||
|
print(f" ✅ benchmark_report.json saved")
|
||||||
|
|
||||||
|
md_path = MODELS_DIR / "benchmark_report.md"
|
||||||
|
_write_markdown_report(report, md_path)
|
||||||
|
print(f" ✅ benchmark_report.md saved")
|
||||||
|
|
||||||
|
print("\n" + "=" * 65)
|
||||||
|
print("DONE")
|
||||||
|
print(f" Baseline P@3: {baseline['precision_at3']:.4f}")
|
||||||
|
print(f" Ensemble P@3: {ens_res['precision_at3']:.4f}")
|
||||||
|
print(f" Delta: {delta:+.4f} ({delta*100:+.1f}%)")
|
||||||
|
print(f" Deploy: {'✅ YES' if deploy else '❌ NO'}")
|
||||||
|
print("=" * 65)
|
||||||
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)
|
||||||
@@ -9,7 +9,7 @@ from flask import Blueprint, request, jsonify
|
|||||||
import sqlite3
|
import sqlite3
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from .saas_auth import require_auth
|
from saas_auth import require_auth
|
||||||
|
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
|
||||||
@@ -255,3 +255,46 @@ def export_csv():
|
|||||||
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
|
||||||
|
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
|
||||||
|
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
|
||||||
|
try:
|
||||||
|
from flask_jwt_extended import JWTManager
|
||||||
|
from api_v1.routes.billing import billing_bp
|
||||||
|
|
||||||
|
# Initialize JWTManager on the Flask app when api_v1_bp is registered
|
||||||
|
@api_v1_bp.record_once
|
||||||
|
def _init_jwt(state):
|
||||||
|
app = state.app
|
||||||
|
if not app.config.get("JWT_SECRET_KEY"):
|
||||||
|
import os
|
||||||
|
|
||||||
|
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||||
|
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
|
||||||
|
)
|
||||||
|
if "flask_jwt_extended" not in app.extensions:
|
||||||
|
JWTManager(app)
|
||||||
|
|
||||||
|
# Register billing blueprint with url_prefix='/billing'
|
||||||
|
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
||||||
|
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
|
||||||
|
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
|
||||||
|
except Exception as _billing_err:
|
||||||
|
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||||
|
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||||
|
try:
|
||||||
|
from api_v1.routes.org import org_bp
|
||||||
|
|
||||||
|
@api_v1_bp.record_once
|
||||||
|
def _register_org_bp(state):
|
||||||
|
app = state.app
|
||||||
|
app.register_blueprint(org_bp)
|
||||||
|
|
||||||
|
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||||
|
except Exception as _org_err:
|
||||||
|
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||||
|
|||||||
215
saas_auth.py
215
saas_auth.py
@@ -8,12 +8,142 @@ Sprint 4-5 — HRT-30
|
|||||||
from flask import Blueprint, request, jsonify, current_app
|
from flask import Blueprint, request, jsonify, current_app
|
||||||
import sqlite3
|
import sqlite3
|
||||||
import hashlib
|
import hashlib
|
||||||
|
import logging
|
||||||
import secrets
|
import secrets
|
||||||
import os
|
import os
|
||||||
import time
|
import time
|
||||||
import json
|
import json
|
||||||
from functools import wraps
|
from functools import wraps
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
from collections import defaultdict
|
||||||
|
from threading import Lock
|
||||||
|
|
||||||
|
# ─── Rate limiting login ───────────────────────────────────────────────────────
|
||||||
|
_login_attempts: dict = defaultdict(
|
||||||
|
lambda: {"count": 0, "window_start": 0.0, "blocked_until": 0.0}
|
||||||
|
)
|
||||||
|
_login_lock = Lock()
|
||||||
|
|
||||||
|
LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
|
||||||
|
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
||||||
|
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
||||||
|
|
||||||
|
# ─── Blacklist mots de passe faibles ─────────────────────────────────────────
|
||||||
|
# HRT-63 — Validation mots de passe faibles
|
||||||
|
WEAK_PASSWORDS = {
|
||||||
|
"password",
|
||||||
|
"password1",
|
||||||
|
"password123",
|
||||||
|
"passw0rd",
|
||||||
|
"12345678",
|
||||||
|
"123456789",
|
||||||
|
"1234567890",
|
||||||
|
"123456",
|
||||||
|
"12345",
|
||||||
|
"1234",
|
||||||
|
"qwerty",
|
||||||
|
"qwerty123",
|
||||||
|
"qwertyuiop",
|
||||||
|
"azerty",
|
||||||
|
"azertyuiop",
|
||||||
|
"letmein",
|
||||||
|
"letmein1",
|
||||||
|
"iloveyou",
|
||||||
|
"iloveyou1",
|
||||||
|
"admin",
|
||||||
|
"admin123",
|
||||||
|
"admin1234",
|
||||||
|
"administrator",
|
||||||
|
"welcome",
|
||||||
|
"welcome1",
|
||||||
|
"welcome123",
|
||||||
|
"monkey",
|
||||||
|
"monkey1",
|
||||||
|
"dragon",
|
||||||
|
"dragon1",
|
||||||
|
"master",
|
||||||
|
"master1",
|
||||||
|
"football",
|
||||||
|
"soccer",
|
||||||
|
"baseball",
|
||||||
|
"basketball",
|
||||||
|
"superman",
|
||||||
|
"batman",
|
||||||
|
"starwars",
|
||||||
|
"starwars1",
|
||||||
|
"princess",
|
||||||
|
"princess1",
|
||||||
|
"sunshine",
|
||||||
|
"sunshine1",
|
||||||
|
"shadow",
|
||||||
|
"shadow1",
|
||||||
|
"michael",
|
||||||
|
"michael1",
|
||||||
|
"jessica",
|
||||||
|
"jessica1",
|
||||||
|
"abc123",
|
||||||
|
"abc1234",
|
||||||
|
"abcd1234",
|
||||||
|
"abcdefgh",
|
||||||
|
"login",
|
||||||
|
"login123",
|
||||||
|
"pass",
|
||||||
|
"pass1234",
|
||||||
|
"test",
|
||||||
|
"test1234",
|
||||||
|
"test123456",
|
||||||
|
"hello",
|
||||||
|
"hello123",
|
||||||
|
"hello1234",
|
||||||
|
"changeme",
|
||||||
|
"changeme1",
|
||||||
|
"secret",
|
||||||
|
"secret1",
|
||||||
|
"secret123",
|
||||||
|
"trustno1",
|
||||||
|
"zaq1zaq1",
|
||||||
|
"qazwsx",
|
||||||
|
"qazwsxedc",
|
||||||
|
"111111",
|
||||||
|
"1111111",
|
||||||
|
"11111111",
|
||||||
|
"000000",
|
||||||
|
"00000000",
|
||||||
|
"123123",
|
||||||
|
"1231234",
|
||||||
|
"321321",
|
||||||
|
"p@ssword",
|
||||||
|
"p@ssw0rd",
|
||||||
|
"pa$$word",
|
||||||
|
"turf",
|
||||||
|
"turf123",
|
||||||
|
"cheval",
|
||||||
|
"cheval123",
|
||||||
|
"pmu",
|
||||||
|
"pmu123",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def validate_password_strength(password: str):
|
||||||
|
"""
|
||||||
|
Valide la complexité d'un mot de passe.
|
||||||
|
Retourne None si OK, sinon un message d'erreur (str).
|
||||||
|
Règles :
|
||||||
|
- 8 caractères minimum
|
||||||
|
- absent de la blacklist WEAK_PASSWORDS
|
||||||
|
- au moins 1 chiffre
|
||||||
|
- au moins 1 lettre
|
||||||
|
"""
|
||||||
|
if len(password) < 8:
|
||||||
|
return "Mot de passe trop court (8 caractères minimum)."
|
||||||
|
if password.lower() in WEAK_PASSWORDS:
|
||||||
|
return "Mot de passe trop commun. Choisissez un mot de passe plus sécurisé."
|
||||||
|
if not any(c.isdigit() for c in password):
|
||||||
|
return "Le mot de passe doit contenir au moins 1 chiffre."
|
||||||
|
if not any(c.isalpha() for c in password):
|
||||||
|
return "Le mot de passe doit contenir au moins 1 lettre."
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
@@ -100,14 +230,54 @@ def hash_password(password: str) -> str:
|
|||||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def validate_api_key(raw_key: str):
|
||||||
|
"""
|
||||||
|
Validate a personal API token (X-API-Key header).
|
||||||
|
Returns user dict or None. Updates last_used_at on success.
|
||||||
|
HRT-80
|
||||||
|
"""
|
||||||
|
if not raw_key:
|
||||||
|
return None
|
||||||
|
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||||
|
conn = get_db()
|
||||||
|
try:
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||||
|
"JOIN saas_users u ON t.user_id = u.id "
|
||||||
|
"WHERE t.token_hash = ? AND t.revoked = 0",
|
||||||
|
(key_hash,),
|
||||||
|
).fetchone()
|
||||||
|
if row:
|
||||||
|
conn.execute(
|
||||||
|
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||||
|
"WHERE token_hash = ?",
|
||||||
|
(key_hash,),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
return dict(row) if row else None
|
||||||
|
except Exception as e:
|
||||||
|
logging.getLogger("turf_saas.auth").warning("validate_api_key error: %s", e)
|
||||||
|
return None
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
def require_auth(f):
|
def require_auth(f):
|
||||||
@wraps(f)
|
@wraps(f)
|
||||||
def decorated(*args, **kwargs):
|
def decorated(*args, **kwargs):
|
||||||
|
# 1. Try Bearer session token (existing flow — unchanged)
|
||||||
auth = request.headers.get("Authorization", "")
|
auth = request.headers.get("Authorization", "")
|
||||||
token = (
|
token = (
|
||||||
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
||||||
)
|
)
|
||||||
user = validate_token(token)
|
user = validate_token(token) if token else None
|
||||||
|
|
||||||
|
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||||
|
if not user:
|
||||||
|
api_key = request.headers.get("X-API-Key", "").strip()
|
||||||
|
if api_key:
|
||||||
|
user = validate_api_key(api_key)
|
||||||
|
|
||||||
if not user:
|
if not user:
|
||||||
return jsonify({"error": "Non authentifié"}), 401
|
return jsonify({"error": "Non authentifié"}), 401
|
||||||
request.current_user = user
|
request.current_user = user
|
||||||
@@ -148,10 +318,9 @@ def register():
|
|||||||
|
|
||||||
if not email or "@" not in email:
|
if not email or "@" not in email:
|
||||||
return jsonify({"error": "Adresse email invalide."}), 400
|
return jsonify({"error": "Adresse email invalide."}), 400
|
||||||
if len(password) < 8:
|
pwd_error = validate_password_strength(password)
|
||||||
return jsonify(
|
if pwd_error:
|
||||||
{"error": "Mot de passe trop court (8 caractères minimum)."}
|
return jsonify({"error": pwd_error}), 400
|
||||||
), 400
|
|
||||||
if plan not in ("free", "premium", "pro"):
|
if plan not in ("free", "premium", "pro"):
|
||||||
plan = "free"
|
plan = "free"
|
||||||
|
|
||||||
@@ -184,6 +353,37 @@ def login():
|
|||||||
if not email or not password:
|
if not email or not password:
|
||||||
return jsonify({"error": "Email et mot de passe requis."}), 400
|
return jsonify({"error": "Email et mot de passe requis."}), 400
|
||||||
|
|
||||||
|
# ── Rate limit par IP ────────────────────────────────────────
|
||||||
|
ip = request.remote_addr or "unknown"
|
||||||
|
now = time.time()
|
||||||
|
|
||||||
|
with _login_lock:
|
||||||
|
bucket = _login_attempts[ip]
|
||||||
|
# Lever le blocage si la durée est écoulée
|
||||||
|
if now >= bucket["blocked_until"]:
|
||||||
|
if now - bucket["window_start"] >= LOGIN_RATE_WINDOW:
|
||||||
|
bucket["count"] = 0
|
||||||
|
bucket["window_start"] = now
|
||||||
|
bucket["count"] += 1
|
||||||
|
count = bucket["count"]
|
||||||
|
if count > LOGIN_RATE_MAX:
|
||||||
|
bucket["blocked_until"] = now + LOGIN_BLOCK_DURATION
|
||||||
|
retry_after = LOGIN_BLOCK_DURATION
|
||||||
|
blocked = True
|
||||||
|
else:
|
||||||
|
retry_after = int(LOGIN_RATE_WINDOW - (now - bucket["window_start"]))
|
||||||
|
blocked = False
|
||||||
|
else:
|
||||||
|
blocked = True
|
||||||
|
retry_after = int(bucket["blocked_until"] - now)
|
||||||
|
|
||||||
|
if blocked:
|
||||||
|
resp = jsonify({"error": "Trop de tentatives. Réessayez plus tard."})
|
||||||
|
resp.status_code = 429
|
||||||
|
resp.headers["Retry-After"] = str(retry_after)
|
||||||
|
return resp
|
||||||
|
# ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
pw_hash = hash_password(password)
|
pw_hash = hash_password(password)
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
@@ -249,8 +449,9 @@ def change_password():
|
|||||||
cur_pwd = data.get("current_password") or ""
|
cur_pwd = data.get("current_password") or ""
|
||||||
new_pwd = data.get("new_password") or ""
|
new_pwd = data.get("new_password") or ""
|
||||||
|
|
||||||
if len(new_pwd) < 8:
|
pwd_error = validate_password_strength(new_pwd)
|
||||||
return jsonify({"error": "Nouveau mot de passe trop court."}), 400
|
if pwd_error:
|
||||||
|
return jsonify({"error": pwd_error}), 400
|
||||||
|
|
||||||
conn = get_db()
|
conn = get_db()
|
||||||
user = conn.execute(
|
user = conn.execute(
|
||||||
|
|||||||
481
scoring_v2.py
481
scoring_v2.py
@@ -10,30 +10,35 @@ import json
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
|
HEADERS = {"User-Agent": "Mozilla/5.0", "Accept": "application/json"}
|
||||||
|
|
||||||
|
|
||||||
def get_cote_from_db(horse_name, date_course):
|
def get_cote_from_db(horse_name, date_course):
|
||||||
"""Recupere la cote depuis la table predictions (plus recente et non nulle)"""
|
"""Recupere la cote depuis la table predictions (plus recente et non nulle)"""
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
c = conn.execute("""
|
c = conn.execute(
|
||||||
|
"""
|
||||||
SELECT odds FROM predictions
|
SELECT odds FROM predictions
|
||||||
WHERE date=? AND horse_name LIKE ? AND odds > 0
|
WHERE date=? AND horse_name LIKE ? AND odds > 0
|
||||||
ORDER BY created_at DESC LIMIT 1
|
ORDER BY created_at DESC LIMIT 1
|
||||||
""", (date_course, f"%{horse_name}%"))
|
""",
|
||||||
|
(date_course, f"%{horse_name}%"),
|
||||||
|
)
|
||||||
r = c.fetchone()
|
r = c.fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
return r['odds'] if r else 0
|
return r["odds"] if r else 0
|
||||||
|
|
||||||
|
|
||||||
def parse_musique(musique):
|
def parse_musique(musique):
|
||||||
if not musique:
|
if not musique:
|
||||||
return {}
|
return {}
|
||||||
clean = re.sub(r'\(\d+\)', '', musique)
|
clean = re.sub(r"\(\d+\)", "", musique)
|
||||||
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
|
resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
|
||||||
positions = []
|
positions = []
|
||||||
for pos, disc in resultats[:10]:
|
for pos, disc in resultats[:10]:
|
||||||
positions.append(99 if pos == 'D' else int(pos))
|
positions.append(99 if pos == "D" else int(pos))
|
||||||
if not positions:
|
if not positions:
|
||||||
return {}
|
return {}
|
||||||
nb_courses = len(positions)
|
nb_courses = len(positions)
|
||||||
@@ -41,222 +46,385 @@ def parse_musique(musique):
|
|||||||
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
||||||
recentes = [p for p in positions[:3] if p != 99]
|
recentes = [p for p in positions[:3] if p != 99]
|
||||||
forme_recente = sum(recentes) / len(recentes) if recentes else 99
|
forme_recente = sum(recentes) / len(recentes) if recentes else 99
|
||||||
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
tendance = (
|
||||||
|
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
'forme_recente': round(forme_recente, 1),
|
"forme_recente": round(forme_recente, 1),
|
||||||
'tendance': round(tendance, 1),
|
"tendance": round(tendance, 1),
|
||||||
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
"tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||||
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
"tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
def score_cheval_v2(p, all_participants, today):
|
|
||||||
|
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
|
||||||
|
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
|
||||||
|
if not penetrometre_intitule:
|
||||||
|
return "inconnu"
|
||||||
|
val = penetrometre_intitule.upper()
|
||||||
|
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
|
||||||
|
return "bon"
|
||||||
|
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
|
||||||
|
return "bon"
|
||||||
|
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
|
||||||
|
return "souple"
|
||||||
|
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
|
||||||
|
return "lourd"
|
||||||
|
if any(k in val for k in ("SOFT", "MOU")):
|
||||||
|
return "souple"
|
||||||
|
return "inconnu"
|
||||||
|
|
||||||
|
|
||||||
|
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
|
||||||
|
"""
|
||||||
|
Calcule un score d'impact météo/terrain sur [−5, +5].
|
||||||
|
weather_data keys attendues : nebulositecode, temperature, force_vent
|
||||||
|
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
|
||||||
|
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
|
||||||
|
"""
|
||||||
|
if not weather_data:
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
delta = 0.0
|
||||||
|
|
||||||
|
# Terrain
|
||||||
|
if terrain_condition == "lourd":
|
||||||
|
delta -= 3.0
|
||||||
|
elif terrain_condition == "souple":
|
||||||
|
delta -= 1.5
|
||||||
|
elif terrain_condition == "bon":
|
||||||
|
delta += 1.0
|
||||||
|
# inconnu → 0
|
||||||
|
|
||||||
|
# Vent
|
||||||
|
force_vent = weather_data.get("force_vent") or 0
|
||||||
|
try:
|
||||||
|
force_vent = float(force_vent)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
force_vent = 0.0
|
||||||
|
if force_vent >= 50:
|
||||||
|
delta -= 2.0
|
||||||
|
elif force_vent >= 30:
|
||||||
|
delta -= 1.0
|
||||||
|
|
||||||
|
# Températures extrêmes
|
||||||
|
temperature = weather_data.get("temperature")
|
||||||
|
try:
|
||||||
|
temperature = float(temperature) if temperature is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
temperature = None
|
||||||
|
if temperature is not None:
|
||||||
|
if temperature <= 0:
|
||||||
|
delta -= 1.0
|
||||||
|
elif temperature >= 35:
|
||||||
|
delta -= 1.0
|
||||||
|
|
||||||
|
return round(max(-5.0, min(5.0, delta)), 2)
|
||||||
|
|
||||||
|
|
||||||
|
def score_cheval_v2(p, all_participants, today, weather_data=None):
|
||||||
|
"""
|
||||||
|
Score un cheval pour le modèle V2.
|
||||||
|
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
|
||||||
|
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
|
||||||
|
"""
|
||||||
score = 0
|
score = 0
|
||||||
details = {}
|
details = {}
|
||||||
|
|
||||||
# 1. COTE - Essaye PMU API, sinon DB
|
# 1. COTE - Essaye PMU API, sinon DB
|
||||||
horse_name = p.get('nom', '')
|
horse_name = p.get("nom", "")
|
||||||
cote = 0
|
cote = 0
|
||||||
|
|
||||||
# Essayer d'abord depuis l'API PMU
|
# Essayer d'abord depuis l'API PMU
|
||||||
rapport = p.get('dernierRapportDirect', {})
|
rapport = p.get("dernierRapportDirect", {})
|
||||||
if rapport:
|
if rapport:
|
||||||
cote = rapport.get('rapport', 0)
|
cote = rapport.get("rapport", 0)
|
||||||
if not cote:
|
if not cote:
|
||||||
rapport_ref = p.get('dernierRapportReference', {})
|
rapport_ref = p.get("dernierRapportReference", {})
|
||||||
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0
|
cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
|
||||||
|
|
||||||
# Fallback: aller chercher dans la DB
|
# Fallback: aller chercher dans la DB
|
||||||
if not cote or cote == 0:
|
if not cote or cote == 0:
|
||||||
cote = get_cote_from_db(horse_name, today)
|
cote = get_cote_from_db(horse_name, today)
|
||||||
|
|
||||||
# Si toujours pas de cote, utiliser 99 comme valeur par defaut
|
# Si toujours pas de cote, utiliser 99 comme valeur par defaut
|
||||||
if not cote or cote == 0:
|
if not cote or cote == 0:
|
||||||
cote = 99.0
|
cote = 99.0
|
||||||
|
|
||||||
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
||||||
score += score_cote
|
score += score_cote
|
||||||
details['cote'] = round(cote, 1)
|
details["cote"] = round(cote, 1)
|
||||||
details['score_cote'] = round(score_cote, 1)
|
details["score_cote"] = round(score_cote, 1)
|
||||||
|
|
||||||
# 2. FORME - AUGMENTE a 30 pts
|
# 2. FORME - AUGMENTE a 30 pts
|
||||||
musique_stats = parse_musique(p.get('musique', ''))
|
musique_stats = parse_musique(p.get("musique", ""))
|
||||||
forme = musique_stats.get('forme_recente', 99)
|
forme = musique_stats.get("forme_recente", 99)
|
||||||
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0
|
score_forme = (
|
||||||
|
30
|
||||||
|
if forme <= 1
|
||||||
|
else 25
|
||||||
|
if forme <= 2
|
||||||
|
else 20
|
||||||
|
if forme <= 3
|
||||||
|
else 15
|
||||||
|
if forme <= 5
|
||||||
|
else 8
|
||||||
|
if forme <= 8
|
||||||
|
else 0
|
||||||
|
)
|
||||||
score += score_forme
|
score += score_forme
|
||||||
details['forme_recente'] = forme
|
details["forme_recente"] = forme
|
||||||
details['score_forme'] = score_forme
|
details["score_forme"] = score_forme
|
||||||
|
|
||||||
# 3. TAUX VICTOIRE (15 pts)
|
# 3. TAUX VICTOIRE (15 pts)
|
||||||
nb_courses_total = p.get('nombreCourses', 0)
|
nb_courses_total = p.get("nombreCourses", 0)
|
||||||
nb_victoires_total = p.get('nombreVictoires', 0)
|
nb_victoires_total = p.get("nombreVictoires", 0)
|
||||||
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
|
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||||
score_vic = min(15, tx_vic * 0.5)
|
score_vic = min(15, tx_vic * 0.5)
|
||||||
score += score_vic
|
score += score_vic
|
||||||
details['tx_victoire'] = round(tx_vic, 1)
|
details["tx_victoire"] = round(tx_vic, 1)
|
||||||
details['score_victoire'] = round(score_vic, 1)
|
details["score_victoire"] = round(score_vic, 1)
|
||||||
|
|
||||||
# 4. TAUX PLACE (15 pts)
|
# 4. TAUX PLACE (15 pts)
|
||||||
nb_places_total = p.get('nombrePlaces', 0)
|
nb_places_total = p.get("nombrePlaces", 0)
|
||||||
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
|
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||||
score_place = min(15, tx_place * 0.2)
|
score_place = min(15, tx_place * 0.2)
|
||||||
score += score_place
|
score += score_place
|
||||||
details['tx_place'] = round(tx_place, 1)
|
details["tx_place"] = round(tx_place, 1)
|
||||||
details['score_place'] = round(score_place, 1)
|
details["score_place"] = round(score_place, 1)
|
||||||
|
|
||||||
# 5. REDUCTION KM (10 pts)
|
# 5. REDUCTION KM (10 pts)
|
||||||
rk = p.get('reductionKilometrique', 0)
|
rk = p.get("reductionKilometrique", 0)
|
||||||
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0]
|
all_rk = [
|
||||||
|
x.get("reductionKilometrique", 0)
|
||||||
|
for x in all_participants
|
||||||
|
if x.get("reductionKilometrique", 0) > 0
|
||||||
|
]
|
||||||
if rk > 0 and all_rk:
|
if rk > 0 and all_rk:
|
||||||
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5
|
score_rk = (
|
||||||
|
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
|
||||||
|
if max(all_rk) > min(all_rk)
|
||||||
|
else 5
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
score_rk = 0
|
score_rk = 0
|
||||||
score += score_rk
|
score += score_rk
|
||||||
details['rk'] = rk
|
details["rk"] = rk
|
||||||
details['score_rk'] = round(score_rk, 1)
|
details["score_rk"] = round(score_rk, 1)
|
||||||
|
|
||||||
# 6. TENDANCE (10 pts)
|
# 6. TENDANCE (10 pts)
|
||||||
tendance = musique_stats.get('tendance', 0)
|
tendance = musique_stats.get("tendance", 0)
|
||||||
score_tendance = min(10, max(0, 5 + tendance))
|
score_tendance = min(10, max(0, 5 + tendance))
|
||||||
score += score_tendance
|
score += score_tendance
|
||||||
details['tendance'] = tendance
|
details["tendance"] = tendance
|
||||||
details['score_tendance'] = round(score_tendance, 1)
|
details["score_tendance"] = round(score_tendance, 1)
|
||||||
|
|
||||||
# 7. AVIS ENTRAINEUR (5 pts)
|
# 7. AVIS ENTRAINEUR (5 pts)
|
||||||
avis = p.get('avisEntraineur', 'NEUTRE')
|
avis = p.get("avisEntraineur", "NEUTRE")
|
||||||
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2)
|
score_avis = {
|
||||||
|
"POSITIF": 5,
|
||||||
|
"TRES_POSITIF": 5,
|
||||||
|
"NEUTRE": 2,
|
||||||
|
"NEGATIF": 0,
|
||||||
|
"TRES_NEGATIF": 0,
|
||||||
|
}.get(avis, 2)
|
||||||
score += score_avis
|
score += score_avis
|
||||||
details['avis_entraineur'] = avis
|
details["avis_entraineur"] = avis
|
||||||
details['score_avis'] = score_avis
|
details["score_avis"] = score_avis
|
||||||
|
|
||||||
# 8. BONUS OUTSIDER (5 pts)
|
# 8. BONUS OUTSIDER (5 pts)
|
||||||
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
||||||
score += bonus_outsider
|
score += bonus_outsider
|
||||||
details['bonus_outsider'] = bonus_outsider
|
details["bonus_outsider"] = bonus_outsider
|
||||||
|
|
||||||
# Driver change penalty
|
# Driver change penalty
|
||||||
if p.get('driverChange', False):
|
if p.get("driverChange", False):
|
||||||
score -= 3
|
score -= 3
|
||||||
details['driver_change'] = True
|
details["driver_change"] = True
|
||||||
|
|
||||||
details['score_total'] = round(score, 1)
|
# 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
|
||||||
details['musique'] = p.get('musique', '')
|
penetrometre = p.get("penetrometre_intitule", "") or ""
|
||||||
details['nb_victoires'] = nb_victoires_total
|
terrain_condition = (
|
||||||
details['nb_places'] = nb_places_total
|
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||||
details['nb_courses'] = nb_courses_total
|
)
|
||||||
|
weather_impact = 0.0
|
||||||
|
if weather_data is not None:
|
||||||
|
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||||
|
score += weather_impact
|
||||||
|
details["terrain_condition"] = terrain_condition
|
||||||
|
details["weather_impact"] = weather_impact
|
||||||
|
|
||||||
|
details["score_total"] = round(score, 1)
|
||||||
|
details["musique"] = p.get("musique", "")
|
||||||
|
details["nb_victoires"] = nb_victoires_total
|
||||||
|
details["nb_places"] = nb_places_total
|
||||||
|
details["nb_courses"] = nb_courses_total
|
||||||
|
|
||||||
return round(score, 1), details
|
return round(score, 1), details
|
||||||
|
|
||||||
|
|
||||||
def get_ze2sur4_combinaisons(top4):
|
def get_ze2sur4_combinaisons(top4):
|
||||||
combinaisons = []
|
combinaisons = []
|
||||||
for i in range(4):
|
for i in range(4):
|
||||||
for j in range(i+1, 4):
|
for j in range(i + 1, 4):
|
||||||
c1 = top4[i]
|
c1 = top4[i]
|
||||||
c2 = top4[j]
|
c2 = top4[j]
|
||||||
combinaisons.append({
|
combinaisons.append(
|
||||||
'cheval1': c1['nom'],
|
{
|
||||||
'numero1': c1['numero'],
|
"cheval1": c1["nom"],
|
||||||
'cheval2': c2['nom'],
|
"numero1": c1["numero"],
|
||||||
'numero2': c2['numero'],
|
"cheval2": c2["nom"],
|
||||||
'mise': 1.0,
|
"numero2": c2["numero"],
|
||||||
})
|
"mise": 1.0,
|
||||||
|
}
|
||||||
|
)
|
||||||
return combinaisons
|
return combinaisons
|
||||||
|
|
||||||
|
|
||||||
def build_recommendations_v2(scored_horses):
|
def build_recommendations_v2(scored_horses):
|
||||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||||
if len(ranked) < 4:
|
if len(ranked) < 4:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
|
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
|
||||||
top4_list = ranked[:4]
|
top4_list = ranked[:4]
|
||||||
|
|
||||||
def confiance(s):
|
def confiance(s):
|
||||||
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE"
|
return (
|
||||||
|
"FORTE"
|
||||||
|
if s >= 55
|
||||||
|
else "BONNE"
|
||||||
|
if s >= 45
|
||||||
|
else "MOYENNE"
|
||||||
|
if s >= 35
|
||||||
|
else "FAIBLE"
|
||||||
|
)
|
||||||
|
|
||||||
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
|
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
|
||||||
mise_ze2 = len(ze2_combinaisons) * 1.0
|
mise_ze2 = len(ze2_combinaisons) * 1.0
|
||||||
|
|
||||||
return {
|
return {
|
||||||
'simple_gagnant': {
|
"simple_gagnant": {
|
||||||
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'],
|
"cheval": top1["nom"],
|
||||||
'score': top1['score'], 'confiance': confiance(top1['score']),
|
"numero": top1["numero"],
|
||||||
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2)
|
"cote": top1["details"]["cote"],
|
||||||
|
"score": top1["score"],
|
||||||
|
"confiance": confiance(top1["score"]),
|
||||||
|
"mise_suggeree": 2.0,
|
||||||
|
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
|
||||||
},
|
},
|
||||||
'ze2_sur_4': {
|
"ze2_sur_4": {
|
||||||
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list],
|
"top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
|
||||||
'combinaisons': ze2_combinaisons,
|
"combinaisons": ze2_combinaisons,
|
||||||
'mise_totale': mise_ze2,
|
"mise_totale": mise_ze2,
|
||||||
'nb_combinaisons': len(ze2_combinaisons),
|
"nb_combinaisons": len(ze2_combinaisons),
|
||||||
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4),
|
"confiance": confiance(
|
||||||
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers'
|
(top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
|
||||||
|
),
|
||||||
|
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
|
||||||
},
|
},
|
||||||
'outsider': _find_outsider(ranked),
|
"outsider": _find_outsider(ranked),
|
||||||
'budget_total': 2.0 + mise_ze2,
|
"budget_total": 2.0 + mise_ze2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def _find_outsider(ranked):
|
def _find_outsider(ranked):
|
||||||
for h in ranked[3:7]:
|
for h in ranked[3:7]:
|
||||||
d = h['details']
|
d = h["details"]
|
||||||
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5:
|
if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
|
||||||
return {
|
return {
|
||||||
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'],
|
"cheval": h["nom"],
|
||||||
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2)
|
"numero": h["numero"],
|
||||||
|
"cote": d["cote"],
|
||||||
|
"mise_suggeree": 1.0,
|
||||||
|
"gain_potentiel": round(1.0 * d["cote"], 2),
|
||||||
}
|
}
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
cursor = conn.cursor()
|
cursor = conn.cursor()
|
||||||
|
|
||||||
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
||||||
|
|
||||||
for i, h in enumerate(scored_horses, 1):
|
for i, h in enumerate(scored_horses, 1):
|
||||||
d = h['details']
|
d = h["details"]
|
||||||
cursor.execute("""
|
cursor.execute(
|
||||||
|
"""
|
||||||
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
||||||
score_cote, score_forme, score_victoire, score_place, score_rk,
|
score_cote, score_forme, score_victoire, score_place, score_rk,
|
||||||
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
||||||
avis_entraineur, musique, rang_scoring, scoring_version)
|
avis_entraineur, musique, rang_scoring, scoring_version)
|
||||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
|
||||||
""", (date_course, libelle, h['numero'], h['nom'], h['score'],
|
""",
|
||||||
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0),
|
(
|
||||||
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0),
|
date_course,
|
||||||
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0),
|
libelle,
|
||||||
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''),
|
h["numero"],
|
||||||
d.get('musique', ''), i))
|
h["nom"],
|
||||||
|
h["score"],
|
||||||
|
d.get("score_cote", 0),
|
||||||
|
d.get("score_forme", 0),
|
||||||
|
d.get("score_victoire", 0),
|
||||||
|
d.get("score_place", 0),
|
||||||
|
d.get("score_rk", 0),
|
||||||
|
d.get("score_tendance", 0),
|
||||||
|
d.get("score_avis", 0),
|
||||||
|
d.get("cote", 0),
|
||||||
|
d.get("forme_recente", 0),
|
||||||
|
d.get("tx_victoire", 0),
|
||||||
|
d.get("tx_place", 0),
|
||||||
|
d.get("avis_entraineur", ""),
|
||||||
|
d.get("musique", ""),
|
||||||
|
i,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
conn.close()
|
conn.close()
|
||||||
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
today = datetime.now().strftime('%Y-%m-%d')
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
date_pmu = datetime.now().strftime('%d%m%Y')
|
date_pmu = datetime.now().strftime("%d%m%Y")
|
||||||
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===")
|
print(
|
||||||
|
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
|
||||||
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
||||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||||
reunions = r.json().get('programme', {}).get('reunions', [])
|
reunions = r.json().get("programme", {}).get("reunions", [])
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Erreur: {e}")
|
print(f"Erreur: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
quinte = None
|
quinte = None
|
||||||
for reunion in reunions:
|
for reunion in reunions:
|
||||||
for course in reunion.get('courses', []):
|
for course in reunion.get("courses", []):
|
||||||
paris_types = [p["typePari"] for p in course.get("paris", [])]
|
paris_types = [p["typePari"] for p in course.get("paris", [])]
|
||||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''):
|
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
|
||||||
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''),
|
"libelle", ""
|
||||||
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0))
|
):
|
||||||
|
quinte = (
|
||||||
|
reunion["numOfficiel"],
|
||||||
|
course["numOrdre"],
|
||||||
|
course.get("libelle", ""),
|
||||||
|
reunion["hippodrome"]["libelleCourt"],
|
||||||
|
course.get("heureDepart", 0),
|
||||||
|
)
|
||||||
break
|
break
|
||||||
if quinte:
|
if quinte:
|
||||||
break
|
break
|
||||||
|
|
||||||
if not quinte:
|
if not quinte:
|
||||||
# Fallback: utiliser la premiere reunion francaise avec predictions
|
# Fallback: utiliser la premiere reunion francaise avec predictions
|
||||||
conn = sqlite3.connect(DB_PATH)
|
conn = sqlite3.connect(DB_PATH)
|
||||||
conn.row_factory = sqlite3.Row
|
conn.row_factory = sqlite3.Row
|
||||||
r = conn.execute("""
|
r = conn.execute(
|
||||||
|
"""
|
||||||
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
||||||
FROM pmu_courses c
|
FROM pmu_courses c
|
||||||
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
|
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
|
||||||
@@ -264,57 +432,82 @@ def main():
|
|||||||
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
|
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
|
||||||
AND p.race_name LIKE '%' || c.libelle || '%')
|
AND p.race_name LIKE '%' || c.libelle || '%')
|
||||||
ORDER BY c.heure_depart_str ASC LIMIT 1
|
ORDER BY c.heure_depart_str ASC LIMIT 1
|
||||||
""", (today, today)).fetchone()
|
""",
|
||||||
|
(today, today),
|
||||||
|
).fetchone()
|
||||||
conn.close()
|
conn.close()
|
||||||
if r:
|
if r:
|
||||||
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0)
|
quinte = (
|
||||||
|
r["num_reunion"],
|
||||||
|
r["num_course"],
|
||||||
|
r["libelle"],
|
||||||
|
r["hippodrome_court"],
|
||||||
|
0,
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
print("Aucune course trouvee")
|
print("Aucune course trouvee")
|
||||||
return
|
return
|
||||||
|
|
||||||
num_r, num_c, libelle, hippodrome, heure_ts = quinte
|
num_r, num_c, libelle, hippodrome, heure_ts = quinte
|
||||||
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
|
heure = (
|
||||||
|
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
|
||||||
|
if heure_ts
|
||||||
|
else "13:55"
|
||||||
|
)
|
||||||
print(f"Course: {libelle} - {hippodrome} {heure}")
|
print(f"Course: {libelle} - {hippodrome} {heure}")
|
||||||
|
|
||||||
try:
|
try:
|
||||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
|
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
|
||||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||||
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT']
|
participants = [
|
||||||
|
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
|
||||||
|
]
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
print(f"Erreur: {e}")
|
print(f"Erreur: {e}")
|
||||||
return
|
return
|
||||||
|
|
||||||
scored_horses = []
|
scored_horses = []
|
||||||
for p in participants:
|
for p in participants:
|
||||||
score, details = score_cheval_v2(p, participants, today)
|
score, details = score_cheval_v2(p, participants, today)
|
||||||
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details})
|
scored_horses.append(
|
||||||
|
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
|
||||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
)
|
||||||
|
|
||||||
|
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||||
print(f"\n=== TOP 4 ===")
|
print(f"\n=== TOP 4 ===")
|
||||||
for i, h in enumerate(ranked[:4], 1):
|
for i, h in enumerate(ranked[:4], 1):
|
||||||
d = h['details']
|
d = h["details"]
|
||||||
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}")
|
print(
|
||||||
|
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
|
||||||
|
)
|
||||||
|
|
||||||
save_to_db(ranked, today, hippodrome, libelle)
|
save_to_db(ranked, today, hippodrome, libelle)
|
||||||
|
|
||||||
reco = build_recommendations_v2(scored_horses)
|
reco = build_recommendations_v2(scored_horses)
|
||||||
if reco:
|
if reco:
|
||||||
print(f"\n=== RECOMMANDATIONS ===")
|
print(f"\n=== RECOMMANDATIONS ===")
|
||||||
sg = reco['simple_gagnant']
|
sg = reco["simple_gagnant"]
|
||||||
print(f"\n🎯 SIMPLE GAGNANT:")
|
print(f"\n🎯 SIMPLE GAGNANT:")
|
||||||
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)")
|
print(
|
||||||
|
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
|
||||||
ze2 = reco['ze2_sur_4']
|
)
|
||||||
|
|
||||||
|
ze2 = reco["ze2_sur_4"]
|
||||||
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
|
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
|
||||||
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)")
|
print(
|
||||||
|
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
|
||||||
|
)
|
||||||
print(f" Confiance: {ze2['confiance']}")
|
print(f" Confiance: {ze2['confiance']}")
|
||||||
print(f" Combinaisons:")
|
print(f" Combinaisons:")
|
||||||
for c in ze2['combinaisons']:
|
for c in ze2["combinaisons"]:
|
||||||
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}")
|
print(
|
||||||
|
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
|
||||||
|
)
|
||||||
|
|
||||||
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
||||||
print(f" - Simple Gagnant: 2EUR")
|
print(f" - Simple Gagnant: 2EUR")
|
||||||
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
main()
|
main()
|
||||||
|
|||||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||||
|
HRT-79: Alertes Telegram configurables (Premium)
|
||||||
|
|
||||||
|
Fonctionnement :
|
||||||
|
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||||
|
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||||
|
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||||
|
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
import sqlite3
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
import requests
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||||
|
|
||||||
|
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||||
|
|
||||||
|
|
||||||
|
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _get_db():
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||||
|
"""
|
||||||
|
Envoie un message Telegram à un chat_id donné.
|
||||||
|
|
||||||
|
Returns True si succès, False sinon.
|
||||||
|
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||||
|
return False
|
||||||
|
|
||||||
|
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||||
|
payload = {
|
||||||
|
"chat_id": chat_id,
|
||||||
|
"text": text,
|
||||||
|
"parse_mode": "Markdown",
|
||||||
|
"disable_web_page_preview": True,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
resp = requests.post(url, json=payload, timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
return True
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||||
|
chat_id,
|
||||||
|
resp.status_code,
|
||||||
|
resp.text[:200],
|
||||||
|
)
|
||||||
|
return False
|
||||||
|
except requests.RequestException as exc:
|
||||||
|
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||||
|
"""
|
||||||
|
Construit le message Markdown de l'alerte pré-course.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||||
|
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||||
|
|
||||||
|
Returns: texte Markdown formaté
|
||||||
|
"""
|
||||||
|
hippo = race_data.get("hippo", "?")
|
||||||
|
num_course = race_data.get("num_course", "?")
|
||||||
|
heure = race_data.get("heure", "?")
|
||||||
|
type_course = race_data.get("type_course", "")
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||||
|
f"⏰ Départ prévu : *{heure}*",
|
||||||
|
]
|
||||||
|
if type_course:
|
||||||
|
lines.append(f"📋 Type : {type_course}")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||||
|
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||||
|
|
||||||
|
if top3:
|
||||||
|
lines.append("📊 *Top-3 ML :*")
|
||||||
|
for i, p in enumerate(top3, 1):
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
prob = p.get("prob_top3", 0)
|
||||||
|
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
if value_bets:
|
||||||
|
lines.append("💡 *Value bets :*")
|
||||||
|
for p in value_bets[:3]:
|
||||||
|
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||||
|
score = p.get("ml_score", 0)
|
||||||
|
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||||
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Main send function ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||||
|
"""
|
||||||
|
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||||
|
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minutes_before: non utilisé directement (la planification est gérée
|
||||||
|
par le scheduler), présent pour documentation.
|
||||||
|
|
||||||
|
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||||
|
"""
|
||||||
|
if not BOT_TOKEN:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||||
|
)
|
||||||
|
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||||
|
|
||||||
|
try:
|
||||||
|
conn = _get_db()
|
||||||
|
today = datetime.now().strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
# Récupère les courses du jour
|
||||||
|
try:
|
||||||
|
courses_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT DISTINCT
|
||||||
|
hippo, num_course, heure_depart, type_course
|
||||||
|
FROM pmu_courses
|
||||||
|
WHERE date_programme = ?
|
||||||
|
AND heure_depart IS NOT NULL
|
||||||
|
ORDER BY heure_depart ASC
|
||||||
|
LIMIT 20
|
||||||
|
""",
|
||||||
|
(today,),
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not courses_rows:
|
||||||
|
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||||
|
try:
|
||||||
|
users = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT id, telegram_chat_id,
|
||||||
|
alert_value_bets, alert_top1, alert_quinte_only
|
||||||
|
FROM users
|
||||||
|
WHERE plan IN ('premium', 'pro')
|
||||||
|
AND is_active = 1
|
||||||
|
AND telegram_chat_id IS NOT NULL
|
||||||
|
AND telegram_chat_id != ''
|
||||||
|
""",
|
||||||
|
).fetchall()
|
||||||
|
except sqlite3.OperationalError as exc:
|
||||||
|
logger.warning(
|
||||||
|
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||||
|
exc,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
if not users:
|
||||||
|
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||||
|
conn.close()
|
||||||
|
return stats
|
||||||
|
|
||||||
|
for course_row in courses_rows:
|
||||||
|
hippo = course_row["hippo"] or "?"
|
||||||
|
num_course = course_row["num_course"] or "?"
|
||||||
|
heure_ts = course_row["heure_depart"]
|
||||||
|
type_course = course_row["type_course"] or ""
|
||||||
|
|
||||||
|
try:
|
||||||
|
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||||
|
heure_str = dt.strftime("%H:%M")
|
||||||
|
except Exception:
|
||||||
|
heure_str = str(heure_ts)
|
||||||
|
|
||||||
|
race_data = {
|
||||||
|
"hippo": hippo,
|
||||||
|
"num_course": num_course,
|
||||||
|
"heure": heure_str,
|
||||||
|
"type_course": type_course,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Récupère les prédictions ML pour cette course
|
||||||
|
predictions = []
|
||||||
|
try:
|
||||||
|
pred_rows = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
AND hippo = ?
|
||||||
|
AND num_course = ?
|
||||||
|
ORDER BY prob_top3 DESC
|
||||||
|
LIMIT 10
|
||||||
|
""",
|
||||||
|
(today, hippo, num_course),
|
||||||
|
).fetchall()
|
||||||
|
predictions = [dict(r) for r in pred_rows]
|
||||||
|
except sqlite3.OperationalError:
|
||||||
|
pass # table absente, on envoie quand même avec données minimales
|
||||||
|
|
||||||
|
is_quinte = (
|
||||||
|
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
for user in users:
|
||||||
|
chat_id = user["telegram_chat_id"]
|
||||||
|
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||||
|
alert_top1 = bool(user["alert_top1"])
|
||||||
|
alert_value_bets = bool(user["alert_value_bets"])
|
||||||
|
|
||||||
|
# Filtre quinte_only
|
||||||
|
if alert_quinte_only and not is_quinte:
|
||||||
|
stats["skipped"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Construit le message selon préférences
|
||||||
|
filtered_preds = []
|
||||||
|
if predictions:
|
||||||
|
for p in predictions:
|
||||||
|
include = False
|
||||||
|
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||||
|
include = True
|
||||||
|
if alert_value_bets and p.get("is_value_bet"):
|
||||||
|
include = True
|
||||||
|
if include:
|
||||||
|
filtered_preds.append(p)
|
||||||
|
|
||||||
|
text = build_race_alert(race_data, filtered_preds)
|
||||||
|
ok = send_telegram_message(chat_id, text)
|
||||||
|
if ok:
|
||||||
|
stats["sent"] += 1
|
||||||
|
else:
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as exc:
|
||||||
|
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
stats["errors"] += 1
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats["sent"],
|
||||||
|
stats["skipped"],
|
||||||
|
stats["errors"],
|
||||||
|
)
|
||||||
|
return stats
|
||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
448
tests/beta_monitor.py
Normal file
448
tests/beta_monitor.py
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
"""
|
||||||
|
Beta Monitoring — SaaS Turf Prédictions IA
|
||||||
|
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||||
|
Ticket: HRT-34
|
||||||
|
|
||||||
|
Ce module :
|
||||||
|
- Collecte les feedbacks beta via l'API in-app
|
||||||
|
- Envoie des alertes Telegram en cas d'erreur détectée pendant la beta
|
||||||
|
- Génère le rapport beta final (bugs, UX, NPS)
|
||||||
|
|
||||||
|
Usage :
|
||||||
|
# Démarrer le monitoring beta
|
||||||
|
python tests/beta_monitor.py --watch --interval 60
|
||||||
|
|
||||||
|
# Générer le rapport beta final
|
||||||
|
python tests/beta_monitor.py --report
|
||||||
|
|
||||||
|
# Test d'envoi Telegram
|
||||||
|
python tests/beta_monitor.py --test-telegram
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import sqlite3
|
||||||
|
import requests
|
||||||
|
import argparse
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||||
|
TELEGRAM_TOKEN = os.environ.get(
|
||||||
|
"TELEGRAM_TOKEN", "8649773134:AAFqzZVtSHfPPFDadcte1B-1h23nZ8DmdYE"
|
||||||
|
)
|
||||||
|
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") # À configurer
|
||||||
|
|
||||||
|
BETA_DB_PATH = os.environ.get("BETA_DB_PATH", "/home/h3r7/turf_saas/turf_saas.db")
|
||||||
|
REPORTS_DIR = Path("tests/reports")
|
||||||
|
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
# Seuils d'alerte
|
||||||
|
ERROR_RATE_THRESHOLD = 0.01 # 1% d'erreurs → alerte
|
||||||
|
LATENCY_P95_THRESHOLD_MS = 500 # p95 > 500ms → alerte
|
||||||
|
BETA_MIN_USERS = 10 # Minimum d'utilisateurs beta requis
|
||||||
|
NPS_TARGET = 7.0 # NPS cible (sur 10)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Alertes Telegram
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def send_telegram(message: str, parse_mode: str = "Markdown") -> bool:
|
||||||
|
"""Envoie un message Telegram d'alerte."""
|
||||||
|
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
|
||||||
|
print(f"⚠️ Telegram non configuré. Message: {message[:100]}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
try:
|
||||||
|
resp = requests.post(
|
||||||
|
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
|
||||||
|
json={
|
||||||
|
"chat_id": TELEGRAM_CHAT_ID,
|
||||||
|
"text": message,
|
||||||
|
"parse_mode": parse_mode,
|
||||||
|
},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
print(f"✅ Alerte Telegram envoyée")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
print(f"❌ Telegram erreur: {resp.status_code} — {resp.text}")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"❌ Telegram exception: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def alert_error(endpoint: str, status_code: int, message: str):
|
||||||
|
"""Alerte Telegram sur erreur critique."""
|
||||||
|
text = (
|
||||||
|
f"🚨 *ALERTE BETA — SaaS Turf IA*\n\n"
|
||||||
|
f"Erreur détectée sur `{endpoint}`\n"
|
||||||
|
f"Status: `{status_code}`\n"
|
||||||
|
f"Message: {message[:200]}\n"
|
||||||
|
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||||
|
f"_Ticket: HRT-34_"
|
||||||
|
)
|
||||||
|
send_telegram(text)
|
||||||
|
|
||||||
|
|
||||||
|
def alert_performance(p95_ms: float, error_rate: float):
|
||||||
|
"""Alerte Telegram sur dégradation de performance."""
|
||||||
|
text = (
|
||||||
|
f"⚠️ *ALERTE PERFORMANCE — SaaS Turf IA*\n\n"
|
||||||
|
f"p95 latence: `{p95_ms:.0f}ms` (seuil: {LATENCY_P95_THRESHOLD_MS}ms)\n"
|
||||||
|
f"Error rate: `{error_rate * 100:.2f}%` (seuil: {ERROR_RATE_THRESHOLD * 100:.1f}%)\n"
|
||||||
|
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||||
|
f"_Ticket: HRT-34_"
|
||||||
|
)
|
||||||
|
send_telegram(text)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Collecte de métriques
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class BetaMonitor:
|
||||||
|
"""Moniteur actif pendant la beta fermée."""
|
||||||
|
|
||||||
|
ENDPOINTS_TO_CHECK = [
|
||||||
|
"/api",
|
||||||
|
"/api/races",
|
||||||
|
"/api/scoring",
|
||||||
|
"/dashboard",
|
||||||
|
"/",
|
||||||
|
]
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = BASE_URL):
|
||||||
|
self.base_url = base_url.rstrip("/")
|
||||||
|
self.errors: list[dict] = []
|
||||||
|
self.latencies: list[float] = []
|
||||||
|
self.check_count = 0
|
||||||
|
|
||||||
|
def check_endpoint(self, path: str) -> dict:
|
||||||
|
"""Vérifie un endpoint et retourne le résultat."""
|
||||||
|
start = time.time()
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{self.base_url}{path}", timeout=10)
|
||||||
|
latency_ms = (time.time() - start) * 1000
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"status": resp.status_code,
|
||||||
|
"latency_ms": latency_ms,
|
||||||
|
"ok": resp.status_code < 500,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
except requests.exceptions.ConnectionError as e:
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"status": 0,
|
||||||
|
"latency_ms": 0,
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
except Exception as e:
|
||||||
|
return {
|
||||||
|
"path": path,
|
||||||
|
"status": 0,
|
||||||
|
"latency_ms": 0,
|
||||||
|
"ok": False,
|
||||||
|
"error": str(e),
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def run_checks(self) -> dict:
|
||||||
|
"""Exécute tous les checks et retourne un résumé."""
|
||||||
|
results = [self.check_endpoint(p) for p in self.ENDPOINTS_TO_CHECK]
|
||||||
|
self.check_count += 1
|
||||||
|
|
||||||
|
failures = [r for r in results if not r["ok"]]
|
||||||
|
latencies = [r["latency_ms"] for r in results if r["latency_ms"] > 0]
|
||||||
|
|
||||||
|
p95 = (
|
||||||
|
sorted(latencies)[int(len(latencies) * 0.95)]
|
||||||
|
if len(latencies) >= 2
|
||||||
|
else (latencies[0] if latencies else 0)
|
||||||
|
)
|
||||||
|
error_rate = len(failures) / len(results) if results else 0
|
||||||
|
|
||||||
|
# Stocker pour rapport
|
||||||
|
self.latencies.extend(latencies)
|
||||||
|
self.errors.extend(failures)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"check_number": self.check_count,
|
||||||
|
"timestamp": datetime.now().isoformat(),
|
||||||
|
"total_checks": len(results),
|
||||||
|
"failures": len(failures),
|
||||||
|
"error_rate": error_rate,
|
||||||
|
"p95_ms": p95,
|
||||||
|
"results": results,
|
||||||
|
}
|
||||||
|
|
||||||
|
def watch(self, interval_seconds: int = 60):
|
||||||
|
"""Surveillance continue avec alertes Telegram."""
|
||||||
|
print(f"🔍 Beta monitoring démarré — {self.base_url}")
|
||||||
|
print(f" Intervalle: {interval_seconds}s")
|
||||||
|
print(f" Endpoints: {len(self.ENDPOINTS_TO_CHECK)}")
|
||||||
|
print(f" Ctrl+C pour arrêter\n")
|
||||||
|
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
summary = self.run_checks()
|
||||||
|
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||||
|
|
||||||
|
status_icon = "✅" if summary["error_rate"] == 0 else "❌"
|
||||||
|
print(
|
||||||
|
f"[{timestamp}] {status_icon} "
|
||||||
|
f"Check #{summary['check_number']} — "
|
||||||
|
f"p95={summary['p95_ms']:.0f}ms, "
|
||||||
|
f"errors={summary['failures']}/{summary['total_checks']}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alertes
|
||||||
|
if summary["error_rate"] > ERROR_RATE_THRESHOLD:
|
||||||
|
consecutive_errors += 1
|
||||||
|
if consecutive_errors >= 2: # 2 checks consécutifs en erreur
|
||||||
|
for failure in summary["results"]:
|
||||||
|
if not failure["ok"]:
|
||||||
|
alert_error(
|
||||||
|
failure["path"],
|
||||||
|
failure.get("status", 0),
|
||||||
|
failure.get("error", "Non-2xx response"),
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
consecutive_errors = 0
|
||||||
|
|
||||||
|
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS:
|
||||||
|
print(f"⚠️ Latence p95 élevée: {summary['p95_ms']:.0f}ms")
|
||||||
|
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS * 2:
|
||||||
|
alert_performance(summary["p95_ms"], summary["error_rate"])
|
||||||
|
|
||||||
|
# Sauvegarder les résultats
|
||||||
|
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||||
|
with open(log_file, "a") as f:
|
||||||
|
f.write(json.dumps(summary) + "\n")
|
||||||
|
|
||||||
|
time.sleep(interval_seconds)
|
||||||
|
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print(f"\n⏹️ Monitoring arrêté après {self.check_count} checks")
|
||||||
|
self.generate_report()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Rapport beta final
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
class BetaReport:
|
||||||
|
"""Générateur de rapport beta fermée."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: str = BASE_URL):
|
||||||
|
self.base_url = base_url
|
||||||
|
self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
|
||||||
|
def collect_feedback_from_db(self) -> list[dict]:
|
||||||
|
"""Collecte les feedbacks depuis la BDD (table beta_feedback si elle existe)."""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(BETA_DB_PATH)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type='table' AND name='beta_feedback'"
|
||||||
|
)
|
||||||
|
if not c.fetchone():
|
||||||
|
conn.close()
|
||||||
|
return []
|
||||||
|
c.execute("SELECT * FROM beta_feedback ORDER BY created_at DESC")
|
||||||
|
rows = c.fetchall()
|
||||||
|
conn.close()
|
||||||
|
return [dict(zip([col[0] for col in c.description], row)) for row in rows]
|
||||||
|
except Exception as e:
|
||||||
|
print(f"⚠️ Impossible de lire beta_feedback: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def collect_monitor_logs(self) -> list[dict]:
|
||||||
|
"""Lit les logs du monitoring beta."""
|
||||||
|
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||||
|
if not log_file.exists():
|
||||||
|
return []
|
||||||
|
entries = []
|
||||||
|
with open(log_file) as f:
|
||||||
|
for line in f:
|
||||||
|
try:
|
||||||
|
entries.append(json.loads(line))
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return entries
|
||||||
|
|
||||||
|
def generate(self) -> str:
|
||||||
|
"""Génère le rapport complet et le sauvegarde."""
|
||||||
|
feedbacks = self.collect_feedback_from_db()
|
||||||
|
monitor_logs = self.collect_monitor_logs()
|
||||||
|
|
||||||
|
# Calculer NPS depuis les feedbacks
|
||||||
|
nps_scores = [
|
||||||
|
f.get("nps_score") for f in feedbacks if f.get("nps_score") is not None
|
||||||
|
]
|
||||||
|
avg_nps = sum(nps_scores) / len(nps_scores) if nps_scores else None
|
||||||
|
|
||||||
|
# Statistiques monitoring
|
||||||
|
if monitor_logs:
|
||||||
|
all_latencies = []
|
||||||
|
total_errors = 0
|
||||||
|
total_checks = 0
|
||||||
|
for entry in monitor_logs:
|
||||||
|
all_latencies.extend(
|
||||||
|
[
|
||||||
|
r["latency_ms"]
|
||||||
|
for r in entry.get("results", [])
|
||||||
|
if r.get("latency_ms", 0) > 0
|
||||||
|
]
|
||||||
|
)
|
||||||
|
total_errors += entry.get("failures", 0)
|
||||||
|
total_checks += entry.get("total_checks", 0)
|
||||||
|
avg_latency = (
|
||||||
|
sum(all_latencies) / len(all_latencies) if all_latencies else 0
|
||||||
|
)
|
||||||
|
overall_error_rate = total_errors / total_checks if total_checks > 0 else 0
|
||||||
|
else:
|
||||||
|
avg_latency = 0
|
||||||
|
overall_error_rate = 0
|
||||||
|
total_checks = 0
|
||||||
|
|
||||||
|
# Construire le rapport
|
||||||
|
report = []
|
||||||
|
report.append("=" * 60)
|
||||||
|
report.append("RAPPORT BETA FERMÉE — SaaS Turf Prédictions IA")
|
||||||
|
report.append(f"Généré le : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||||
|
report.append(f"Ticket : HRT-34")
|
||||||
|
report.append("=" * 60)
|
||||||
|
report.append("")
|
||||||
|
report.append("## 1. PARTICIPANTS BETA")
|
||||||
|
report.append(f" Feedbacks reçus : {len(feedbacks)}")
|
||||||
|
report.append(
|
||||||
|
f" NPS moyen : {avg_nps:.1f}/10"
|
||||||
|
if avg_nps
|
||||||
|
else " NPS moyen : (en attente feedbacks)"
|
||||||
|
)
|
||||||
|
report.append(f" Cible NPS : ≥ {NPS_TARGET}/10")
|
||||||
|
nps_ok = avg_nps is not None and avg_nps >= NPS_TARGET
|
||||||
|
report.append(
|
||||||
|
f" Statut NPS : {'✅ OBJECTIF ATTEINT' if nps_ok else '⏳ En attente' if avg_nps is None else '❌ OBJECTIF NON ATTEINT'}"
|
||||||
|
)
|
||||||
|
report.append("")
|
||||||
|
report.append("## 2. BUGS SIGNALÉS")
|
||||||
|
bugs = [f for f in feedbacks if f.get("type") == "bug"]
|
||||||
|
critical_bugs = [b for b in bugs if b.get("severity") in ("critical", "high")]
|
||||||
|
report.append(f" Total bugs : {len(bugs)}")
|
||||||
|
report.append(f" Critiques/High : {len(critical_bugs)}")
|
||||||
|
report.append(
|
||||||
|
f" Statut : {'✅ 0 bug critique' if len(critical_bugs) == 0 else f'❌ {len(critical_bugs)} bug(s) critique(s)'}"
|
||||||
|
)
|
||||||
|
report.append("")
|
||||||
|
report.append("## 3. PERFORMANCE RÉELLE (monitoring)")
|
||||||
|
report.append(f" Checks effectués: {total_checks}")
|
||||||
|
report.append(f" Latence moyenne : {avg_latency:.1f}ms")
|
||||||
|
report.append(f" Error rate : {overall_error_rate * 100:.2f}%")
|
||||||
|
report.append(f" Seuil latence : {LATENCY_P95_THRESHOLD_MS}ms")
|
||||||
|
perf_ok = (
|
||||||
|
avg_latency < LATENCY_P95_THRESHOLD_MS
|
||||||
|
and overall_error_rate < ERROR_RATE_THRESHOLD
|
||||||
|
)
|
||||||
|
report.append(
|
||||||
|
f" Statut : {'✅ OBJECTIF ATTEINT' if perf_ok else '⏳ Données insuffisantes' if total_checks == 0 else '❌ OBJECTIF NON ATTEINT'}"
|
||||||
|
)
|
||||||
|
report.append("")
|
||||||
|
report.append("## 4. FEEDBACKS UX")
|
||||||
|
ux_feedbacks = [f for f in feedbacks if f.get("type") == "ux"]
|
||||||
|
report.append(f" Retours UX : {len(ux_feedbacks)}")
|
||||||
|
if ux_feedbacks:
|
||||||
|
for fb in ux_feedbacks[:5]: # Top 5
|
||||||
|
report.append(f" - {fb.get('comment', '')[:100]}")
|
||||||
|
report.append("")
|
||||||
|
report.append("## 5. VERDICT BETA FERMÉE")
|
||||||
|
users_ok = len(feedbacks) >= 5 # Au moins 5 feedbacks = 5 users satisfaits
|
||||||
|
verdict = all([users_ok, nps_ok, len(critical_bugs) == 0])
|
||||||
|
report.append(
|
||||||
|
f" Participants suffisants (≥5) : {'✅' if users_ok else '❌'}"
|
||||||
|
)
|
||||||
|
report.append(f" NPS ≥ 7/10 : {'✅' if nps_ok else '❌'}")
|
||||||
|
report.append(
|
||||||
|
f" 0 bug critique : {'✅' if len(critical_bugs) == 0 else '❌'}"
|
||||||
|
)
|
||||||
|
report.append("")
|
||||||
|
report.append(
|
||||||
|
f" VERDICT GLOBAL : {'✅ GO — Beta réussie' if verdict else '❌ NO-GO — Conditions non remplies'}"
|
||||||
|
)
|
||||||
|
report.append("=" * 60)
|
||||||
|
|
||||||
|
report_text = "\n".join(report)
|
||||||
|
|
||||||
|
# Sauvegarder
|
||||||
|
report_file = REPORTS_DIR / f"beta_report_{self.timestamp}.txt"
|
||||||
|
with open(report_file, "w") as f:
|
||||||
|
f.write(report_text)
|
||||||
|
|
||||||
|
print(report_text)
|
||||||
|
print(f"\nRapport sauvegardé : {report_file}")
|
||||||
|
|
||||||
|
return report_text
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# CLI
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser(description="Beta Monitor — SaaS Turf IA")
|
||||||
|
parser.add_argument("--watch", action="store_true", help="Surveillance continue")
|
||||||
|
parser.add_argument(
|
||||||
|
"--interval", type=int, default=60, help="Intervalle en secondes (défaut: 60)"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--report", action="store_true", help="Générer le rapport beta final"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--test-telegram", action="store_true", help="Tester l'envoi Telegram"
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--url", default=BASE_URL, help=f"URL de l'app (défaut: {BASE_URL})"
|
||||||
|
)
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
if args.test_telegram:
|
||||||
|
print("Test d'envoi Telegram...")
|
||||||
|
ok = send_telegram(
|
||||||
|
"✅ *Test alerte Beta* — SaaS Turf IA\n_Ceci est un test du système d'alertes QA_\nTicket: HRT-34"
|
||||||
|
)
|
||||||
|
sys.exit(0 if ok else 1)
|
||||||
|
|
||||||
|
if args.report:
|
||||||
|
reporter = BetaReport(args.url)
|
||||||
|
reporter.generate()
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
if args.watch:
|
||||||
|
monitor = BetaMonitor(args.url)
|
||||||
|
monitor.watch(interval_seconds=args.interval)
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
parser.print_help()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
"""
|
||||||
|
conftest.py — Configuration pytest globale
|
||||||
|
SaaS Turf Prédictions IA — Sprint 8 QA
|
||||||
|
Ticket: HRT-34
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import asyncio
|
||||||
|
import pytest
|
||||||
|
from pathlib import Path
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Répertoires de sortie
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
REPORTS_DIR = Path("tests/reports")
|
||||||
|
SCREENSHOTS_DIR = Path("tests/screenshots")
|
||||||
|
|
||||||
|
for d in [REPORTS_DIR, SCREENSHOTS_DIR]:
|
||||||
|
d.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Variables d'environnement
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fixtures globales
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def base_url():
|
||||||
|
return BASE_URL
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def event_loop():
|
||||||
|
"""Event loop partagé pour les tests async de la session."""
|
||||||
|
policy = asyncio.get_event_loop_policy()
|
||||||
|
loop = policy.new_event_loop()
|
||||||
|
yield loop
|
||||||
|
loop.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def reports_dir():
|
||||||
|
return REPORTS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def screenshots_dir():
|
||||||
|
return SCREENSHOTS_DIR
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Hook : screenshot automatique sur échec
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||||
|
def pytest_runtest_makereport(item, call):
|
||||||
|
"""Capture screenshot automatiquement sur tout test E2E en échec."""
|
||||||
|
outcome = yield
|
||||||
|
report = outcome.get_result()
|
||||||
|
|
||||||
|
if report.when == "call" and report.failed:
|
||||||
|
# Récupérer la page Playwright si disponible dans les fixtures
|
||||||
|
page = None
|
||||||
|
for fixture_name in ("page", "context_page"):
|
||||||
|
if fixture_name in item.funcargs:
|
||||||
|
val = item.funcargs[fixture_name]
|
||||||
|
if isinstance(val, tuple):
|
||||||
|
page = val[0] # (page, browser_name)
|
||||||
|
else:
|
||||||
|
page = val
|
||||||
|
break
|
||||||
|
|
||||||
|
if page is not None:
|
||||||
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
test_name = item.name.replace("/", "_").replace(":", "_")
|
||||||
|
screenshot_path = SCREENSHOTS_DIR / f"FAIL_{test_name}_{timestamp}.png"
|
||||||
|
try:
|
||||||
|
# Playwright page.screenshot est synchrone dans les fixtures sync
|
||||||
|
# Pour les fixtures async, on force la capture
|
||||||
|
import asyncio as _asyncio
|
||||||
|
|
||||||
|
if _asyncio.iscoroutinefunction(page.screenshot):
|
||||||
|
loop = _asyncio.get_event_loop()
|
||||||
|
loop.run_until_complete(page.screenshot(path=str(screenshot_path)))
|
||||||
|
else:
|
||||||
|
page.screenshot(path=str(screenshot_path))
|
||||||
|
report.sections.append(
|
||||||
|
("Screenshot", f"Sauvegardé : {screenshot_path}")
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
report.sections.append(
|
||||||
|
("Screenshot Error", f"Impossible de capturer : {e}")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Marqueurs personnalisés
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
def pytest_configure(config):
|
||||||
|
config.addinivalue_line("markers", "e2e: Tests End-to-End Playwright")
|
||||||
|
config.addinivalue_line("markers", "load: Tests de charge Locust")
|
||||||
|
config.addinivalue_line("markers", "security: Tests de sécurité")
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "smoke: Tests rapides de smoke (sans infra complète)"
|
||||||
|
)
|
||||||
|
config.addinivalue_line("markers", "beta: Tests spécifiques beta fermée")
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "requires_billing: Nécessite HRT-31 (Billing Stripe)"
|
||||||
|
)
|
||||||
|
config.addinivalue_line(
|
||||||
|
"markers", "requires_infra: Nécessite HRT-33 (infra staging)"
|
||||||
|
)
|
||||||
@@ -141,7 +141,7 @@ class TestJWTAuthentication:
|
|||||||
"invalid_signature_here"
|
"invalid_signature_here"
|
||||||
)
|
)
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{BASE_URL}/api/races",
|
f"{BASE_URL}/api/v1/predictions/today",
|
||||||
headers={"Authorization": f"Bearer {expired_token}"},
|
headers={"Authorization": f"Bearer {expired_token}"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
|
|||||||
"""Un token JWT malformé doit être rejeté."""
|
"""Un token JWT malformé doit être rejeté."""
|
||||||
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
||||||
resp = requests.get(
|
resp = requests.get(
|
||||||
f"{BASE_URL}/api/races",
|
f"{BASE_URL}/api/v1/predictions/today",
|
||||||
headers={"Authorization": f"Bearer {bad_token}"},
|
headers={"Authorization": f"Bearer {bad_token}"},
|
||||||
timeout=5,
|
timeout=5,
|
||||||
)
|
)
|
||||||
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
|
|||||||
|
|
||||||
def test_jwt_sans_token(self):
|
def test_jwt_sans_token(self):
|
||||||
"""Sans token, les routes protégées doivent retourner 401."""
|
"""Sans token, les routes protégées doivent retourner 401."""
|
||||||
resp = requests.get(f"{BASE_URL}/api/export/csv", timeout=5)
|
resp = requests.get(f"{BASE_URL}/api/v1/export/csv", timeout=5)
|
||||||
assert resp.status_code in (401, 403), (
|
assert resp.status_code in (401, 403), (
|
||||||
f"Route protégée accessible sans token: status={resp.status_code}"
|
f"Route protégée accessible sans token: status={resp.status_code}"
|
||||||
)
|
)
|
||||||
@@ -303,6 +303,138 @@ class TestPlanAuthorisation:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# === Tests validation mots de passe faibles (HRT-63) ===
|
||||||
|
|
||||||
|
|
||||||
|
class TestWeakPasswordRejection:
|
||||||
|
"""Tests rejet mots de passe faibles : blacklist + complexité (HRT-63)."""
|
||||||
|
|
||||||
|
REGISTER_URL = (
|
||||||
|
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/register"
|
||||||
|
)
|
||||||
|
|
||||||
|
WEAK_PASSWORDS = [
|
||||||
|
"password",
|
||||||
|
"12345678",
|
||||||
|
"qwerty123",
|
||||||
|
"letmein1",
|
||||||
|
"admin123",
|
||||||
|
"welcome1",
|
||||||
|
"iloveyou",
|
||||||
|
"abc1234",
|
||||||
|
"sunshine",
|
||||||
|
"111111111",
|
||||||
|
]
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("weak_pwd", WEAK_PASSWORDS)
|
||||||
|
def test_weak_password_rejected(self, weak_pwd):
|
||||||
|
"""Les mots de passe faibles/blacklistés doivent retourner 400."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_weak_{int(_time.time() * 1000)}_{weak_pwd[:4]}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": weak_pwd, "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe faible accepté: pwd={weak_pwd!r}, status={resp.status_code}"
|
||||||
|
)
|
||||||
|
body = resp.json()
|
||||||
|
assert "error" in body, f"Pas de champ 'error' dans la réponse: {body}"
|
||||||
|
|
||||||
|
def test_strong_password_accepted(self):
|
||||||
|
"""Un mot de passe fort doit permettre l'inscription (retourne 201)."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_strong_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "Tr0ub4d@ur!", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"Mot de passe fort rejeté: status={resp.status_code}, body={resp.text}"
|
||||||
|
)
|
||||||
|
data = resp.json()
|
||||||
|
assert "token" in data, f"Pas de token dans la réponse: {data}"
|
||||||
|
|
||||||
|
def test_no_digit_rejected(self):
|
||||||
|
"""Un mot de passe sans chiffre doit être rejeté."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_nodigit_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "NoDigitPassword", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe sans chiffre accepté: status={resp.status_code}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_no_letter_rejected(self):
|
||||||
|
"""Un mot de passe sans lettre doit être rejeté."""
|
||||||
|
import time as _time
|
||||||
|
|
||||||
|
unique_email = f"test_noletter_{int(_time.time() * 1000)}@h3r7.tech"
|
||||||
|
resp = requests.post(
|
||||||
|
self.REGISTER_URL,
|
||||||
|
json={"email": unique_email, "password": "12345678901", "plan": "free"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400, (
|
||||||
|
f"Mot de passe sans lettre accepté: status={resp.status_code}"
|
||||||
|
)
|
||||||
|
# === Tests rate limiting login ===
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoginRateLimit:
|
||||||
|
"""Tests rate limiting sur /api/v1/auth/login."""
|
||||||
|
|
||||||
|
TARGET_URL = (
|
||||||
|
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/login"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_login_brute_force_blocked_after_5_attempts(self):
|
||||||
|
"""Après 5 tentatives, le 6ème appel doit retourner 429."""
|
||||||
|
# Utiliser un email unique pour isoler le test
|
||||||
|
email = f"ratelimit_test_{int(time.time())}@h3r7.tech"
|
||||||
|
for i in range(5):
|
||||||
|
resp = requests.post(
|
||||||
|
self.TARGET_URL,
|
||||||
|
json={"email": email, "password": "wrong_password"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code in (400, 401), (
|
||||||
|
f"Tentative {i + 1}: status inattendu {resp.status_code}"
|
||||||
|
)
|
||||||
|
# La 6ème tentative doit être bloquée
|
||||||
|
resp = requests.post(
|
||||||
|
self.TARGET_URL,
|
||||||
|
json={"email": email, "password": "wrong_password"},
|
||||||
|
timeout=5,
|
||||||
|
)
|
||||||
|
assert resp.status_code == 429, (
|
||||||
|
f"Rate limit non appliqué après 5 tentatives: got {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert "Retry-After" in resp.headers, "Header Retry-After manquant sur 429"
|
||||||
|
|
||||||
|
def test_login_429_has_retry_after_header(self):
|
||||||
|
"""La réponse 429 doit inclure Retry-After."""
|
||||||
|
email = f"ratelimit_test2_{int(time.time())}@h3r7.tech"
|
||||||
|
for _ in range(6):
|
||||||
|
requests.post(
|
||||||
|
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||||
|
)
|
||||||
|
resp = requests.post(
|
||||||
|
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||||
|
)
|
||||||
|
if resp.status_code == 429:
|
||||||
|
assert "Retry-After" in resp.headers
|
||||||
|
assert int(resp.headers["Retry-After"]) > 0
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import subprocess
|
import subprocess
|
||||||
|
|
||||||
|
|||||||
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()
|
||||||
407
tests/test_history.py
Normal file
407
tests/test_history.py
Normal file
@@ -0,0 +1,407 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests for GET /api/v1/history — HRT-81
|
||||||
|
Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||||
|
|
||||||
|
Run with:
|
||||||
|
cd /home/h3r7/turf_saas
|
||||||
|
source venv/bin/activate
|
||||||
|
python -m pytest tests/test_history.py -v
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import sqlite3
|
||||||
|
import tempfile
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||||
|
|
||||||
|
# Use an isolated temp DB for these 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-history-secret-key"
|
||||||
|
|
||||||
|
from app_v1 import create_app
|
||||||
|
from auth_db import init_auth_tables
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Helpers
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
TODAY = datetime.now().date()
|
||||||
|
|
||||||
|
|
||||||
|
def days_ago(n: int) -> str:
|
||||||
|
return (TODAY - timedelta(days=n)).isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Fixtures
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
application = create_app()
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||||
|
return application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def seeded_db():
|
||||||
|
"""
|
||||||
|
Seed the test DB:
|
||||||
|
- Create ml_predictions_cache with rows spanning 120 days back
|
||||||
|
- Create users for free/premium/pro plans
|
||||||
|
"""
|
||||||
|
db_path = os.environ["TURF_SAAS_DB"]
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
|
||||||
|
# Create ml_predictions_cache table if absent
|
||||||
|
conn.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
date TEXT NOT NULL,
|
||||||
|
horse_name TEXT,
|
||||||
|
prob_top1 REAL,
|
||||||
|
prob_top3 REAL,
|
||||||
|
ml_score REAL,
|
||||||
|
race_label TEXT,
|
||||||
|
hippodrome TEXT,
|
||||||
|
heure TEXT,
|
||||||
|
is_value_bet INTEGER DEFAULT 0
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
|
||||||
|
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
|
||||||
|
for offset in offsets:
|
||||||
|
d = days_ago(offset)
|
||||||
|
conn.execute(
|
||||||
|
"""INSERT INTO ml_predictions_cache
|
||||||
|
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||||
|
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
return db_path
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def auth_tokens(client, seeded_db):
|
||||||
|
"""Register/login users for each plan and return their JWT tokens."""
|
||||||
|
plans = {
|
||||||
|
"free": "hist_free@test.com",
|
||||||
|
"premium": "hist_premium@test.com",
|
||||||
|
"pro": "hist_pro@test.com",
|
||||||
|
}
|
||||||
|
password = "password123"
|
||||||
|
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||||
|
|
||||||
|
# Set plan via direct DB
|
||||||
|
db_path = os.environ["TURF_SAAS_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()
|
||||||
|
|
||||||
|
tokens = {}
|
||||||
|
for plan, email in plans.items():
|
||||||
|
r = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": email, "password": password},
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||||
|
tokens[plan] = r.get_json()["access_token"]
|
||||||
|
|
||||||
|
return tokens
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Auth guard
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryAuth:
|
||||||
|
def test_requires_auth(self, client):
|
||||||
|
"""Unauthenticated request must return 401."""
|
||||||
|
r = client.get("/api/v1/history")
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
def test_invalid_token_returns_401(self, client):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers={"Authorization": "Bearer this.is.not.valid"},
|
||||||
|
)
|
||||||
|
assert r.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Free plan — 7-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryFreePlan:
|
||||||
|
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-6 (within 7-day window) must return 200."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "free"
|
||||||
|
assert data["history_limit_days"] == 7
|
||||||
|
|
||||||
|
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
|
||||||
|
start = days_ago(8)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert (
|
||||||
|
"upgrade" in data.get("message", "").lower()
|
||||||
|
or "plan" in data.get("message", "").lower()
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Free user: no dates specified — should use defaults and return 200."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
|
||||||
|
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
|
||||||
|
"""403 response must contain required_plans and upgrade_url."""
|
||||||
|
start = days_ago(30)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["free"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert "required_plans" in data
|
||||||
|
assert "upgrade_url" in data
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Premium plan — 90-day window
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryPremiumPlan:
|
||||||
|
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-89 must return 200."""
|
||||||
|
start = days_ago(89)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "premium"
|
||||||
|
assert data["history_limit_days"] == 90
|
||||||
|
|
||||||
|
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user: start = today-91 must return 403."""
|
||||||
|
start = days_ago(91)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 403
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 403
|
||||||
|
assert "required_plans" in data
|
||||||
|
# Premium upgrade hint should suggest pro
|
||||||
|
assert "pro" in data.get("required_plans", [])
|
||||||
|
|
||||||
|
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Premium user can always access the free window too."""
|
||||||
|
start = days_ago(6)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}",
|
||||||
|
headers=auth_header(auth_tokens["premium"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Pro plan — unlimited
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryProPlan:
|
||||||
|
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro user: start = today-100 must return 200 (unlimited)."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["status"] == "ok"
|
||||||
|
assert data["plan"] == "pro"
|
||||||
|
assert data["history_limit_days"] is None # unlimited
|
||||||
|
|
||||||
|
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
|
||||||
|
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
|
||||||
|
start = days_ago(100)
|
||||||
|
end = TODAY.isoformat()
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&end={end}&limit=500",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# All 9 seeded rows should be present
|
||||||
|
assert data["pagination"]["total"] == 9
|
||||||
|
|
||||||
|
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
# Input validation
|
||||||
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestHistoryValidation:
|
||||||
|
def test_invalid_start_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?start=31-12-2025",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert data["code"] == 400
|
||||||
|
assert "start" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_invalid_end_format(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history?end=2025/12/31",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
data = r.get_json()
|
||||||
|
assert "end" in data["message"].lower()
|
||||||
|
|
||||||
|
def test_start_after_end_returns_400(self, client, auth_tokens):
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 400
|
||||||
|
|
||||||
|
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert len(data["history"]) <= 3
|
||||||
|
assert data["pagination"]["limit"] == 3
|
||||||
|
|
||||||
|
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
|
||||||
|
"""has_more should be True when more rows exist beyond current page."""
|
||||||
|
start = days_ago(100)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
# 9 total rows seeded, limit=3 → has_more=True
|
||||||
|
assert data["pagination"]["has_more"] is True
|
||||||
|
|
||||||
|
def test_response_shape(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Verify the full response envelope shape."""
|
||||||
|
r = client.get(
|
||||||
|
"/api/v1/history",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
assert "status" in data
|
||||||
|
assert "plan" in data
|
||||||
|
assert "history_limit_days" in data
|
||||||
|
assert "start" in data
|
||||||
|
assert "end" in data
|
||||||
|
assert "history" in data
|
||||||
|
assert "pagination" in data
|
||||||
|
pagination = data["pagination"]
|
||||||
|
assert "total" in pagination
|
||||||
|
assert "limit" in pagination
|
||||||
|
assert "offset" in pagination
|
||||||
|
assert "has_more" in pagination
|
||||||
|
|
||||||
|
def test_history_row_fields(self, client, auth_tokens, seeded_db):
|
||||||
|
"""Each history row must contain the expected ML fields."""
|
||||||
|
start = days_ago(10)
|
||||||
|
r = client.get(
|
||||||
|
f"/api/v1/history?start={start}&limit=5",
|
||||||
|
headers=auth_header(auth_tokens["pro"]),
|
||||||
|
)
|
||||||
|
assert r.status_code == 200
|
||||||
|
data = r.get_json()
|
||||||
|
if data["history"]:
|
||||||
|
row = data["history"][0]
|
||||||
|
expected_fields = {
|
||||||
|
"id",
|
||||||
|
"date",
|
||||||
|
"horse_name",
|
||||||
|
"prob_top1",
|
||||||
|
"prob_top3",
|
||||||
|
"ml_score",
|
||||||
|
"race_label",
|
||||||
|
"hippodrome",
|
||||||
|
"heure",
|
||||||
|
"is_value_bet",
|
||||||
|
}
|
||||||
|
assert expected_fields.issubset(set(row.keys()))
|
||||||
300
tests/test_ml_cache_integrity.py
Normal file
300
tests/test_ml_cache_integrity.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
test_ml_cache_integrity.py — Test d'intégration : zéro NULL dans ml_predictions_cache
|
||||||
|
SaaS Turf Prédictions IA
|
||||||
|
Ticket: HRT-43 (suite au fix HRT-41 — métadonnées manquantes dans le cache ML)
|
||||||
|
|
||||||
|
Ces tests vérifient que la table ml_predictions_cache ne contient aucune ligne
|
||||||
|
avec des métadonnées NULL (hippodrome, race_label, heure) pour la date courante,
|
||||||
|
après le job ML de 19h30.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pytest tests/test_ml_cache_integrity.py -v -m integration
|
||||||
|
pytest tests/test_ml_cache_integrity.py -v -m integration --date 2026-04-26
|
||||||
|
|
||||||
|
Variables d'environnement:
|
||||||
|
TURF_DB_PATH : chemin vers turf.db (défaut: /home/h3r7/turf_scraper/turf.db)
|
||||||
|
TEST_DATE : date cible au format YYYY-MM-DD (défaut: date du jour)
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import os
|
||||||
|
import pytest
|
||||||
|
from datetime import date, datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Configuration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
DEFAULT_DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||||
|
DB_PATH = os.environ.get("TURF_DB_PATH", DEFAULT_DB_PATH)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_test_date() -> str:
|
||||||
|
"""Retourne la date cible pour les tests (env TEST_DATE ou date du jour)."""
|
||||||
|
env_date = os.environ.get("TEST_DATE", "")
|
||||||
|
if env_date:
|
||||||
|
try:
|
||||||
|
datetime.strptime(env_date, "%Y-%m-%d")
|
||||||
|
return env_date
|
||||||
|
except ValueError:
|
||||||
|
raise ValueError(
|
||||||
|
f"TEST_DATE invalide : '{env_date}'. Format attendu : YYYY-MM-DD"
|
||||||
|
)
|
||||||
|
return date.today().isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Fixture : connexion DB en lecture seule
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def db_connection():
|
||||||
|
"""
|
||||||
|
Connexion SQLite en mode lecture seule (uri=True + ?mode=ro).
|
||||||
|
Garantit qu'aucune modification accidentelle de la DB de prod n'est possible.
|
||||||
|
"""
|
||||||
|
db_path = Path(DB_PATH)
|
||||||
|
if not db_path.exists():
|
||||||
|
pytest.skip(
|
||||||
|
f"Base de données introuvable : {DB_PATH}. "
|
||||||
|
"Définir TURF_DB_PATH ou vérifier le chemin."
|
||||||
|
)
|
||||||
|
|
||||||
|
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||||
|
conn = sqlite3.connect(uri, uri=True)
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
yield conn
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def target_date():
|
||||||
|
"""Date cible pour les tests (date du jour ou TEST_DATE)."""
|
||||||
|
return _get_test_date()
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================
|
||||||
|
# Tests d'intégration
|
||||||
|
# ============================================================
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.integration
|
||||||
|
class TestMlCacheNullIntegrity:
|
||||||
|
"""
|
||||||
|
Vérifie qu'après le job ML de 19h30, la table ml_predictions_cache
|
||||||
|
ne contient aucune métadonnée NULL pour la date courante.
|
||||||
|
|
||||||
|
Régression testée : HRT-41 (Fix #17 — métadonnées manquantes dans le cache ML)
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_table_exists(self, db_connection):
|
||||||
|
"""Vérifie que la table ml_predictions_cache existe dans la DB."""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT name FROM sqlite_master "
|
||||||
|
"WHERE type='table' AND name='ml_predictions_cache'"
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
assert row is not None, (
|
||||||
|
"La table ml_predictions_cache est introuvable dans la base de données. "
|
||||||
|
"Vérifier que le job ML a bien créé la table."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_rows_exist_for_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que des prédictions existent pour la date cible.
|
||||||
|
|
||||||
|
Ce test passe en skip si aucune ligne n'existe (ex: avant le job 19h30).
|
||||||
|
Il échoue uniquement si le job a manifestement tourné mais a laissé 0 lignes.
|
||||||
|
"""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
if count == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Ce test doit être exécuté après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_hippodrome_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
CRITÈRE D'ACCEPTATION PRINCIPAL (HRT-43) :
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND hippodrome IS NULL = 0.
|
||||||
|
|
||||||
|
Régression directe du bug HRT-41 : le champ hippodrome était NULL
|
||||||
|
pour toutes les prédictions du cache ML.
|
||||||
|
"""
|
||||||
|
# Vérifier si des données existent avant de tester les NULLs
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND hippodrome IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"RÉGRESSION HRT-41 DÉTECTÉE : {null_count} ligne(s) avec hippodrome IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Le patch de métadonnées n'a pas été appliqué correctement."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_race_label_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND race_label IS NULL = 0.
|
||||||
|
|
||||||
|
Complément du test hippodrome : vérifie que le libellé de course
|
||||||
|
est bien renseigné pour toutes les prédictions.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND race_label IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"ANOMALIE : {null_count} ligne(s) avec race_label IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Vérifier le pipeline de patch de métadonnées."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zero_null_heure_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Vérifie que COUNT(*) WHERE date = today AND heure IS NULL = 0.
|
||||||
|
|
||||||
|
Vérifie que l'heure de course est bien renseignée pour toutes les prédictions.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? AND heure IS NULL",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
null_count = cursor.fetchone()["cnt"]
|
||||||
|
|
||||||
|
assert null_count == 0, (
|
||||||
|
f"ANOMALIE : {null_count} ligne(s) avec heure IS NULL "
|
||||||
|
f"dans ml_predictions_cache pour le {target_date}. "
|
||||||
|
"Vérifier le pipeline de patch de métadonnées."
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_full_metadata_coverage_today(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Test de couverture globale : aucune des trois colonnes critiques
|
||||||
|
(hippodrome, race_label, heure) n'est NULL pour une même ligne.
|
||||||
|
|
||||||
|
Retourne les 5 premières lignes problématiques pour faciliter le débogage.
|
||||||
|
"""
|
||||||
|
cursor_total = db_connection.execute(
|
||||||
|
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
total = cursor_total.fetchone()["cnt"]
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"SELECT id, num_reunion, num_course, horse_name, hippodrome, race_label, heure "
|
||||||
|
"FROM ml_predictions_cache "
|
||||||
|
"WHERE date = ? "
|
||||||
|
"AND (hippodrome IS NULL OR race_label IS NULL OR heure IS NULL) "
|
||||||
|
"LIMIT 5",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
bad_rows = cursor.fetchall()
|
||||||
|
|
||||||
|
assert len(bad_rows) == 0, (
|
||||||
|
f"ANOMALIE : {len(bad_rows)} ligne(s) avec au moins une métadonnée NULL "
|
||||||
|
f"(hippodrome, race_label ou heure) pour le {target_date}.\n"
|
||||||
|
"Exemples de lignes affectées :\n"
|
||||||
|
+ "\n".join(
|
||||||
|
f" - id={r['id']} R{r['num_reunion']}C{r['num_course']} "
|
||||||
|
f"{r['horse_name']} | hippodrome={r['hippodrome']!r} "
|
||||||
|
f"race_label={r['race_label']!r} heure={r['heure']!r}"
|
||||||
|
for r in bad_rows
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_metadata_completeness_summary(self, db_connection, target_date):
|
||||||
|
"""
|
||||||
|
Résumé diagnostique : affiche les statistiques de complétude des métadonnées
|
||||||
|
pour la date cible. Toujours en mode informatif (pas de assertion stricte),
|
||||||
|
utile pour le monitoring et les logs CI.
|
||||||
|
"""
|
||||||
|
cursor = db_connection.execute(
|
||||||
|
"""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) as total,
|
||||||
|
SUM(CASE WHEN hippodrome IS NULL THEN 1 ELSE 0 END) as null_hippodrome,
|
||||||
|
SUM(CASE WHEN race_label IS NULL THEN 1 ELSE 0 END) as null_race_label,
|
||||||
|
SUM(CASE WHEN heure IS NULL THEN 1 ELSE 0 END) as null_heure,
|
||||||
|
COUNT(DISTINCT hippodrome) as distinct_hippodromes,
|
||||||
|
COUNT(DISTINCT race_label) as distinct_race_labels
|
||||||
|
FROM ml_predictions_cache
|
||||||
|
WHERE date = ?
|
||||||
|
""",
|
||||||
|
(target_date,),
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
total = row["total"]
|
||||||
|
|
||||||
|
if total == 0:
|
||||||
|
pytest.skip(
|
||||||
|
f"Aucune prédiction en cache pour le {target_date}. "
|
||||||
|
"Lancer ce test après le job ML de 19h30."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Afficher les statistiques (visibles avec pytest -v -s)
|
||||||
|
print(f"\n=== Statistiques ml_predictions_cache pour le {target_date} ===")
|
||||||
|
print(f" Total lignes : {total}")
|
||||||
|
print(f" NULL hippodrome : {row['null_hippodrome']}")
|
||||||
|
print(f" NULL race_label : {row['null_race_label']}")
|
||||||
|
print(f" NULL heure : {row['null_heure']}")
|
||||||
|
print(f" Hippodromes distincts: {row['distinct_hippodromes']}")
|
||||||
|
print(f" Race labels distincts: {row['distinct_race_labels']}")
|
||||||
|
|
||||||
|
# L'assertion ici reste stricte pour hippodrome (bug HRT-41 critique)
|
||||||
|
assert row["null_hippodrome"] == 0, (
|
||||||
|
f"RÉGRESSION HRT-41 : {row['null_hippodrome']}/{total} lignes "
|
||||||
|
f"avec hippodrome IS NULL pour le {target_date}."
|
||||||
|
)
|
||||||
333
tests/test_ml_ensemble.py
Normal file
333
tests/test_ml_ensemble.py
Normal file
@@ -0,0 +1,333 @@
|
|||||||
|
"""
|
||||||
|
Tests ML Ensemble — HRT-32 Sprint 6-7
|
||||||
|
Tests de régression, benchmark et latence pour le nouveau modèle ensemble.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
pytest tests/test_ml_ensemble.py -v
|
||||||
|
pytest tests/test_ml_ensemble.py -v -m regression
|
||||||
|
pytest tests/test_ml_ensemble.py -v -m latency
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import pickle
|
||||||
|
import sqlite3
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
import pandas as pd
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("APP_URL", "http://localhost:8790")
|
||||||
|
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf.db")
|
||||||
|
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||||
|
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||||
|
BENCHMARK_PATH = MODELS_DIR / "benchmark_report.json"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def ensemble_model():
|
||||||
|
"""Load ensemble model (skip tests if not yet trained)."""
|
||||||
|
if not ENSEMBLE_PATH.exists():
|
||||||
|
pytest.skip(
|
||||||
|
f"Ensemble model not found at {ENSEMBLE_PATH}. Run train_ensemble.py first."
|
||||||
|
)
|
||||||
|
with open(ENSEMBLE_PATH, "rb") as f:
|
||||||
|
return pickle.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def benchmark_report():
|
||||||
|
"""Load benchmark report (skip if not generated)."""
|
||||||
|
if not BENCHMARK_PATH.exists():
|
||||||
|
pytest.skip(f"Benchmark report not found at {BENCHMARK_PATH}.")
|
||||||
|
with open(BENCHMARK_PATH) as f:
|
||||||
|
return json.load(f)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def holdout_data():
|
||||||
|
"""Load holdout slice (last 20% temporal) for regression tests."""
|
||||||
|
conn = sqlite3.connect(DB_PATH)
|
||||||
|
df = pd.read_sql_query(
|
||||||
|
"""
|
||||||
|
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||||
|
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||||
|
FROM pmu_partants p
|
||||||
|
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||||
|
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||||
|
WHERE p.ordre_arrivee > 0
|
||||||
|
ORDER BY p.date_programme, p.num_reunion, p.num_course, p.num_pmu
|
||||||
|
""",
|
||||||
|
conn,
|
||||||
|
)
|
||||||
|
conn.close()
|
||||||
|
n = len(df)
|
||||||
|
cutoff = int(n * 0.80)
|
||||||
|
return df.iloc[cutoff:].copy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def predict_v2():
|
||||||
|
"""Import predict_v2 module."""
|
||||||
|
import importlib.util
|
||||||
|
|
||||||
|
spec = importlib.util.spec_from_file_location(
|
||||||
|
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||||
|
)
|
||||||
|
mod = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(mod)
|
||||||
|
return mod
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Model Existence Tests ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestModelFiles:
|
||||||
|
"""Verify all expected model files exist."""
|
||||||
|
|
||||||
|
def test_ensemble_model_exists(self):
|
||||||
|
assert ENSEMBLE_PATH.exists(), f"Ensemble model missing: {ENSEMBLE_PATH}"
|
||||||
|
|
||||||
|
def test_benchmark_report_exists(self):
|
||||||
|
assert BENCHMARK_PATH.exists(), f"Benchmark report missing: {BENCHMARK_PATH}"
|
||||||
|
|
||||||
|
def test_models_dir_contains_expected_files(self):
|
||||||
|
expected = ["ensemble_top3.pkl", "benchmark_report.json", "benchmark_report.md"]
|
||||||
|
for fname in expected:
|
||||||
|
assert (MODELS_DIR / fname).exists(), f"Missing: {MODELS_DIR / fname}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Benchmark Tests ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestBenchmark:
|
||||||
|
"""Validate benchmark metrics from the training report."""
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_ensemble_beats_baseline_or_meets_threshold(self, benchmark_report):
|
||||||
|
"""Ensemble Precision@3 must be >= baseline XGBoost."""
|
||||||
|
baseline = benchmark_report["baseline"]["precision_at3"]
|
||||||
|
ensemble = benchmark_report["ensemble"]["precision_at3"]
|
||||||
|
assert ensemble >= baseline, (
|
||||||
|
f"Ensemble Precision@3 {ensemble:.4f} < baseline {baseline:.4f}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_ensemble_auc_above_random(self, benchmark_report):
|
||||||
|
"""Ensemble AUC must be > 0.60 (significantly above random 0.50)."""
|
||||||
|
auc = benchmark_report["ensemble"]["auc"]
|
||||||
|
assert auc > 0.60, f"Ensemble AUC {auc:.4f} <= 0.60"
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_optuna_ran_minimum_trials(self, benchmark_report):
|
||||||
|
"""Optuna must have run at least 100 trials per model."""
|
||||||
|
n_trials = benchmark_report["optuna"]["n_trials"]
|
||||||
|
assert n_trials >= 100, f"Only {n_trials} Optuna trials (minimum 100 required)"
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_no_precision_regression(self, benchmark_report):
|
||||||
|
"""Ensemble Precision@3 must not be below naive random baseline (~30%)."""
|
||||||
|
ensemble_p3 = benchmark_report["ensemble"]["precision_at3"]
|
||||||
|
assert ensemble_p3 >= 0.30, (
|
||||||
|
f"Precision@3 {ensemble_p3:.4f} is below random baseline (~0.30)"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_benchmark_has_all_required_models(self, benchmark_report):
|
||||||
|
"""Benchmark must include results for all 3 models."""
|
||||||
|
required = {"xgboost", "lightgbm", "mlp"}
|
||||||
|
found = set(benchmark_report.get("individual_models", {}).keys())
|
||||||
|
missing = required - found
|
||||||
|
assert not missing, f"Missing model benchmarks: {missing}"
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Regression Tests ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPrecisionRegression:
|
||||||
|
"""Holdout regression: ensure precision doesn't degrade."""
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_precision_at3_on_holdout(self, ensemble_model, holdout_data):
|
||||||
|
"""Precision@3 on holdout must be above naive baseline."""
|
||||||
|
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||||
|
|
||||||
|
df = holdout_data.copy()
|
||||||
|
df["top3"] = (df["ordre_arrivee"] <= 3).astype(int)
|
||||||
|
|
||||||
|
partants = df.to_dict("records")
|
||||||
|
feature_df = build_feature_df(partants)
|
||||||
|
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||||
|
X = feature_df[available].fillna(0)
|
||||||
|
|
||||||
|
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||||
|
|
||||||
|
# Per-race Precision@3
|
||||||
|
tmp = df[["date_programme", "num_reunion", "num_course"]].copy()
|
||||||
|
tmp["proba"] = proba
|
||||||
|
tmp["actual"] = df["top3"].values
|
||||||
|
|
||||||
|
precisions = []
|
||||||
|
for _, group in tmp.groupby(["date_programme", "num_reunion", "num_course"]):
|
||||||
|
if len(group) >= 3:
|
||||||
|
top3_pred = group.nlargest(3, "proba")
|
||||||
|
precisions.append(top3_pred["actual"].sum() / 3.0)
|
||||||
|
|
||||||
|
p_at3 = float(np.mean(precisions)) if precisions else 0.0
|
||||||
|
print(f"\n Holdout Precision@3: {p_at3:.4f} over {len(precisions)} races")
|
||||||
|
|
||||||
|
# Must beat random baseline (30%)
|
||||||
|
assert p_at3 >= 0.30, f"Holdout Precision@3 {p_at3:.4f} < 0.30"
|
||||||
|
|
||||||
|
@pytest.mark.regression
|
||||||
|
def test_no_all_zero_predictions(self, ensemble_model, holdout_data):
|
||||||
|
"""Ensemble must not predict 0 probability for all horses."""
|
||||||
|
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||||
|
|
||||||
|
partants = holdout_data.head(50).to_dict("records")
|
||||||
|
feature_df = build_feature_df(partants)
|
||||||
|
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||||
|
X = feature_df[available].fillna(0)
|
||||||
|
|
||||||
|
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||||
|
assert proba.max() > 0.01, "All predictions are near 0 — model appears broken"
|
||||||
|
assert proba.std() > 0.01, (
|
||||||
|
"All predictions have identical probability — no discrimination"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Latency Tests ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestPredictionLatency:
|
||||||
|
"""Prediction latency must be < 200ms per race."""
|
||||||
|
|
||||||
|
@pytest.mark.latency
|
||||||
|
def test_single_race_latency(self, ensemble_model, holdout_data):
|
||||||
|
"""Prediction for a single race (<=20 horses) must be < 200ms."""
|
||||||
|
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||||
|
|
||||||
|
# Take one race
|
||||||
|
first_race = (
|
||||||
|
holdout_data.groupby(["date_programme", "num_reunion", "num_course"])
|
||||||
|
.first()
|
||||||
|
.reset_index()
|
||||||
|
.iloc[0]
|
||||||
|
)
|
||||||
|
mask = (
|
||||||
|
(holdout_data["date_programme"] == first_race["date_programme"])
|
||||||
|
& (holdout_data["num_reunion"] == first_race["num_reunion"])
|
||||||
|
& (holdout_data["num_course"] == first_race["num_course"])
|
||||||
|
)
|
||||||
|
race_df = holdout_data[mask]
|
||||||
|
partants = race_df.to_dict("records")
|
||||||
|
|
||||||
|
# Warm-up
|
||||||
|
feature_df = build_feature_df(partants)
|
||||||
|
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||||
|
X = feature_df[available].fillna(0)
|
||||||
|
ensemble_model.predict_proba(X)
|
||||||
|
|
||||||
|
# Timed run
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
for _ in range(10):
|
||||||
|
ensemble_model.predict_proba(X)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) / 10 * 1000
|
||||||
|
|
||||||
|
print(f"\n Single-race latency: {elapsed_ms:.2f} ms ({len(partants)} horses)")
|
||||||
|
assert elapsed_ms < 200, (
|
||||||
|
f"Prediction latency {elapsed_ms:.1f} ms exceeds 200 ms limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.latency
|
||||||
|
def test_full_day_latency(self, ensemble_model, holdout_data):
|
||||||
|
"""Prediction for a full day (all races) must complete < 5 seconds."""
|
||||||
|
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||||
|
|
||||||
|
# Take one day
|
||||||
|
day = holdout_data["date_programme"].iloc[0]
|
||||||
|
day_df = holdout_data[holdout_data["date_programme"] == day]
|
||||||
|
partants = day_df.to_dict("records")
|
||||||
|
|
||||||
|
feature_df = build_feature_df(partants)
|
||||||
|
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||||
|
X = feature_df[available].fillna(0)
|
||||||
|
|
||||||
|
t0 = time.perf_counter()
|
||||||
|
proba = ensemble_model.predict_proba(X)
|
||||||
|
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"\n Full day latency: {elapsed_ms:.2f} ms ({len(partants)} horses, {day})"
|
||||||
|
)
|
||||||
|
assert elapsed_ms < 5000, (
|
||||||
|
f"Full-day prediction {elapsed_ms:.0f} ms exceeds 5s limit"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ─── API Endpoint Tests ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestV1PredictionsAPI:
|
||||||
|
"""Tests for the new /api/v1/predictions endpoint."""
|
||||||
|
|
||||||
|
def _api_available(self):
|
||||||
|
try:
|
||||||
|
requests.get(f"{BASE_URL}/api/v1/model/status", timeout=3)
|
||||||
|
return True
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
def test_model_status_endpoint(self):
|
||||||
|
"""GET /api/v1/model/status returns valid JSON."""
|
||||||
|
if not self._api_available():
|
||||||
|
pytest.skip("API server not running")
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/v1/model/status", timeout=10)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "ensemble_available" in data
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
def test_v1_predictions_no_500(self):
|
||||||
|
"""GET /api/v1/predictions must not return 5xx."""
|
||||||
|
if not self._api_available():
|
||||||
|
pytest.skip("API server not running")
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||||
|
assert resp.status_code < 500, (
|
||||||
|
f"Server error: {resp.status_code}\n{resp.text[:200]}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
def test_v1_predictions_returns_json(self):
|
||||||
|
"""GET /api/v1/predictions returns valid JSON with expected keys."""
|
||||||
|
if not self._api_available():
|
||||||
|
pytest.skip("API server not running")
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||||
|
if resp.status_code == 503:
|
||||||
|
pytest.skip("Ensemble model not yet deployed")
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert "model_version" in data, "Missing model_version in response"
|
||||||
|
assert "races" in data or "predictions" in data, (
|
||||||
|
"Missing races/predictions in response"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.api
|
||||||
|
def test_v1_predictions_latency(self):
|
||||||
|
"""GET /api/v1/predictions must respond in < 3 seconds."""
|
||||||
|
if not self._api_available():
|
||||||
|
pytest.skip("API server not running")
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||||
|
if resp.status_code == 503:
|
||||||
|
pytest.skip("Ensemble model not yet deployed")
|
||||||
|
# Check API-reported latency
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
latency = data.get("latency_ms", 0)
|
||||||
|
assert latency < 3000, f"API latency {latency:.0f} ms > 3000 ms"
|
||||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Tests — Multi-compte / Organisations Pro
|
||||||
|
Sprint: HRT-82
|
||||||
|
|
||||||
|
Couvre :
|
||||||
|
- Migration DB (tables organizations + org_members)
|
||||||
|
- POST /api/v1/org
|
||||||
|
- GET /api/v1/org
|
||||||
|
- DELETE /api/v1/org
|
||||||
|
- POST /api/v1/org/invite
|
||||||
|
- GET /api/v1/org/members
|
||||||
|
- DELETE /api/v1/org/members/<user_id>
|
||||||
|
- Plan enforcement (plan != pro → 403)
|
||||||
|
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||||
|
|
||||||
|
Run:
|
||||||
|
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||||
|
"""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||||
|
_tmp_db.close()
|
||||||
|
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# ─── App import (après configuration env) ────────────────────────────────────
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
from org_db import get_db, migrate_org_tables
|
||||||
|
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(email: str, plan: str = "free") -> dict:
|
||||||
|
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||||
|
init_users_table()
|
||||||
|
uid = secrets.token_hex(16)
|
||||||
|
pw_hash = "hashed"
|
||||||
|
conn = auth_get_db()
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||||
|
"VALUES (?,?,?,?,?,?)",
|
||||||
|
(uid, email, "Test", "User", pw_hash, plan),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
token = generate_token(uid)
|
||||||
|
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(token: str) -> dict:
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||||
|
from flask import Flask
|
||||||
|
from flask_cors import CORS
|
||||||
|
from saas_auth import auth_bp
|
||||||
|
from api_v1.routes.org import org_bp
|
||||||
|
|
||||||
|
application = Flask(__name__)
|
||||||
|
CORS(application)
|
||||||
|
application.config["TESTING"] = True
|
||||||
|
|
||||||
|
# S'assurer que la migration a tourné
|
||||||
|
migrate_org_tables()
|
||||||
|
|
||||||
|
application.register_blueprint(auth_bp)
|
||||||
|
application.register_blueprint(org_bp)
|
||||||
|
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_owner(app):
|
||||||
|
"""Un utilisateur Pro qui va créer une org."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("owner_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user2(app):
|
||||||
|
"""Un 2e utilisateur Pro à inviter."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member2_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user3(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member3_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user4(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member4_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user5(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member5_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def pro_user6(app):
|
||||||
|
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("member6_pro@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def free_user(app):
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("free_user@test.com", plan="free")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def other_pro_owner(app):
|
||||||
|
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||||
|
with app.app_context():
|
||||||
|
return _create_user("other_owner@test.com", plan="pro")
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests DB migration
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestOrgDbMigration:
|
||||||
|
def test_tables_exist(self):
|
||||||
|
"""Les tables organizations et org_members doivent exister."""
|
||||||
|
conn = get_db()
|
||||||
|
tables = {
|
||||||
|
row[0]
|
||||||
|
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
}
|
||||||
|
conn.close()
|
||||||
|
assert "organizations" in tables, "Table organizations manquante"
|
||||||
|
assert "org_members" in tables, "Table org_members manquante"
|
||||||
|
|
||||||
|
def test_migration_idempotent(self):
|
||||||
|
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||||
|
migrate_org_tables() # 2e appel — doit être silencieux
|
||||||
|
self.test_tables_exist()
|
||||||
|
|
||||||
|
def test_org_members_unique_constraint(self):
|
||||||
|
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||||
|
conn = get_db()
|
||||||
|
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||||
|
conn.close()
|
||||||
|
# Il doit y avoir un index d'unicité
|
||||||
|
assert (
|
||||||
|
any(
|
||||||
|
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||||
|
for idx in indexes
|
||||||
|
)
|
||||||
|
or True
|
||||||
|
)
|
||||||
|
# On vérifie via insertion en double
|
||||||
|
conn = get_db()
|
||||||
|
oid = "test_org_unique"
|
||||||
|
uid = "test_uid_unique"
|
||||||
|
try:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||||
|
(oid, uid, "TestOrg"),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
# 2e insertion doit lever IntegrityError
|
||||||
|
with pytest.raises(sqlite3.IntegrityError):
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||||
|
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||||
|
(oid, uid),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
finally:
|
||||||
|
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||||
|
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests plan enforcement
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestPlanEnforcement:
|
||||||
|
def test_create_org_free_plan_403(self, client, free_user):
|
||||||
|
"""Un utilisateur free ne peut pas créer une org."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "FreePlanOrg"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["required"] == "pro"
|
||||||
|
|
||||||
|
def test_get_org_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "someone@test.com"},
|
||||||
|
headers=_auth_header(free_user["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_free_plan_403(self, client, free_user):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_no_token_401(self, client):
|
||||||
|
resp = client.get("/api/v1/org")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests création d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestCreateOrg:
|
||||||
|
def test_create_org_success(self, client, pro_owner):
|
||||||
|
"""Un Pro peut créer une organisation."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "H3R7 Racing Club"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org" in data
|
||||||
|
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["max_members"] == 5
|
||||||
|
|
||||||
|
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||||
|
"""Un Pro ne peut pas créer 2 organisations."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "Second Org"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "org_id" in data
|
||||||
|
|
||||||
|
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||||
|
"""Le nom est obligatoire."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": " "},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org",
|
||||||
|
json={"name": "x" * 101},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests lecture d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestGetOrg:
|
||||||
|
def test_get_org_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||||
|
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||||
|
|
||||||
|
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||||
|
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||||
|
# other_pro_owner n'a pas encore d'org dans ce test
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||||
|
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||||
|
assert resp.status_code in (200, 404)
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests invitation de membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestInviteMember:
|
||||||
|
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["member"]["user_id"] == pro_user2["id"]
|
||||||
|
assert data["member"]["role"] == "member"
|
||||||
|
|
||||||
|
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||||
|
"""Inviter 2x le même utilisateur → 409."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user2["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "nobody@nowhere.com"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "not-an-email"},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_invite_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas inviter."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": "anyone@test.com"},
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_invite_fill_to_max(
|
||||||
|
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||||
|
):
|
||||||
|
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||||
|
for u in (pro_user3, pro_user4, pro_user5):
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": u["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, (
|
||||||
|
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||||
|
"""Le 6e membre doit être refusé (max 5)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user6["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests liste des membres
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestListMembers:
|
||||||
|
def test_list_members_as_owner(self, client, pro_owner):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert "members" in data
|
||||||
|
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||||
|
assert data["max_members"] == 5
|
||||||
|
|
||||||
|
def test_list_members_as_member(self, client, pro_user2):
|
||||||
|
"""Un membre peut aussi consulter la liste."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["count"] >= 1
|
||||||
|
|
||||||
|
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||||
|
)
|
||||||
|
data = resp.get_json()
|
||||||
|
emails = [m["email"] for m in data["members"]]
|
||||||
|
assert pro_user2["email"] in emails
|
||||||
|
|
||||||
|
def test_list_members_no_org_404(self, client, pro_user6):
|
||||||
|
"""Un Pro sans org reçoit 404."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression de membre
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoveMember:
|
||||||
|
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user5['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["removed_user_id"] == pro_user5["id"]
|
||||||
|
|
||||||
|
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||||
|
"""L'owner ne peut pas se retirer lui-même."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_owner['id']}",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||||
|
resp = client.delete(
|
||||||
|
"/api/v1/org/members/nonexistent-id-xyz",
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||||
|
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||||
|
resp = client.delete(
|
||||||
|
f"/api/v1/org/members/{pro_user3['id']}",
|
||||||
|
headers=_auth_header(pro_user2["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||||
|
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/org/invite",
|
||||||
|
json={"email": pro_user5["email"]},
|
||||||
|
headers=_auth_header(pro_owner["token"]),
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
|
||||||
|
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
# Tests suppression d'organisation
|
||||||
|
# ═══════════════════════════════════════════════════════════════════════════════
|
||||||
|
|
||||||
|
|
||||||
|
class TestDeleteOrg:
|
||||||
|
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||||
|
"""Un simple membre ne peut pas supprimer l'org."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_delete_org_success(self, client, pro_owner):
|
||||||
|
"""L'owner peut supprimer l'organisation."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["ok"] is True
|
||||||
|
|
||||||
|
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||||
|
"""Après suppression, GET /org renvoie 404."""
|
||||||
|
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||||
|
"""Supprimer une org qui n'existe plus → 403."""
|
||||||
|
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_members_cascade_deleted(self, client, pro_user2):
|
||||||
|
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||||
|
resp = client.get(
|
||||||
|
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||||
|
)
|
||||||
|
assert resp.status_code == 404
|
||||||
205
tests/test_smoke.py
Normal file
205
tests/test_smoke.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""
|
||||||
|
Tests de smoke — SaaS Turf Prédictions IA
|
||||||
|
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||||
|
Ticket: HRT-34
|
||||||
|
|
||||||
|
Vérifications rapides sur l'état de l'application :
|
||||||
|
- Routes de base accessibles
|
||||||
|
- API répond en JSON valide
|
||||||
|
- Base de données accessible
|
||||||
|
- Pas d'erreurs 5xx sur les routes principales
|
||||||
|
|
||||||
|
Ces tests peuvent tourner SANS infra complète (pas besoin de HRT-31/33).
|
||||||
|
Exécuter sur l'app actuelle en staging ou localhost.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
import os
|
||||||
|
import json
|
||||||
|
|
||||||
|
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||||
|
|
||||||
|
# Routes qui doivent retourner 200 (publiques)
|
||||||
|
PUBLIC_ROUTES_200 = [
|
||||||
|
"/",
|
||||||
|
"/dashboard",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Routes API qui doivent retourner 200 ou 401 (jamais 500)
|
||||||
|
API_ROUTES_NO_500 = [
|
||||||
|
"/api",
|
||||||
|
"/api/races",
|
||||||
|
"/api/scoring",
|
||||||
|
"/api/weather",
|
||||||
|
"/api/odds_history",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmoke:
|
||||||
|
"""Tests de smoke : l'app répond correctement aux requêtes de base."""
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
@pytest.mark.parametrize("route", PUBLIC_ROUTES_200)
|
||||||
|
def test_route_publique_accessible(self, route):
|
||||||
|
"""Les routes publiques doivent retourner 200."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||||
|
assert resp.status_code in (200, 304), (
|
||||||
|
f"Route publique inaccessible: {route} → {resp.status_code}"
|
||||||
|
)
|
||||||
|
assert len(resp.content) > 0, f"Réponse vide sur {route}"
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(
|
||||||
|
f"App non accessible sur {BASE_URL} — vérifier que le serveur est démarré"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
@pytest.mark.parametrize("route", API_ROUTES_NO_500)
|
||||||
|
def test_api_pas_derreur_serveur(self, route):
|
||||||
|
"""Les routes API ne doivent jamais retourner 5xx."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||||
|
assert resp.status_code < 500, (
|
||||||
|
f"Erreur serveur sur {route}: {resp.status_code}\n{resp.text[:200]}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_api_today_retourne_json(self):
|
||||||
|
"""L'endpoint principal /api doit retourner du JSON valide."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}/api", timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
assert data is not None, "Réponse JSON nulle"
|
||||||
|
assert isinstance(data, (list, dict)), (
|
||||||
|
f"Type de réponse inattendu: {type(data)}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||||
|
except json.JSONDecodeError as e:
|
||||||
|
pytest.fail(f"/api ne retourne pas du JSON valide: {e}")
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_contenu_html_portail_valide(self):
|
||||||
|
"""Le portail doit contenir un titre et du contenu significatif."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
content = resp.text
|
||||||
|
assert "<html" in content.lower() or "<!doctype" in content.lower(), (
|
||||||
|
"La page d'accueil ne retourne pas du HTML"
|
||||||
|
)
|
||||||
|
assert len(content) > 500, (
|
||||||
|
f"Page d'accueil trop courte ({len(content)} chars)"
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_headers_securite_presents(self):
|
||||||
|
"""Les headers de sécurité de base doivent être présents."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
return
|
||||||
|
|
||||||
|
# En production (derrière Nginx), ces headers doivent être présents
|
||||||
|
# En dev direct Flask, ils peuvent être absents — on note seulement
|
||||||
|
security_headers = {
|
||||||
|
"X-Content-Type-Options": "nosniff",
|
||||||
|
"X-Frame-Options": None, # SAMEORIGIN ou DENY
|
||||||
|
"X-XSS-Protection": None,
|
||||||
|
}
|
||||||
|
|
||||||
|
missing = []
|
||||||
|
for header, expected_value in security_headers.items():
|
||||||
|
if header not in resp.headers:
|
||||||
|
missing.append(header)
|
||||||
|
|
||||||
|
if missing:
|
||||||
|
# Warning seulement — bloquant uniquement en prod derrière Nginx
|
||||||
|
pytest.warns(UserWarning, match=r".*") if False else None
|
||||||
|
print(f"⚠️ Headers sécurité manquants (requis en prod): {missing}")
|
||||||
|
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_api_races_format_reponse(self):
|
||||||
|
"""L'endpoint /api/races doit retourner une liste structurée."""
|
||||||
|
try:
|
||||||
|
resp = requests.get(f"{BASE_URL}/api/races", timeout=10)
|
||||||
|
if resp.status_code == 200:
|
||||||
|
data = resp.json()
|
||||||
|
assert isinstance(data, (list, dict)), (
|
||||||
|
f"Format inattendu pour /api/races: {type(data)}"
|
||||||
|
)
|
||||||
|
if isinstance(data, list) and len(data) > 0:
|
||||||
|
first = data[0]
|
||||||
|
# Vérifier la présence de champs clés
|
||||||
|
expected_fields = ["date", "course", "hippodrome"]
|
||||||
|
present = [
|
||||||
|
f
|
||||||
|
for f in expected_fields
|
||||||
|
if f in first
|
||||||
|
or any(k in first for k in [f, f.upper(), f.replace("_", "")])
|
||||||
|
]
|
||||||
|
assert len(present) > 0, (
|
||||||
|
f"Champs attendus absents de /api/races. Champs présents: {list(first.keys())}"
|
||||||
|
)
|
||||||
|
except requests.exceptions.ConnectionError:
|
||||||
|
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
pytest.fail("/api/races ne retourne pas du JSON valide")
|
||||||
|
|
||||||
|
|
||||||
|
class TestSmokeDatabase:
|
||||||
|
"""Tests smoke sur la base de données."""
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_base_donnees_accessible(self):
|
||||||
|
"""La base de données SQLite doit être accessible et contenir des données."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
|
|
||||||
|
if not __import__("os").path.exists(db_path):
|
||||||
|
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
|
||||||
|
# Vérifier que les tables essentielles existent
|
||||||
|
c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||||
|
tables = {row[0] for row in c.fetchall()}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
expected_tables = ["predictions", "results"]
|
||||||
|
for table in expected_tables:
|
||||||
|
assert table in tables, (
|
||||||
|
f"Table manquante dans la BDD: {table}. Tables présentes: {tables}"
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.smoke
|
||||||
|
def test_donnees_predictions_disponibles(self):
|
||||||
|
"""Des prédictions doivent être présentes dans la BDD."""
|
||||||
|
import sqlite3
|
||||||
|
|
||||||
|
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||||
|
|
||||||
|
if not __import__("os").path.exists(db_path):
|
||||||
|
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||||
|
|
||||||
|
conn = sqlite3.connect(db_path)
|
||||||
|
c = conn.cursor()
|
||||||
|
c.execute("SELECT COUNT(*) FROM predictions")
|
||||||
|
count = c.fetchone()[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Au moins quelques données pour que le SaaS soit utile
|
||||||
|
assert count >= 0, "Table predictions accessible"
|
||||||
|
if count == 0:
|
||||||
|
print("⚠️ Aucune prédiction en base — le scraper doit être lancé")
|
||||||
383
tests/test_user_tokens.py
Normal file
383
tests/test_user_tokens.py
Normal file
@@ -0,0 +1,383 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
tests/test_user_tokens.py — Personal API Token + Webhook alertes
|
||||||
|
HRT-80: Tests unitaires et d'intégration
|
||||||
|
|
||||||
|
Couvre:
|
||||||
|
- POST /api/v1/user/api-token (create)
|
||||||
|
- DELETE /api/v1/user/api-token (revoke)
|
||||||
|
- POST /api/v1/user/webhook (create/upsert)
|
||||||
|
- DELETE /api/v1/user/webhook (delete)
|
||||||
|
- Authentification via X-API-Key
|
||||||
|
- dispatch_webhook() fire-and-forget
|
||||||
|
- Plan enforcement Pro uniquement
|
||||||
|
|
||||||
|
Run:
|
||||||
|
./venv/bin/pytest tests/test_user_tokens.py -v --tb=short
|
||||||
|
"""
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# ─── Test DB isolation ────────────────────────────────────────────────────────
|
||||||
|
_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-hrt80"
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
from app_v1 import create_app # noqa: E402
|
||||||
|
|
||||||
|
TEST_CONFIG = {
|
||||||
|
"TESTING": True,
|
||||||
|
"JWT_SECRET_KEY": "test-secret-hrt80",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def app():
|
||||||
|
application = create_app()
|
||||||
|
application.config.update(TEST_CONFIG)
|
||||||
|
yield application
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def client(app):
|
||||||
|
return app.test_client()
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
def _create_user(client, email, plan="pro"):
|
||||||
|
"""Register user (plan=free) then update plan in DB."""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/auth/register",
|
||||||
|
json={"email": email, "password": "Secure123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
user_id = resp.get_json()["user_id"]
|
||||||
|
|
||||||
|
# Update plan directly in DB (no plan-update endpoint in JWT auth)
|
||||||
|
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||||
|
conn.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# Login to get access token
|
||||||
|
login_resp = client.post(
|
||||||
|
"/api/v1/auth/login",
|
||||||
|
json={"email": email, "password": "Secure123"},
|
||||||
|
)
|
||||||
|
assert login_resp.status_code == 200, login_resp.get_json()
|
||||||
|
access_token = login_resp.get_json()["access_token"]
|
||||||
|
return access_token, user_id
|
||||||
|
|
||||||
|
|
||||||
|
def _auth_header(token):
|
||||||
|
return {"Authorization": f"Bearer {token}"}
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tests: API Token (Pro) ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiToken:
|
||||||
|
def test_create_api_token_pro(self, client):
|
||||||
|
"""POST /api/v1/user/api-token — Pro user gets 201 + token starting with trf_"""
|
||||||
|
token, _ = _create_user(client, "pro_token@test.com", plan="pro")
|
||||||
|
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 201, resp.get_json()
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["token"].startswith("trf_")
|
||||||
|
assert data["prefix"] == data["token"][:12]
|
||||||
|
assert "warning" in data
|
||||||
|
assert "created_at" in data
|
||||||
|
|
||||||
|
def test_create_api_token_stores_hash_not_raw(self, client):
|
||||||
|
"""Second POST returns 409 — only hashed token stored"""
|
||||||
|
token, _ = _create_user(client, "pro_token2@test.com", plan="pro")
|
||||||
|
# First create
|
||||||
|
r1 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert r1.status_code == 201
|
||||||
|
raw_token = r1.get_json()["token"]
|
||||||
|
# Second create should conflict
|
||||||
|
r2 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert r2.status_code == 409
|
||||||
|
data = r2.get_json()
|
||||||
|
assert "existing_prefix" in data
|
||||||
|
# Verify raw token is NOT stored in DB (only hash)
|
||||||
|
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT token_hash FROM user_api_tokens WHERE token_prefix = ?",
|
||||||
|
(raw_token[:12],),
|
||||||
|
).fetchone()
|
||||||
|
conn.close()
|
||||||
|
assert row is not None
|
||||||
|
assert row[0] != raw_token # hash != raw
|
||||||
|
assert len(row[0]) == 64 # SHA256 hex
|
||||||
|
|
||||||
|
def test_create_api_token_free_user(self, client):
|
||||||
|
"""Free user gets 403"""
|
||||||
|
token, _ = _create_user(client, "free_token@test.com", plan="free")
|
||||||
|
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_create_api_token_premium_user(self, client):
|
||||||
|
"""Premium user gets 403 (Pro only feature)"""
|
||||||
|
token, _ = _create_user(client, "premium_token@test.com", plan="premium")
|
||||||
|
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_create_api_token_no_auth(self, client):
|
||||||
|
"""No auth → 401"""
|
||||||
|
resp = client.post("/api/v1/user/api-token")
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_revoke_api_token(self, client):
|
||||||
|
"""DELETE /api/v1/user/api-token — Pro user revokes active token"""
|
||||||
|
token, _ = _create_user(client, "pro_revoke@test.com", plan="pro")
|
||||||
|
# Create first
|
||||||
|
client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
# Revoke
|
||||||
|
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["revoked"] is True
|
||||||
|
assert data["count"] >= 1
|
||||||
|
|
||||||
|
def test_revoke_no_active_token(self, client):
|
||||||
|
"""DELETE with no active token → 404"""
|
||||||
|
token, _ = _create_user(client, "pro_notoken@test.com", plan="pro")
|
||||||
|
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_revoke_non_pro(self, client):
|
||||||
|
"""DELETE for free user → 403"""
|
||||||
|
token, _ = _create_user(client, "free_revoke@test.com", plan="free")
|
||||||
|
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tests: X-API-Key Authentication ─────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiKeyAuth:
|
||||||
|
def test_api_key_auth_on_protected_route(self, client):
|
||||||
|
"""Valid X-API-Key authenticates on protected route"""
|
||||||
|
token, _ = _create_user(client, "apikey_auth@test.com", plan="pro")
|
||||||
|
# Create API token
|
||||||
|
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert r.status_code == 201
|
||||||
|
raw_key = r.get_json()["token"]
|
||||||
|
# Use X-API-Key to access a protected route (try create again → 409 means authenticated)
|
||||||
|
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||||
|
# 409 means we were authenticated; 401 means auth failed
|
||||||
|
assert resp.status_code == 409
|
||||||
|
|
||||||
|
def test_api_key_invalid(self, client):
|
||||||
|
"""Invalid X-API-Key → 401"""
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/api-token",
|
||||||
|
headers={"X-API-Key": "trf_invalidkeyXXXXXXXXXXXXXXXXXX"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_api_key_revoked(self, client):
|
||||||
|
"""Revoked X-API-Key → 401"""
|
||||||
|
token, _ = _create_user(client, "revoked_apikey@test.com", plan="pro")
|
||||||
|
# Create token
|
||||||
|
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
assert r.status_code == 201
|
||||||
|
raw_key = r.get_json()["token"]
|
||||||
|
# Revoke it
|
||||||
|
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
# Try using revoked key
|
||||||
|
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||||
|
assert resp.status_code == 401
|
||||||
|
|
||||||
|
def test_revoke_then_cannot_auth(self, client):
|
||||||
|
"""Full flow: create → use → revoke → X-API-Key rejected"""
|
||||||
|
token, _ = _create_user(client, "flow_test@test.com", plan="pro")
|
||||||
|
# Create
|
||||||
|
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
raw_key = r.get_json()["token"]
|
||||||
|
# Validate it works (409 because key exists)
|
||||||
|
r2 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||||
|
assert r2.status_code == 409
|
||||||
|
# Revoke
|
||||||
|
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||||
|
# Try again with revoked key
|
||||||
|
r3 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||||
|
assert r3.status_code == 401
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tests: Webhook ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestWebhook:
|
||||||
|
def test_create_webhook_pro(self, client):
|
||||||
|
"""POST /api/v1/user/webhook — Pro user with provided secret → 201"""
|
||||||
|
token, _ = _create_user(client, "webhook_pro@test.com", plan="pro")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://example.com/hook", "secret": "mysecret123"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert data["webhook_url"] == "https://example.com/hook"
|
||||||
|
assert data["secret"] == "mysecret123"
|
||||||
|
|
||||||
|
def test_create_webhook_auto_secret(self, client):
|
||||||
|
"""POST without secret → auto-generated secret"""
|
||||||
|
token, _ = _create_user(client, "webhook_auto@test.com", plan="pro")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://auto.example.com/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
data = resp.get_json()
|
||||||
|
assert len(data["secret"]) == 64 # token_hex(32) = 64 hex chars
|
||||||
|
|
||||||
|
def test_create_webhook_non_pro_free(self, client):
|
||||||
|
"""Free user → 403"""
|
||||||
|
token, _ = _create_user(client, "webhook_free@test.com", plan="free")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://example.com/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_create_webhook_non_pro_premium(self, client):
|
||||||
|
"""Premium user → 403"""
|
||||||
|
token, _ = _create_user(client, "webhook_premium@test.com", plan="premium")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://example.com/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
def test_create_webhook_url_not_https(self, client):
|
||||||
|
"""HTTP URL → 400"""
|
||||||
|
token, _ = _create_user(client, "webhook_http@test.com", plan="pro")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "http://example.com/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
assert "https" in resp.get_json()["error"].lower()
|
||||||
|
|
||||||
|
def test_create_webhook_missing_url(self, client):
|
||||||
|
"""Missing URL → 400"""
|
||||||
|
token, _ = _create_user(client, "webhook_nourl@test.com", plan="pro")
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_webhook_upsert(self, client):
|
||||||
|
"""Second POST updates URL (upsert behavior)"""
|
||||||
|
token, _ = _create_user(client, "webhook_upsert@test.com", plan="pro")
|
||||||
|
client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://first.example.com/hook"},
|
||||||
|
)
|
||||||
|
resp = client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://second.example.com/hook"},
|
||||||
|
)
|
||||||
|
assert resp.status_code == 201
|
||||||
|
assert resp.get_json()["webhook_url"] == "https://second.example.com/hook"
|
||||||
|
|
||||||
|
def test_delete_webhook(self, client):
|
||||||
|
"""DELETE /api/v1/user/webhook → 200"""
|
||||||
|
token, _ = _create_user(client, "webhook_delete@test.com", plan="pro")
|
||||||
|
client.post(
|
||||||
|
"/api/v1/user/webhook",
|
||||||
|
headers=_auth_header(token),
|
||||||
|
json={"url": "https://delete.example.com/hook"},
|
||||||
|
)
|
||||||
|
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 200
|
||||||
|
assert resp.get_json()["deleted"] is True
|
||||||
|
|
||||||
|
def test_delete_webhook_not_configured(self, client):
|
||||||
|
"""DELETE without webhook configured → 404"""
|
||||||
|
token, _ = _create_user(client, "webhook_notset@test.com", plan="pro")
|
||||||
|
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
def test_delete_webhook_non_pro(self, client):
|
||||||
|
"""Free user DELETE → 403"""
|
||||||
|
token, _ = _create_user(client, "webhook_freedelete@test.com", plan="free")
|
||||||
|
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||||
|
assert resp.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ─── Tests: dispatch_webhook ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
||||||
|
class TestDispatchWebhook:
|
||||||
|
def test_dispatch_no_webhook_configured(self):
|
||||||
|
"""dispatch_webhook silently returns when no webhook is configured"""
|
||||||
|
with patch("api_v1.utils_webhook.get_db") as mock_get_db:
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchone.return_value = None
|
||||||
|
mock_get_db.return_value = mock_conn
|
||||||
|
|
||||||
|
from api_v1.utils_webhook import dispatch_webhook
|
||||||
|
|
||||||
|
# Should not raise, should return silently
|
||||||
|
dispatch_webhook("nonexistent_user", "new_prediction", {"data": "test"})
|
||||||
|
|
||||||
|
def test_dispatch_sends_hmac_header(self):
|
||||||
|
"""dispatch_webhook sends correct HMAC-SHA256 signature header"""
|
||||||
|
test_secret = "testsecret"
|
||||||
|
test_url = "https://hook.example.com/receive"
|
||||||
|
test_payload = {"race_id": "R123", "top1": "Cheval Blanc"}
|
||||||
|
|
||||||
|
with (
|
||||||
|
patch("api_v1.utils_webhook.get_db") as mock_get_db,
|
||||||
|
patch("api_v1.utils_webhook.requests.post") as mock_post,
|
||||||
|
):
|
||||||
|
mock_row = MagicMock()
|
||||||
|
mock_row.__getitem__ = lambda self, key: (
|
||||||
|
test_url if key == "url" else test_secret
|
||||||
|
)
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn.execute.return_value.fetchone.return_value = mock_row
|
||||||
|
mock_get_db.return_value = mock_conn
|
||||||
|
|
||||||
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 200
|
||||||
|
mock_post.return_value = mock_response
|
||||||
|
|
||||||
|
from api_v1.utils_webhook import dispatch_webhook, EVENT_NEW_PREDICTION
|
||||||
|
|
||||||
|
dispatch_webhook("user123", EVENT_NEW_PREDICTION, test_payload)
|
||||||
|
|
||||||
|
assert mock_post.called
|
||||||
|
call_kwargs = mock_post.call_args
|
||||||
|
headers_sent = call_kwargs.kwargs.get("headers") or call_kwargs[1].get(
|
||||||
|
"headers"
|
||||||
|
)
|
||||||
|
assert "X-Turf-Signature" in headers_sent
|
||||||
|
assert headers_sent["X-Turf-Signature"].startswith("sha256=")
|
||||||
|
assert headers_sent["X-Turf-Event"] == EVENT_NEW_PREDICTION
|
||||||
1007
train_ensemble.py
Normal file
1007
train_ensemble.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
|
|||||||
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
||||||
|
|
||||||
|
|
||||||
|
def run_telegram_alerts():
|
||||||
|
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
|
||||||
|
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
|
||||||
|
try:
|
||||||
|
os.chdir("/home/h3r7/turf_saas")
|
||||||
|
import telegram_alerts
|
||||||
|
|
||||||
|
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
|
||||||
|
logger.info(
|
||||||
|
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
|
||||||
|
stats.get("sent", 0),
|
||||||
|
stats.get("skipped", 0),
|
||||||
|
stats.get("errors", 0),
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
traceback.print_exc()
|
||||||
|
|
||||||
|
|
||||||
|
def schedule_dynamic_telegram_alerts():
|
||||||
|
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
|
||||||
|
race_time = get_todays_race_time()
|
||||||
|
|
||||||
|
if race_time:
|
||||||
|
try:
|
||||||
|
# Convertir timestamp ms en datetime
|
||||||
|
dt = datetime.fromtimestamp(race_time / 1000)
|
||||||
|
race_hour = dt.hour
|
||||||
|
race_min = dt.minute
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Alertes 30min avant la course
|
||||||
|
pre_min = race_min - 30
|
||||||
|
pre_hour = race_hour
|
||||||
|
if pre_min < 0:
|
||||||
|
pre_min += 60
|
||||||
|
pre_hour -= 1
|
||||||
|
|
||||||
|
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
|
||||||
|
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
|
||||||
|
"telegram", "dynamic"
|
||||||
|
)
|
||||||
|
logger.info(
|
||||||
|
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
|
||||||
|
)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
|
||||||
|
else:
|
||||||
|
logger.info(
|
||||||
|
"ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def schedule_dynamic_results():
|
def schedule_dynamic_results():
|
||||||
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
||||||
race_time = get_todays_race_time()
|
race_time = get_todays_race_time()
|
||||||
@@ -245,6 +304,9 @@ def main():
|
|||||||
# Scoring dynamique (15min avant course)
|
# Scoring dynamique (15min avant course)
|
||||||
schedule_dynamic_scoring()
|
schedule_dynamic_scoring()
|
||||||
|
|
||||||
|
# Alertes Telegram dynamiques (30min avant course)
|
||||||
|
schedule_dynamic_telegram_alerts()
|
||||||
|
|
||||||
# Résultats dynamiques (H+1)
|
# Résultats dynamiques (H+1)
|
||||||
schedule_dynamic_results()
|
schedule_dynamic_results()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user