Compare commits

...

14 Commits

Author SHA1 Message Date
DevOps Engineer
e517741c97 fix(tests): replace abc12345 by abc1234 in TestWeakPasswordRejection
abc12345 n'est pas dans WEAK_PASSWORDS de saas_auth.py et satisfait
les règles de complexité → test échouait (attendait 400, obtenait 201).
abc1234 est explicitement dans la blacklist (ligne 84 de saas_auth.py).

Correction demandée par CTO en review PR #7 (HRT-63).

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-27 15:53:39 +02:00
CTO H3R7Tech
d39c7d3319 fix(billing): JWT token incompatibility — use saas_auth require_auth + fix table names HRT-54
Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-27 15:21:43 +02:00
DevOps Engineer
8c5fdf1e9c feat(security): blacklist + password strength validation — fix weak passwords HRT-63
- Add WEAK_PASSWORDS set (50+ common passwords) in saas_auth.py
- Add validate_password_strength() function: checks min length, blacklist, digits, letters
- Replace raw len() checks in /register and /change-password with validate_password_strength()
- Add TestWeakPasswordRejection class in test_security.py: parametrized weak pwd test, strong pwd 201 test, no-digit, no-letter tests

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-27 15:01:57 +02:00
DevOps Engineer
36d93697bc Merge Sprint 7-8 CI/CD + Docker + Monitoring (HRT-33)
Some checks failed
CD / Deploy → Staging (push) Has been cancelled
CD / Smoke Tests on Staging (push) Has been cancelled
CD / Deploy → Production (push) Has been cancelled
CD / Rollback Production (push) Has been cancelled
2026-04-26 23:12:59 +02:00
2f57719b21 Merge pull request 'Sprint 4-5 — Landing Page + Onboarding (HRT-30)' (#3) from feature/landing-onboarding into master 2026-04-26 23:12:06 +02:00
bffc06c9b1 Merge pull request 'Sprint 3-4 — Refacto API /v1/ (HRT-29)' (#2) from feature/api-v1-refacto into master 2026-04-26 23:12:04 +02:00
f1ef2648b1 Merge pull request 'Sprint 6-7 — ML Upgrade: Ensemble XGBoost+LightGBM+MLP + Optuna' (#1) from feature/ml-upgrade-ensemble into master 2026-04-25 19:15:15 +02:00
DevOps Engineer
ce0ee150ec fix(api-v1): add billing_db.py dependency for billing routes
The api_v1 Blueprint includes billing routes (POST/GET /api/v1/billing/*),
which import from billing_db. This module lives in feature/billing-stripe
(HRT-31) but is needed here for tests to pass. Added the file so all
42 integration tests pass without modification.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:08:39 +02:00
DevOps Engineer
41a9e36166 feat(sprint4-5): Landing page + onboarding SaaS — HRT-30
Frontend pages:
- landing.html: marketing page — hero, pricing (Free/9.90e/24.90e), features, FAQ, footer, mobile-first responsive, LCP < 2.5s friendly
- login.html: JWT auth login with JS validation, error handling, redirect-after-login
- register.html: registration with plan selection preview sidebar, password strength meter
- dashboard_saas.html: role-based dashboard (Free/Premium/Pro) with locked sections, race prediction cards, detailed table, stats row
- onboarding.html: 3-step wizard — plan confirm + Telegram alerts config + first prediction preview
- account.html: tabbed account management — profile, security (change-password, delete), plan upgrade, notification preferences

Backend:
- saas_auth.py: Flask Blueprint /api/v1/auth/* — register, login, token auth, profile/password/plan/preferences update, logout, delete-account
- saas_api_v1.py: Flask Blueprint /api/v1/* — stats/summary, predictions/today (plan-gated), value-bets (Premium+), CSV export (Pro)

Server:
- portal_server.py: register blueprints, serve all new SaaS routes at /login /register /dashboard /onboarding /account

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:04:19 +02:00
DevOps Engineer
b8ef1ed35d feat: Sprint 3-4 — Refacto API /v1/ (HRT-29)
- Blueprint Flask api_v1 avec prefix /api/v1/
- GET /api/v1/health — healthcheck public
- GET /api/v1/courses/today — courses du jour (paginé, filtré)
- GET /api/v1/courses/{id}/predictions — prédictions ML pour une course
- GET /api/v1/predictions/top3 — top 3 global (free tier)
- GET /api/v1/predictions/all — toutes prédictions (premium+)
- GET /api/v1/valuebets — value bets du jour (premium+)
- GET /api/v1/backtest — résultats backtest historiques (pro)
- GET /api/v1/export/csv — export CSV prédictions/paris (pro)
- GET /api/v1/metrics — métriques perf ML (premium+)
- Swagger/OpenAPI via flasgger à /api/v1/docs
- Erreurs uniformes {status, message, code}
- Pagination limit/offset sur toutes les listes
- 42 tests d'intégration passants

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 18:00:54 +02:00
DevOps Engineer
793ee82c29 fix(qa): add /health endpoints to Flask apps for Docker healthchecks
Docker compose healthchecks target /health on combined-api, dashboard-api
and portal, but these endpoints did not exist (returned 404). This caused
all dependent services (condition: service_healthy) to fail startup.

- combined_api.py: GET /health + /turf/health with DB connectivity check
- dashboard_api.py: GET /health + /turf/health with DB connectivity check
- portal_server.py: GET /health (lightweight, no DB)

QA Finding 1 from HRT-34 review of HRT-33 branch feature/devops-cicd.

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

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:35:45 +02:00
DevOps Engineer
dce1e9b744 feat(devops): CI/CD + Docker + Monitoring infrastructure
- Multi-stage Dockerfile (builder+runner, <500MB target)
- docker-compose.yml: app(x4) + postgres + redis + prometheus + grafana + nginx
- .env.example with all required secrets (never hardcoded)
- requirements.txt with all dependencies including prometheus-client, alembic
- GitHub Actions CI: lint (flake8+bandit+safety) + tests + Docker build/push
- GitHub Actions CD: staging deploy -> smoke tests -> production deploy + rollback
- Alembic migration setup + initial PostgreSQL schema (001_initial_schema)
- SQLite→PostgreSQL data migration script
- Prometheus metrics module (HTTP, ML, DB, business metrics)
- Prometheus alert rules (5xx >1%, latency >2s, disk >80%, ML accuracy)
- Grafana dashboard (overview: req/s, p95, ML accuracy, error rate)
- Nginx reverse proxy config (HTTPS/TLS, rate limiting, security headers)
- Structured JSON logging module
- Automated daily DB backup script (pg_dump + 30-day retention)

Branch: feature/devops-cicd

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-25 17:32:02 +02:00
61 changed files with 9970 additions and 126 deletions

68
.dockerignore Normal file
View File

@@ -0,0 +1,68 @@
# Files/dirs excluded from Docker build context
# Keep image small; sensitive files never baked in
# Python artifacts
__pycache__/
*.py[cod]
*.pyo
*.pyd
.Python
*.egg-info/
dist/
build/
.eggs/
# Virtual environments
venv/
.venv/
env/
# Databases (use volumes)
*.db
*.sqlite
*.sqlite3
# ML models (use volumes)
*.pkl
*.joblib
# Logs
logs/
*.log
# Git
.git/
.gitignore
# Backups & temp files
*.backup*
*.bak*
*.tmp
*.bak
# Secrets & env files
.env
.env.*
!.env.example
# Exports
exports/
# OS files
.DS_Store
Thumbs.db
# Editor files
.vscode/
.idea/
*.swp
*.swo
# Test artifacts
.pytest_cache/
htmlcov/
.coverage
coverage.xml
# AWS
awscliv2.zip

82
.env.example Normal file
View File

@@ -0,0 +1,82 @@
# =============================================================
# H3R7Tech Turf SaaS — Environment Variables Template
# Copy this file to .env and fill in your values.
# NEVER commit .env to version control.
# =============================================================
# ----------------------------------------------------------------
# PostgreSQL
# ----------------------------------------------------------------
POSTGRES_HOST=postgres
POSTGRES_PORT=5432
POSTGRES_DB=turf_saas
POSTGRES_USER=turf
POSTGRES_PASSWORD=CHANGE_ME_STRONG_PASSWORD
# Full DSN used by SQLAlchemy / Alembic
DATABASE_URL=postgresql://turf:CHANGE_ME_STRONG_PASSWORD@postgres:5432/turf_saas
# ----------------------------------------------------------------
# Redis
# ----------------------------------------------------------------
REDIS_HOST=redis
REDIS_PORT=6379
REDIS_PASSWORD=CHANGE_ME_REDIS_PASSWORD
REDIS_URL=redis://:CHANGE_ME_REDIS_PASSWORD@redis:6379/0
# ----------------------------------------------------------------
# Flask / App
# ----------------------------------------------------------------
FLASK_ENV=production
SECRET_KEY=CHANGE_ME_FLASK_SECRET_KEY_64CHARS
DEBUG=false
LOG_LEVEL=INFO
# DB path for legacy SQLite (kept for migration, set to /app/data/db/)
DB_PATH=/app/data/db/turf_saas.db
# ----------------------------------------------------------------
# Domain & TLS
# ----------------------------------------------------------------
DOMAIN=turf.h3r7.tech
ADMIN_EMAIL=admin@h3r7.tech
# ----------------------------------------------------------------
# Stripe (Billing)
# ----------------------------------------------------------------
STRIPE_SECRET_KEY=sk_live_CHANGE_ME
STRIPE_WEBHOOK_SECRET=whsec_CHANGE_ME
STRIPE_PUBLISHABLE_KEY=pk_live_CHANGE_ME
# ----------------------------------------------------------------
# LLM / AI API keys
# ----------------------------------------------------------------
OPENROUTER_API_KEY=CHANGE_ME
OPENAI_API_KEY=CHANGE_ME
LLM_BASE_URL=https://openrouter.ai/v1
LLM_MODEL=liquid/lfm-2.5-1.2b-instruct:free
# ----------------------------------------------------------------
# External APIs
# ----------------------------------------------------------------
RESEND_API=CHANGE_ME
BRAVE_SEARCH_API=CHANGE_ME
# ----------------------------------------------------------------
# Monitoring
# ----------------------------------------------------------------
GRAFANA_ADMIN_USER=admin
GRAFANA_ADMIN_PASSWORD=CHANGE_ME_GRAFANA_PASSWORD
# Slack webhook for CI/CD notifications (optional)
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/CHANGE_ME
# Telegram bot for notifications (optional)
TELEGRAM_BOT_TOKEN=CHANGE_ME
TELEGRAM_CHAT_ID=CHANGE_ME
# ----------------------------------------------------------------
# Docker registry (for CD pipeline)
# ----------------------------------------------------------------
REGISTRY=ghcr.io
IMAGE_NAME=h3r7tech/turf-saas

205
.github/workflows/cd.yml vendored Normal file
View File

@@ -0,0 +1,205 @@
# ============================================================
# CD Pipeline — deploy to staging then production
# Triggers on push to main/master
# ============================================================
name: CD
on:
push:
branches: [main, master]
workflow_dispatch:
inputs:
environment:
description: "Target environment"
required: true
default: staging
type: choice
options: [staging, production]
concurrency:
group: cd-${{ github.ref }}
cancel-in-progress: false # Never cancel an active deploy
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ----------------------------------------------------------
# Job 1: Deploy to Staging
# ----------------------------------------------------------
deploy-staging:
name: Deploy → Staging
runs-on: ubuntu-latest
environment:
name: staging
url: https://staging.turf.h3r7.tech
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- name: Deploy to staging server via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.STAGING_HOST }}
username: ${{ secrets.STAGING_USER }}
key: ${{ secrets.STAGING_SSH_KEY }}
port: ${{ secrets.STAGING_PORT || 22 }}
script: |
set -e
echo "=== Deploying to STAGING ==="
cd /opt/turf-saas
# Pull latest code
git fetch origin
git checkout ${{ github.sha }}
# Pull latest Docker images
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker compose pull
# Run DB migrations
docker compose run --rm combined-api alembic upgrade head
# Rolling restart — zero downtime
docker compose up -d --no-deps --scale combined-api=2 combined-api
sleep 15
docker compose up -d --no-deps --scale combined-api=1 combined-api
# Restart other services
docker compose up -d --no-deps dashboard-api portal scheduler
# Health check
sleep 20
curl -f https://staging.turf.h3r7.tech/health || exit 1
echo "=== Staging deploy OK ==="
- name: Notify Staging Deploy
run: |
MSG="✅ Staging deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data "{\"text\":\"${MSG}\"}" || true
curl -s -X POST \
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d text="${MSG}" || true
# ----------------------------------------------------------
# Job 2: Smoke Tests on Staging
# ----------------------------------------------------------
smoke-test-staging:
name: Smoke Tests on Staging
runs-on: ubuntu-latest
needs: deploy-staging
steps:
- name: Health endpoints check
run: |
BASE="https://staging.turf.h3r7.tech"
echo "Checking ${BASE}/health ..."
curl -f "${BASE}/health" -o /dev/null -s -w "%{http_code}\n"
echo "Checking ${BASE}/api/predictions ..."
curl -f "${BASE}/api/predictions" -o /dev/null -s -w "%{http_code}\n" || true
echo "Smoke tests passed"
# ----------------------------------------------------------
# Job 3: Deploy to Production (manual approval gate)
# ----------------------------------------------------------
deploy-production:
name: Deploy → Production
runs-on: ubuntu-latest
needs: smoke-test-staging
environment:
name: production
url: https://turf.h3r7.tech
permissions:
contents: read
packages: read
steps:
- uses: actions/checkout@v4
- name: Deploy to production server via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
port: ${{ secrets.PROD_PORT || 22 }}
script: |
set -e
echo "=== Deploying to PRODUCTION ==="
cd /opt/turf-saas
# Backup current state
docker compose exec -T postgres pg_dumpall -U turf > /opt/backups/turf_saas_pre_deploy_$(date +%Y%m%d_%H%M%S).sql
# Pull latest code
git fetch origin
git checkout ${{ github.sha }}
# Pull latest Docker images
echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
docker compose pull
# Run DB migrations
docker compose run --rm combined-api alembic upgrade head
# Rolling restart
docker compose up -d --no-deps --scale combined-api=2 combined-api
sleep 20
docker compose up -d --no-deps --scale combined-api=1 combined-api
docker compose up -d --no-deps dashboard-api portal scheduler
# Health check
sleep 30
curl -f https://turf.h3r7.tech/health || exit 1
# Clean old images
docker image prune -f
echo "=== Production deploy OK ==="
- name: Notify Production Deploy
run: |
MSG="🚀 Production deployed: \`${{ github.repository }}\` commit=\`${{ github.sha }}\`"
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data "{\"text\":\"${MSG}\"}" || true
curl -s -X POST \
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d text="${MSG}" || true
# ----------------------------------------------------------
# Rollback job (triggered manually on failure)
# ----------------------------------------------------------
rollback:
name: Rollback Production
runs-on: ubuntu-latest
if: failure() && needs.deploy-production.result == 'failure'
needs: deploy-production
environment: production
steps:
- name: Rollback via SSH
uses: appleboy/ssh-action@v1.0.3
with:
host: ${{ secrets.PROD_HOST }}
username: ${{ secrets.PROD_USER }}
key: ${{ secrets.PROD_SSH_KEY }}
script: |
cd /opt/turf-saas
git checkout HEAD~1
docker compose up -d --force-recreate
echo "Rollback complete"
- name: Notify Rollback
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data '{"text":"⚠️ Production ROLLED BACK due to deploy failure!"}' || true

236
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,236 @@
# ============================================================
# CI Pipeline — lint + tests + Docker build
# Runs on every push and pull request
# ============================================================
name: CI
on:
push:
branches: ["**"]
pull_request:
branches: [main, master, develop]
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
env:
PYTHON_VERSION: "3.12"
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
# ----------------------------------------------------------
# Job 1: Lint & Static Analysis
# ----------------------------------------------------------
lint:
name: Lint & Security Scan
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install lint tools
run: pip install flake8 bandit safety
- name: Flake8 linting
run: |
flake8 . \
--exclude=venv,migrations,__pycache__,.git \
--max-line-length=120 \
--ignore=E501,W503,E302,E303 \
--count --statistics
continue-on-error: true
- name: Bandit security scan
run: |
bandit -r . \
--exclude ./venv,./migrations,./infra \
-ll -ii \
-f json -o bandit-report.json || true
cat bandit-report.json
- name: Safety dependency vulnerability check
run: |
safety check -r requirements.txt --json || true
# ----------------------------------------------------------
# Job 2: Tests
# ----------------------------------------------------------
test:
name: Unit & Integration Tests
runs-on: ubuntu-latest
needs: lint
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: turf_test
POSTGRES_USER: turf
POSTGRES_PASSWORD: testpassword
ports:
- 5432:5432
options: >-
--health-cmd pg_isready
--health-interval 10s
--health-timeout 5s
--health-retries 5
env:
DATABASE_URL: postgresql://turf:testpassword@localhost:5432/turf_test
POSTGRES_HOST: localhost
POSTGRES_PORT: 5432
POSTGRES_DB: turf_test
POSTGRES_USER: turf
POSTGRES_PASSWORD: testpassword
FLASK_ENV: testing
SECRET_KEY: test-secret-key-not-for-production
DB_PATH: /tmp/turf_test.db
LOG_LEVEL: WARNING
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: ${{ env.PYTHON_VERSION }}
cache: pip
- name: Install dependencies
run: pip install -r requirements.txt pytest pytest-cov pytest-flask
- name: Run Alembic migrations
run: |
if [ -f alembic.ini ]; then
alembic upgrade head
else
echo "No alembic.ini found, skipping migrations"
fi
- name: Run tests
run: |
if [ -d tests ]; then
pytest tests/ -v --cov=. --cov-report=xml --cov-report=term-missing
else
echo "No tests directory found — creating basic smoke test"
python -c "
import sys, os
os.environ['FLASK_ENV'] = 'testing'
os.environ['SECRET_KEY'] = 'test'
os.environ['DB_PATH'] = '/tmp/smoke_test.db'
print('Import check...')
try:
import combined_api
print('combined_api: OK')
except Exception as e:
print(f'combined_api: ERROR - {e}')
try:
import dashboard_api
print('dashboard_api: OK')
except Exception as e:
print(f'dashboard_api: ERROR - {e}')
try:
import portal_server
print('portal_server: OK')
except Exception as e:
print(f'portal_server: ERROR - {e}')
print('All checks done.')
"
fi
- name: Upload coverage report
uses: codecov/codecov-action@v4
if: hashFiles('coverage.xml') != ''
with:
file: ./coverage.xml
fail_ci_if_error: false
# ----------------------------------------------------------
# Job 3: Docker Build
# ----------------------------------------------------------
docker-build:
name: Docker Build & Push
runs-on: ubuntu-latest
needs: test
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Log in to GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Extract Docker metadata
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
tags: |
type=ref,event=branch
type=ref,event=pr
type=sha,prefix=sha-
type=raw,value=latest,enable={{is_default_branch}}
- name: Build (and push on non-PR)
uses: docker/build-push-action@v6
with:
context: .
target: runner
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
- name: Verify image size
if: github.event_name != 'pull_request'
run: |
SIZE=$(docker image inspect ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest --format='{{.Size}}' 2>/dev/null || echo "0")
SIZE_MB=$((SIZE / 1024 / 1024))
echo "Image size: ${SIZE_MB}MB"
if [ "$SIZE_MB" -gt 500 ]; then
echo "::warning::Image size ${SIZE_MB}MB exceeds 500MB limit"
fi
# ----------------------------------------------------------
# Job 4: Notify on failure
# ----------------------------------------------------------
notify-failure:
name: Notify on Failure
runs-on: ubuntu-latest
needs: [lint, test, docker-build]
if: failure() && github.event_name == 'push'
steps:
- name: Notify Telegram
if: vars.TELEGRAM_BOT_TOKEN != ''
run: |
curl -s -X POST \
"https://api.telegram.org/bot${{ secrets.TELEGRAM_BOT_TOKEN }}/sendMessage" \
-d chat_id="${{ secrets.TELEGRAM_CHAT_ID }}" \
-d text="❌ CI FAILED: ${{ github.repository }} branch=${{ github.ref_name }} commit=${{ github.sha }}" \
-d parse_mode="Markdown" || true
- name: Notify Slack
if: vars.SLACK_WEBHOOK_URL != ''
run: |
curl -s -X POST "${{ secrets.SLACK_WEBHOOK_URL }}" \
-H 'Content-type: application/json' \
--data "{\"text\":\"❌ CI FAILED: \`${{ github.repository }}\` branch=\`${{ github.ref_name }}\` commit=\`${{ github.sha }}\`\"}" || true

28
.gitignore vendored
View File

@@ -78,3 +78,31 @@ patch_*.py
# Données scraping brutes
v3_*.json
v4_*.json
# Environment secrets (NEVER commit)
.env
.env.local
.env.*.local
!.env.example
# Docker build cache
.docker/
# Editor
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test artifacts
.pytest_cache/
htmlcov/
.coverage
coverage.xml
# TLS certs (managed by certbot volume)
infra/nginx/certs/

132
API_AUTH.md Normal file
View File

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

68
Dockerfile Normal file
View File

@@ -0,0 +1,68 @@
# ============================================================
# Stage 1: Builder — install deps + compile Python bytecode
# ============================================================
FROM python:3.12-slim AS builder
WORKDIR /build
# System deps needed to compile psycopg2, xgboost, etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
g++ \
libpq-dev \
libffi-dev \
libssl-dev \
&& rm -rf /var/lib/apt/lists/*
# Upgrade pip + install wheel for faster builds
RUN pip install --upgrade pip wheel
# Copy only requirements first (layer caching)
COPY requirements.txt .
# Install into a prefix we can copy cleanly
RUN pip install --prefix=/install --no-cache-dir -r requirements.txt
# ============================================================
# Stage 2: Runner — minimal production image
# ============================================================
FROM python:3.12-slim AS runner
LABEL maintainer="DevOps <devops@h3r7tech.ai>"
LABEL org.opencontainers.image.title="Turf SaaS"
LABEL org.opencontainers.image.description="H3R7Tech Turf Predictions SaaS"
# Runtime system deps only
RUN apt-get update && apt-get install -y --no-install-recommends \
libpq5 \
curl \
&& rm -rf /var/lib/apt/lists/*
# Create non-root app user
RUN groupadd -r appuser && useradd -r -g appuser appuser
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /install /usr/local
# Copy application source (exclude files via .dockerignore)
COPY . .
# Create directories for persistent data
RUN mkdir -p /app/data/db /app/data/models /app/logs \
&& chown -R appuser:appuser /app
# Switch to non-root user
USER appuser
# Expose all service ports
EXPOSE 8790 8791 8792 8793
# Health check — hits the combined API
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:8790/health || exit 1
# Default: run combined API via gunicorn
# Override CMD per service in docker-compose
CMD ["gunicorn", "--bind", "0.0.0.0:8790", "--workers", "2", "--timeout", "120", "combined_api:app"]

156
README_API_V1.md Normal file
View File

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

383
account.html Normal file
View File

@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Mon Compte — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149; --gold: #ffd600;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
a { color: inherit; text-decoration: none; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 5%; border-bottom: 1px solid var(--border); }
.nav-logo { font-weight: 700; font-size: 1.1rem; }
.nav-back { color: var(--muted); font-size: .9rem; }
.nav-back:hover { color: var(--text); }
main { flex: 1; padding: 40px 5%; max-width: 900px; margin: 0 auto; width: 100%; }
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 28px; }
/* TABS */
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 28px; }
.tab-btn {
padding: 10px 18px; border: none; background: transparent; color: var(--muted);
font-size: .9rem; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent;
transition: all .2s; margin-bottom: -1px;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active { color: var(--green); border-bottom-color: var(--green); }
.tab-panel { display: none; }
.tab-panel.active { display: block; }
/* CARDS */
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; }
.card-title { font-size: 1rem; font-weight: 700; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
.form-group { margin-bottom: 16px; }
label { display: block; font-size: .83rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
input {
width: 100%; padding: 10px 14px; background: var(--dark3);
border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
}
input:focus { border-color: var(--green); }
input:disabled { opacity: .5; cursor: not-allowed; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .88rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-danger { background: transparent; border: 1px solid var(--error); color: var(--error); }
.btn-danger:hover { background: rgba(248,81,73,.1); }
.btn-upgrade { background: linear-gradient(135deg, var(--gold), #ff6d00); color: #000; }
/* PLAN CARDS */
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 24px; }
.plan-card { background: var(--dark3); border: 2px solid var(--border); border-radius: var(--radius); padding: 20px; position: relative; transition: border-color .2s; }
.plan-card.current { border-color: var(--green); }
.plan-current-badge { position: absolute; top: -10px; left: 16px; background: var(--green); color: #000; padding: 2px 10px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
.plan-name { font-weight: 800; font-size: 1rem; margin-bottom: 6px; }
.plan-price { font-size: 1.5rem; font-weight: 800; color: var(--green); }
.plan-price span { font-size: .85rem; font-weight: 400; color: var(--muted); }
.plan-features-mini { list-style: none; margin-top: 12px; }
.plan-features-mini li { font-size: .8rem; color: var(--muted); padding: 3px 0; }
.plan-features-mini li::before { content: "✓ "; color: var(--green); }
.plan-card .btn { width: 100%; justify-content: center; margin-top: 14px; font-size: .82rem; padding: 8px; }
/* ALERT */
.alert { padding: 12px 16px; border-radius: 8px; font-size: .88rem; margin-bottom: 14px; display: none; }
.alert.show { display: block; }
.alert-success { background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3); color: var(--green); }
.alert-error { background: rgba(248,81,73,.1); border: 1px solid rgba(248,81,73,.3); color: var(--error); }
/* DANGER ZONE */
.danger-card { border-color: rgba(248,81,73,.3); }
/* Toast */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.error { background: var(--error); color: #fff; }
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } main { padding: 20px 4%; } }
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/dashboard" class="nav-back">← Retour au dashboard</a>
</nav>
<main>
<h1>⚙️ Mon compte</h1>
<div class="tabs">
<button class="tab-btn active" data-tab="profile">Profil</button>
<button class="tab-btn" data-tab="security">Sécurité</button>
<button class="tab-btn" data-tab="upgrade">Mon plan</button>
<button class="tab-btn" data-tab="notifications">Notifications</button>
</div>
<!-- PROFIL -->
<div class="tab-panel active" id="tab-profile">
<div class="card">
<div class="card-title">Informations personnelles</div>
<div class="alert alert-success" id="profile-success">Profil mis à jour avec succès.</div>
<div class="alert alert-error" id="profile-error"></div>
<form id="profile-form">
<div class="form-row">
<div class="form-group"><label>Prénom</label><input type="text" id="p-firstname" placeholder="Jean"></div>
<div class="form-group"><label>Nom</label><input type="text" id="p-lastname" placeholder="Dupont"></div>
</div>
<div class="form-group"><label>Adresse email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer les modifications</button>
</form>
</div>
<div class="card">
<div class="card-title">Informations de compte</div>
<div class="form-group"><label>Plan actuel</label><input type="text" id="p-plan-display" disabled></div>
<div class="form-group"><label>Membre depuis</label><input type="text" id="p-created" disabled></div>
<div class="form-group"><label>Identifiant utilisateur</label><input type="text" id="p-id" disabled></div>
</div>
</div>
<!-- SECURITE -->
<div class="tab-panel" id="tab-security">
<div class="card">
<div class="card-title">Changer le mot de passe</div>
<div class="alert alert-success" id="pwd-success">Mot de passe mis à jour.</div>
<div class="alert alert-error" id="pwd-error"></div>
<form id="password-form">
<div class="form-group"><label>Mot de passe actuel</label><input type="password" id="s-current-pwd" placeholder="••••••••" autocomplete="current-password" required></div>
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" autocomplete="new-password" required></div>
<div class="form-group"><label>Confirmer le nouveau mot de passe</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" autocomplete="new-password" required></div>
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour le mot de passe</button>
</form>
</div>
<div class="card danger-card">
<div class="card-title">Zone dangereuse</div>
<p style="font-size:.9rem;color:var(--muted);margin-bottom:16px">La suppression de votre compte est irréversible. Toutes vos données seront perdues.</p>
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
</div>
</div>
<!-- UPGRADE / PLAN -->
<div class="tab-panel" id="tab-upgrade">
<div class="card">
<div class="card-title">Votre plan actuel</div>
<div class="plan-grid">
<div class="plan-card" id="plan-card-free">
<div class="plan-name">Free</div>
<div class="plan-price">0€ <span>/mois</span></div>
<ul class="plan-features-mini">
<li>1 course complète/jour</li>
<li>Aperçu Top-3</li>
</ul>
<button class="btn btn-ghost" id="select-free-btn">Plan actuel</button>
</div>
<div class="plan-card" id="plan-card-premium">
<div class="plan-name">Premium ⭐</div>
<div class="plan-price">9,90€ <span>/mois</span></div>
<ul class="plan-features-mini">
<li>Toutes les courses</li>
<li>Alertes Telegram</li>
<li>Value bets</li>
<li>Historique 90j</li>
</ul>
<button class="btn btn-upgrade" id="select-premium-btn">Choisir Premium</button>
</div>
<div class="plan-card" id="plan-card-pro">
<div class="plan-name">Pro 🚀</div>
<div class="plan-price">24,90€ <span>/mois</span></div>
<ul class="plan-features-mini">
<li>Tout Premium</li>
<li>Export CSV</li>
<li>API REST</li>
<li>Support prioritaire</li>
</ul>
<button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button>
</div>
</div>
<p style="font-size:.82rem;color:var(--muted)">💳 Paiement sécurisé via Stripe (disponible dans le Sprint 5-6). Pour l'instant, contactez-nous pour activer un plan payant.</p>
</div>
</div>
<!-- NOTIFICATIONS -->
<div class="tab-panel" id="tab-notifications">
<div class="card">
<div class="card-title">Alertes Telegram</div>
<div class="alert alert-success" id="notif-success">Préférences enregistrées.</div>
<form id="notif-form">
<div class="form-group">
<label>Chat ID Telegram</label>
<input type="text" id="n-telegram-id" placeholder="Votre Chat ID (ex: 123456789)">
<p style="font-size:.78rem;color:var(--muted);margin-top:5px">Envoyez /start à @TurfIABot pour obtenir votre Chat ID.</p>
</div>
<div class="form-group">
<p style="font-size:.88rem;font-weight:600;margin-bottom:10px;color:var(--muted)">Recevoir des alertes pour :</p>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="n-alert-vb" style="width:auto;accent-color:var(--green)"> Value bets identifiés
</label>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="n-alert-top1" style="width:auto;accent-color:var(--green)"> Favori IA Top-1
</label>
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="n-alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement
</label>
</div>
<button type="submit" class="btn btn-primary">Enregistrer les préférences</button>
</form>
</div>
</div>
</main>
<div id="toast"></div>
<script>
const API = '/api/v1';
function getToken() { return localStorage.getItem('turf_token'); }
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { location.href = '/login'; return null; }
return res.json().catch(() => null);
}
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
setTimeout(() => t.className = '', 3500);
}
function showAlert(id, show = true) {
document.getElementById(id)?.classList.toggle('show', show);
}
function setAlertMsg(id, msg, show = true) {
const el = document.getElementById(id);
if (el) { el.textContent = msg; el.classList.toggle('show', show); }
}
// TABS
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', () => {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
btn.classList.add('active');
document.getElementById(`tab-${btn.dataset.tab}`)?.classList.add('active');
});
});
// URL param: ?tab=upgrade
(function() {
const tab = new URLSearchParams(location.search).get('tab');
if (tab) {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab}`));
}
})();
// Load user
async function loadUser() {
if (!getToken()) { location.href = '/login'; return; }
const data = await fetchJson(`${API}/auth/me`);
if (!data) return;
const user = data.user || data;
document.getElementById('p-firstname').value = user.firstname || '';
document.getElementById('p-lastname').value = user.lastname || '';
document.getElementById('p-email').value = user.email || '';
document.getElementById('p-plan-display').value = { free: 'Free', premium: 'Premium', pro: 'Pro' }[user.plan] || 'Free';
document.getElementById('p-created').value = user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : '—';
document.getElementById('p-id').value = user.id || '—';
document.getElementById('n-telegram-id').value = user.telegram_chat_id || '';
document.getElementById('n-alert-vb').checked = user.alert_value_bets !== false;
document.getElementById('n-alert-top1').checked = user.alert_top1 !== false;
document.getElementById('n-alert-quinte').checked = !!user.alert_quinte_only;
setPlanCards(user.plan || 'free');
localStorage.setItem('turf_user', JSON.stringify(user));
}
function setPlanCards(plan) {
['free','premium','pro'].forEach(p => {
const card = document.getElementById(`plan-card-${p}`);
if (!card) return;
card.classList.toggle('current', p === plan);
const existing = card.querySelector('.plan-current-badge');
if (p === plan && !existing) {
const badge = document.createElement('div');
badge.className = 'plan-current-badge'; badge.textContent = '✓ Actuel';
card.prepend(badge);
} else if (p !== plan && existing) existing.remove();
});
}
// Profile form
document.getElementById('profile-form').addEventListener('submit', async e => {
e.preventDefault();
showAlert('profile-success', false); showAlert('profile-error', false);
const btn = document.getElementById('profile-btn');
btn.disabled = true; btn.textContent = 'Enregistrement…';
const res = await fetchJson(`${API}/auth/update-profile`, {
method: 'POST',
body: JSON.stringify({
firstname: document.getElementById('p-firstname').value.trim(),
lastname: document.getElementById('p-lastname').value.trim(),
email: document.getElementById('p-email').value.trim()
})
});
btn.disabled = false; btn.textContent = 'Enregistrer les modifications';
if (res && res.ok !== false) { showAlert('profile-success'); showToast('Profil mis à jour.'); }
else setAlertMsg('profile-error', res?.error || 'Erreur lors de la mise à jour.');
});
// Password form
document.getElementById('password-form').addEventListener('submit', async e => {
e.preventDefault();
showAlert('pwd-success', false); showAlert('pwd-error', false);
const np = document.getElementById('s-new-pwd').value;
const cp = document.getElementById('s-confirm-pwd').value;
if (np !== cp) { setAlertMsg('pwd-error', 'Les mots de passe ne correspondent pas.'); return; }
if (np.length < 8) { setAlertMsg('pwd-error', 'Minimum 8 caractères.'); return; }
const btn = document.getElementById('pwd-btn');
btn.disabled = true; btn.textContent = 'Mise à jour…';
const res = await fetchJson(`${API}/auth/change-password`, {
method: 'POST',
body: JSON.stringify({ current_password: document.getElementById('s-current-pwd').value, new_password: np })
});
btn.disabled = false; btn.textContent = 'Mettre à jour le mot de passe';
if (res && res.ok !== false) { showAlert('pwd-success'); showToast('Mot de passe mis à jour.'); document.getElementById('password-form').reset(); }
else setAlertMsg('pwd-error', res?.error || 'Mot de passe actuel incorrect.');
});
// Notifications form
document.getElementById('notif-form').addEventListener('submit', async e => {
e.preventDefault();
showAlert('notif-success', false);
const res = await fetchJson(`${API}/auth/update-preferences`, {
method: 'POST',
body: JSON.stringify({
telegram_chat_id: document.getElementById('n-telegram-id').value.trim(),
alert_value_bets: document.getElementById('n-alert-vb').checked,
alert_top1: document.getElementById('n-alert-top1').checked,
alert_quinte_only: document.getElementById('n-alert-quinte').checked
})
});
if (res && res.ok !== false) { showAlert('notif-success'); showToast('Préférences enregistrées.'); }
else showToast('Erreur lors de la sauvegarde.', 'error');
});
// Plan selection (placeholder until Stripe Sprint 5-6)
['free','premium','pro'].forEach(p => {
document.getElementById(`select-${p}-btn`)?.addEventListener('click', () => {
if (p === 'free') {
showToast('Vous êtes déjà sur le plan Free.', 'info');
} else {
showToast('Paiement Stripe disponible dans le Sprint 5-6. Contactez-nous pour activer ce plan.', 'info');
}
});
});
// Delete account
document.getElementById('delete-account-btn').addEventListener('click', () => {
if (confirm('⚠️ Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) {
if (confirm('Confirmez la suppression définitive de votre compte Turf IA.')) {
fetchJson(`${API}/auth/delete-account`, { method: 'DELETE' }).then(() => {
localStorage.clear();
location.href = '/';
});
}
}
});
loadUser();
</script>
</body>
</html>

48
alembic.ini Normal file
View File

@@ -0,0 +1,48 @@
# Alembic configuration for Turf SaaS
# https://alembic.sqlalchemy.org/en/latest/
[alembic]
# Path to migration scripts
script_location = migrations
# Template used to generate new migration files
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
# Connection string — uses DATABASE_URL env var
sqlalchemy.url = postgresql://%(POSTGRES_USER)s:%(POSTGRES_PASSWORD)s@%(POSTGRES_HOST)s:%(POSTGRES_PORT)s/%(POSTGRES_DB)s
[post_write_hooks]
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S

43
api_v1/__init__.py Normal file
View File

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

View File

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

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

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

@@ -0,0 +1,664 @@
#!/usr/bin/env python3
"""
Billing Blueprint — Stripe integration
Sprint 5-6: HRT-31
Endpoints:
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
GET /api/v1/billing/status — current subscription status (auth required)
Environment variables required:
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
"""
import json
import logging
import os
from datetime import datetime, timedelta, timezone
import stripe
from flask import Blueprint, 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
View File

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

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

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

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

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

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

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

View File

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

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

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

98
api_v1/utils.py Normal file
View File

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

138
app_v1.py Normal file
View File

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

362
auth.py Normal file
View File

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

68
auth_db.py Normal file
View File

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

143
billing_db.py Normal file
View 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
)

View File

@@ -131,6 +131,24 @@ def get_db():
return conn
@app.route("/health")
@app.route("/turf/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
import sqlite3 as _sqlite3
db_ok = True
try:
conn = _sqlite3.connect(DB_PATH, timeout=2)
conn.execute("SELECT 1")
conn.close()
except Exception:
db_ok = False
status = "ok" if db_ok else "degraded"
http_code = 200 if db_ok else 503
return {"status": status, "service": "combined-api", "db": db_ok}, http_code
@app.route("/")
def index():
return send_file("/home/h3r7/turf_saas/dashboard.html")

View File

@@ -86,11 +86,15 @@ def ensure_ml_cache_table(conn):
""")
# Migration : ajouter colonnes risque si table existante sans elles
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'")
conn.execute(
"ALTER TABLE ml_predictions_cache ADD COLUMN risque_label TEXT DEFAULT 'neutral'"
)
except Exception:
pass
try:
conn.execute("ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50")
conn.execute(
"ALTER TABLE ml_predictions_cache ADD COLUMN risque_score INTEGER DEFAULT 50"
)
except Exception:
pass
conn.commit()
@@ -101,7 +105,7 @@ def get_ml_from_cache(conn, date):
ensure_ml_cache_table(conn)
cursor = conn.execute(
"""SELECT * FROM ml_predictions_cache WHERE date = ? ORDER BY ml_score DESC""",
(date,)
(date,),
)
rows = cursor.fetchall()
if not rows:
@@ -112,34 +116,36 @@ def get_ml_from_cache(conn, date):
for row in rows:
r = dict(row)
pred = {
"horse_name": r["horse_name"],
"horse_number": r["horse_number"],
"odds": r["odds"],
"prob_top1": r["prob_top1"],
"prob_top3": r["prob_top3"],
"ml_score": r["ml_score"],
"horse_name": r["horse_name"],
"horse_number": r["horse_number"],
"odds": r["odds"],
"prob_top1": r["prob_top1"],
"prob_top3": r["prob_top3"],
"ml_score": r["ml_score"],
"recommendation": r["recommendation"],
"is_value_bet": r["is_value_bet"],
"is_outlier": r["is_outlier"],
"num_reunion": r["num_reunion"],
"num_course": r["num_course"],
"race_label": r["race_label"],
"race_name": r["race_name"],
"hippodrome": r["hippodrome"],
"discipline": r["discipline"],
"distance": r["distance"],
"heure": r["heure"],
"risque_label": r["risque_label"] if "risque_label" in r.keys() else "neutral",
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
"is_value_bet": r["is_value_bet"],
"is_outlier": r["is_outlier"],
"num_reunion": r["num_reunion"],
"num_course": r["num_course"],
"race_label": r["race_label"],
"race_name": r["race_name"],
"hippodrome": r["hippodrome"],
"discipline": r["discipline"],
"distance": r["distance"],
"heure": r["heure"],
"risque_label": r["risque_label"]
if "risque_label" in r.keys()
else "neutral",
"risque_score": r["risque_score"] if "risque_score" in r.keys() else 50,
}
predictions.append(pred)
key = f"{r['num_reunion']}_{r['num_course']}"
if key not in course_info:
course_info[key] = {
"libelle": r["race_name"],
"libelle_court": r["hippodrome"],
"discipline": r["discipline"],
"distance": r["distance"],
"libelle": r["race_name"],
"libelle_court": r["hippodrome"],
"discipline": r["discipline"],
"distance": r["distance"],
"heure_depart_str": r["heure"],
}
return predictions, course_info
@@ -152,15 +158,18 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
conn.execute("DELETE FROM ml_predictions_cache WHERE date = ?", (date,))
# Calculer le risque par course (grouper les chevaux avec tous leurs scores ML)
from collections import defaultdict
race_horses = defaultdict(list)
for p in predictions:
key = (p.get("num_reunion"), p.get("num_course"))
race_horses[key].append({
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1":p.get("prob_top1", 0),
"prob_top3":p.get("prob_top3", 0),
})
race_horses[key].append(
{
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0),
}
)
race_risque = {}
for key, partants in race_horses.items():
@@ -170,36 +179,39 @@ def save_ml_to_cache(conn, date, predictions, model_version="xgboost_v1"):
for p in predictions:
rkey = (p.get("num_reunion"), p.get("num_course"))
rl, rs = race_risque.get(rkey, ("neutral", 50))
conn.execute("""
conn.execute(
"""
INSERT INTO ml_predictions_cache
(date, num_reunion, num_course, horse_name, horse_number, odds,
prob_top1, prob_top3, ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance, heure,
risque_label, risque_score, model_version)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)
""", (
date,
p.get("num_reunion"),
p.get("num_course"),
p.get("horse_name"),
p.get("horse_number"),
p.get("odds"),
p.get("prob_top1"),
p.get("prob_top3"),
p.get("ml_score"),
p.get("recommendation"),
p.get("is_value_bet", 0),
p.get("is_outlier", 0),
p.get("race_label"),
p.get("race_name"),
p.get("hippodrome"),
p.get("discipline"),
p.get("distance"),
p.get("heure"),
rl,
rs,
model_version,
))
""",
(
date,
p.get("num_reunion"),
p.get("num_course"),
p.get("horse_name"),
p.get("horse_number"),
p.get("odds"),
p.get("prob_top1"),
p.get("prob_top3"),
p.get("ml_score"),
p.get("recommendation"),
p.get("is_value_bet", 0),
p.get("is_outlier", 0),
p.get("race_label"),
p.get("race_name"),
p.get("hippodrome"),
p.get("discipline"),
p.get("distance"),
p.get("heure"),
rl,
rs,
model_version,
),
)
conn.commit()
@@ -219,22 +231,38 @@ def calculate_risque(partants):
return None, None
# Trier par ml_score desc (ou prob_top1 si ml_score absent)
sorted_p = sorted(partants, key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0, reverse=True)
sorted_p = sorted(
partants,
key=lambda x: x.get("ml_score") or x.get("prob_top1") or 0,
reverse=True,
)
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
top2_score = sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0 if len(sorted_p) > 1 else 0
top3_score = sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0 if len(sorted_p) > 2 else 0
top1_score = sorted_p[0].get("ml_score") or sorted_p[0].get("prob_top1") or 0
top2_score = (
sorted_p[1].get("ml_score") or sorted_p[1].get("prob_top1") or 0
if len(sorted_p) > 1
else 0
)
top3_score = (
sorted_p[2].get("ml_score") or sorted_p[2].get("prob_top1") or 0
if len(sorted_p) > 2
else 0
)
gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML
gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML
gap_1_2 = top1_score - top2_score # écart entre 1er et 2e ML
gap_1_3 = top1_score - top3_score # écart entre 1er et 3e ML
# Nombre de concurrents avec ml_score > 40 (dangereux)
nb_dangerous = sum(1 for p in sorted_p if (p.get("ml_score") or 0) > 40)
# Détection favori de cote surpris par le ML
odds_fav = sorted(partants, key=lambda x: x.get("odds") or 999)
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
fav_ml = odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0 if odds_fav else 0
fav_odds = odds_fav[0].get("odds") or 999 if odds_fav else 999
fav_ml = (
odds_fav[0].get("ml_score") or odds_fav[0].get("prob_top1") or 0
if odds_fav
else 0
)
fav_surprise = fav_odds < 5 and fav_ml < 25 # favori de cote ignoré par le ML
# --- SAFE : domination claire ---
@@ -256,7 +284,6 @@ def calculate_risque(partants):
return "neutral", score
def table_exists(conn, table_name):
c = conn.execute(
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
@@ -470,6 +497,28 @@ def prepare_features_from_db(horse_data):
return df
@app.route("/health")
@app.route("/turf/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
import sqlite3 as _sqlite3
db_ok = True
try:
conn = _sqlite3.connect(
DB_FILE if "DB_FILE" in dir() else "/home/h3r7/turf_saas/turf_saas.db",
timeout=2,
)
conn.execute("SELECT 1")
conn.close()
except Exception:
db_ok = False
status = "ok" if db_ok else "degraded"
return {"status": status, "service": "dashboard-api", "db": db_ok}, (
200 if db_ok else 503
)
@app.route("/")
def index():
return send_file("/home/h3r7/turf_saas/dashboard.html")
@@ -722,13 +771,15 @@ def api_ml_predictions():
cached_preds, cached_courses = get_ml_from_cache(conn, today)
if cached_preds:
conn.close()
return jsonify({
"date": today,
"model_version": "xgboost_v1",
"predictions": cached_preds,
"courses": cached_courses,
"from_cache": True,
})
return jsonify(
{
"date": today,
"model_version": "xgboost_v1",
"predictions": cached_preds,
"courses": cached_courses,
"from_cache": True,
}
)
# --- CALCUL ML ---
models = load_models()
@@ -946,15 +997,18 @@ def api_ml_predictions():
# --- CALCUL RISQUE PAR COURSE + INJECTION DANS PREDICTIONS ---
from collections import defaultdict as _dd
_race_horses_ml = _dd(list)
for p in predictions:
key = (p.get("num_reunion"), p.get("num_course"))
_race_horses_ml[key].append({
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0),
})
_race_horses_ml[key].append(
{
"odds": p.get("odds", 999),
"ml_score": p.get("ml_score", 0),
"prob_top1": p.get("prob_top1", 0),
"prob_top3": p.get("prob_top3", 0),
}
)
_race_risque_map = {}
for key, partants in _race_horses_ml.items():
label, score = calculate_risque(partants)
@@ -996,6 +1050,7 @@ def api_ml_predictions_refresh():
conn.close()
# Déléguer au endpoint principal avec force_refresh
from flask import redirect, url_for
return redirect(url_for("api_ml_predictions") + "?refresh=1")
@@ -1107,8 +1162,6 @@ def api_suggestions():
return jsonify({"suggestions": suggestions})
@app.route("/turf/api/metrics/summary")
@app.route("/turf/api/metrics/summary/")
def metrics_summary():
@@ -1124,7 +1177,8 @@ def metrics_summary():
"ROUND(SUM(roi_sp_net), 3) as roi_sp_cumul, ROUND(AVG(ecart_rang_moyen), 2) as moy_ecart_rang, "
"SUM(quinte_5sur5) as nb_5sur5, SUM(quinte_4sur5) as nb_4sur5, SUM(quinte_3sur5) as nb_3sur5 "
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY source ORDER BY moy_taux_place DESC",
(date_filter,))
(date_filter,),
)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
@@ -1132,6 +1186,7 @@ def metrics_summary():
except Exception as e:
return jsonify({"error": True, "message": str(e)})
@app.route("/turf/api/metrics/daily")
@app.route("/turf/api/metrics/daily/")
def metrics_daily():
@@ -1146,7 +1201,8 @@ def metrics_daily():
"ROUND(AVG(roi_sp_net), 3) as roi_sp, SUM(quinte_5sur5) as quinte_5sur5, "
"SUM(quinte_4sur5) as quinte_4sur5 "
"FROM prediction_metrics WHERE date >= date('now', ?) GROUP BY date, source ORDER BY date DESC",
(date_filter,))
(date_filter,),
)
cols = [d[0] for d in cur.description]
rows = [dict(zip(cols, row)) for row in cur.fetchall()]
conn.close()
@@ -1154,6 +1210,7 @@ def metrics_daily():
except Exception as e:
return jsonify({"error": True, "message": str(e)})
if __name__ == "__main__":
load_models()
app.run(host="0.0.0.0", port=8791, debug=False)

441
dashboard_saas.html Normal file
View File

@@ -0,0 +1,441 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Dashboard — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--gold: #ffd600; --orange: #ff6d00;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 10px; --error: #f85149;
}
html { scroll-behavior: smooth; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; }
a { color: inherit; text-decoration: none; }
/* SIDEBAR */
.sidebar {
width: 240px; flex-shrink: 0; background: var(--dark2);
border-right: 1px solid var(--border); padding: 20px 0;
display: flex; flex-direction: column; height: 100vh;
position: sticky; top: 0; overflow-y: auto;
}
.sidebar-logo { padding: 0 20px 20px; font-weight: 800; font-size: 1.1rem; border-bottom: 1px solid var(--border); margin-bottom: 16px; }
.sidebar-logo span { color: var(--green); }
.nav-section { padding: 0 12px 8px; font-size: .72rem; text-transform: uppercase; letter-spacing: 1px; color: var(--muted); font-weight: 600; margin-top: 12px; }
.nav-item {
display: flex; align-items: center; gap: 10px;
padding: 9px 20px; font-size: .9rem; cursor: pointer;
border-radius: 8px; margin: 1px 8px; color: var(--muted); transition: all .15s;
}
.nav-item:hover { background: var(--dark3); color: var(--text); }
.nav-item.active { background: rgba(0,200,83,.1); color: var(--green); }
.nav-item .icon { font-size: 1.1rem; width: 22px; text-align: center; }
.sidebar-bottom { margin-top: auto; padding: 16px; border-top: 1px solid var(--border); }
.user-chip { display: flex; align-items: center; gap: 10px; }
.user-avatar { width: 32px; height: 32px; border-radius: 50%; background: var(--green); display: flex; align-items: center; justify-content: center; font-weight: 700; font-size: .9rem; color: #000; flex-shrink: 0; }
.user-info { flex: 1; min-width: 0; }
.user-name { font-size: .85rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.user-plan { font-size: .72rem; color: var(--muted); text-transform: uppercase; }
/* MAIN */
.main { flex: 1; overflow-y: auto; }
.topbar { display: flex; align-items: center; justify-content: space-between; padding: 16px 28px; border-bottom: 1px solid var(--border); background: var(--dark); position: sticky; top: 0; z-index: 10; }
.topbar-title { font-size: 1.1rem; font-weight: 700; }
.topbar-right { display: flex; align-items: center; gap: 12px; }
.badge { padding: 3px 10px; border-radius: 20px; font-size: .75rem; font-weight: 700; }
.badge-free { background: rgba(139,148,158,.15); color: var(--muted); }
.badge-premium { background: rgba(255,214,0,.15); color: var(--gold); }
.badge-pro { background: rgba(30,136,229,.15); color: var(--blue); }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 16px; border-radius: 8px; font-size: .85rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-upgrade { background: linear-gradient(135deg, var(--gold), var(--orange)); color: #000; }
/* CONTENT */
.content { padding: 28px; }
/* UPGRADE BANNER */
.upgrade-banner {
background: linear-gradient(135deg, rgba(255,214,0,.1), rgba(255,109,0,.08));
border: 1px solid rgba(255,214,0,.25); border-radius: var(--radius);
padding: 16px 20px; margin-bottom: 24px;
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.upgrade-banner p { font-size: .9rem; }
.upgrade-banner strong { color: var(--gold); }
/* STAT CARDS */
.stats-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; margin-bottom: 24px; }
.stat-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px 20px; }
.stat-label { font-size: .78rem; color: var(--muted); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 8px; }
.stat-value { font-size: 1.8rem; font-weight: 800; }
.stat-sub { font-size: .78rem; color: var(--muted); margin-top: 4px; }
.stat-up { color: var(--green); }
.stat-down { color: var(--error); }
/* SECTION TITLE */
.section-title { font-size: 1rem; font-weight: 700; margin-bottom: 14px; display: flex; align-items: center; justify-content: space-between; }
.section-title small { font-size: .78rem; color: var(--muted); font-weight: 400; }
/* RACE TABLE */
.race-table-wrap { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 24px; }
table { width: 100%; border-collapse: collapse; }
thead th { padding: 10px 14px; font-size: .78rem; text-transform: uppercase; letter-spacing: .5px; color: var(--muted); text-align: left; border-bottom: 1px solid var(--border); background: var(--dark3); }
tbody tr { border-bottom: 1px solid var(--border); transition: background .15s; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--dark3); }
tbody td { padding: 11px 14px; font-size: .88rem; }
.horse-name { font-weight: 600; }
.prob-bar-wrap { display: flex; align-items: center; gap: 8px; }
.prob-bar { width: 80px; height: 6px; border-radius: 3px; background: var(--border); overflow: hidden; }
.prob-bar-fill { height: 100%; border-radius: 3px; background: var(--green); }
.value-bet { background: rgba(0,200,83,.15); color: var(--green); padding: 2px 8px; border-radius: 12px; font-size: .75rem; font-weight: 700; }
.cote { font-weight: 700; }
.rank-1 { color: var(--gold); font-weight: 800; }
.rank-2 { color: var(--muted); }
.rank-3 { color: #cd7f32; }
/* BLURRED (locked) */
.locked { filter: blur(6px); pointer-events: none; user-select: none; position: relative; }
.locked-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(13,17,23,.7); border-radius: var(--radius); z-index: 2; }
.locked-overlay-msg { text-align: center; }
.locked-overlay-msg h3 { font-size: 1rem; margin-bottom: 8px; }
.lock-wrap { position: relative; }
/* RACE CARD grid */
.races-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); gap: 16px; margin-bottom: 24px; }
.race-card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 18px; transition: border-color .2s; }
.race-card:hover { border-color: var(--muted); }
.race-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 12px; }
.race-name { font-weight: 700; font-size: .93rem; }
.race-meta { font-size: .78rem; color: var(--muted); margin-top: 2px; }
.race-time { font-size: .8rem; font-weight: 700; color: var(--green); }
.top3-row { display: flex; gap: 8px; margin-top: 10px; flex-wrap: wrap; }
.horse-chip {
padding: 4px 10px; border-radius: 20px; font-size: .78rem; font-weight: 600;
background: var(--dark3); border: 1px solid var(--border);
}
.horse-chip.top1 { border-color: var(--gold); color: var(--gold); background: rgba(255,214,0,.08); }
.horse-chip.top2 { border-color: var(--muted); }
.horse-chip.top3 { border-color: #cd7f32; color: #cd7f32; background: rgba(205,127,50,.08); }
/* EMPTY STATE */
.empty-state { text-align: center; padding: 60px 20px; color: var(--muted); }
.empty-state .icon { font-size: 3rem; margin-bottom: 14px; }
.empty-state h3 { font-size: 1.1rem; font-weight: 700; color: var(--text); margin-bottom: 8px; }
.empty-state p { font-size: .9rem; max-width: 360px; margin: 0 auto 20px; }
/* TOAST */
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.info { background: var(--blue); color: #fff; }
/* LOADING */
.loader-row { display: flex; align-items: center; justify-content: center; gap: 10px; padding: 40px; color: var(--muted); }
.spinner { width: 20px; height: 20px; border: 2px solid var(--border); border-top-color: var(--green); border-radius: 50%; animation: spin .7s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
/* RESPONSIVE */
@media (max-width: 900px) { .sidebar { display: none; } }
@media (max-width: 600px) { .stats-row { grid-template-columns: 1fr 1fr; } .content { padding: 16px; } }
</style>
</head>
<body>
<!-- SIDEBAR -->
<aside class="sidebar">
<div class="sidebar-logo">🏇 <span>Turf</span> IA</div>
<div class="nav-section">Principal</div>
<a class="nav-item active" href="/dashboard"><span class="icon">📊</span> Dashboard</a>
<a class="nav-item" href="#races"><span class="icon">🏁</span> Courses du jour</a>
<a class="nav-item" href="#predictions"><span class="icon">🧠</span> Prédictions</a>
<a class="nav-item" id="nav-value-bets" href="#value-bets"><span class="icon">💎</span> Value Bets</a>
<div class="nav-section">Analyse</div>
<a class="nav-item" id="nav-history" href="#history"><span class="icon">📅</span> Historique</a>
<a class="nav-item" id="nav-export" href="#export"><span class="icon">📤</span> Export CSV</a>
<a class="nav-item" id="nav-api" href="/docs/api"><span class="icon"></span> API Docs</a>
<div class="nav-section">Compte</div>
<a class="nav-item" href="/account"><span class="icon">⚙️</span> Mon compte</a>
<a class="nav-item" href="#" id="logout-btn"><span class="icon">🚪</span> Déconnexion</a>
<div class="sidebar-bottom">
<div class="user-chip">
<div class="user-avatar" id="sidebar-avatar">?</div>
<div class="user-info">
<div class="user-name" id="sidebar-name">Chargement…</div>
<div class="user-plan" id="sidebar-plan"></div>
</div>
</div>
</div>
</aside>
<!-- MAIN -->
<div class="main">
<div class="topbar">
<div class="topbar-title">Tableau de bord</div>
<div class="topbar-right">
<span class="badge" id="plan-badge"></span>
<a href="/account?tab=upgrade" class="btn btn-upgrade" id="upgrade-btn" style="display:none">⭐ Upgrader</a>
</div>
</div>
<div class="content">
<!-- Upgrade banner (shown for free users) -->
<div class="upgrade-banner" id="upgrade-banner" style="display:none">
<p>🔒 Plan <strong>Free</strong> — Vous voyez un aperçu limité. Passez à <strong>Premium</strong> pour toutes les courses, value bets et alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-upgrade">Upgrader — 9,90€/mois</a>
</div>
<!-- Stats row -->
<div class="stats-row" id="stats-row">
<div class="stat-card">
<div class="stat-label">Courses analysées</div>
<div class="stat-value" id="stat-courses"></div>
<div class="stat-sub">aujourd'hui</div>
</div>
<div class="stat-card">
<div class="stat-label">Précision Top-3</div>
<div class="stat-value stat-up" id="stat-accuracy"></div>
<div class="stat-sub">30 derniers jours</div>
</div>
<div class="stat-card">
<div class="stat-label">Value bets du jour</div>
<div class="stat-value" id="stat-vb"></div>
<div class="stat-sub" id="stat-vb-sub"></div>
</div>
<div class="stat-card">
<div class="stat-label">Prochaine course</div>
<div class="stat-value stat-up" id="stat-next"></div>
<div class="stat-sub" id="stat-next-hip"></div>
</div>
</div>
<!-- Today's races -->
<div class="section-title">
🏁 Prédictions du jour
<small id="race-count-label">Chargement…</small>
</div>
<div id="races-container">
<div class="loader-row"><div class="spinner"></div> Chargement des prédictions…</div>
</div>
<!-- Locked section for free users -->
<div id="locked-section" style="display:none">
<div class="lock-wrap" style="position:relative; min-height:200px;">
<div style="filter:blur(5px)">
<div class="races-grid">
<div class="race-card"><div class="race-name">R3C5 - Quinté+</div><div class="race-meta">16 partants · Plat · 2400m</div></div>
<div class="race-card"><div class="race-name">R2C3 - Prix du Président</div><div class="race-meta">12 partants · Trot · 2100m</div></div>
<div class="race-card"><div class="race-name">R4C7 - Prix Deauville</div><div class="race-meta">10 partants · Galop · 1600m</div></div>
</div>
</div>
<div class="locked-overlay">
<div class="locked-overlay-msg">
<div style="font-size:2rem;margin-bottom:10px">🔒</div>
<h3>+120 courses cachées</h3>
<p style="color:var(--muted);font-size:.88rem;margin-bottom:14px">Passez à Premium pour débloquer toutes les courses, les value bets et les alertes Telegram.</p>
<a href="/account?tab=upgrade" class="btn btn-primary">Débloquer — 9,90€/mois</a>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="toast"></div>
<script>
const API = '/api/v1';
let currentUser = null;
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg; t.className = `show ${type}`;
setTimeout(() => t.className = '', 3500);
}
function getToken() {
return localStorage.getItem('turf_token');
}
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { logout(); return null; }
return res.json();
}
function logout() {
localStorage.removeItem('turf_token');
localStorage.removeItem('turf_user');
location.href = '/login';
}
document.getElementById('logout-btn').addEventListener('click', e => { e.preventDefault(); logout(); });
function setPlanUI(plan) {
const badge = document.getElementById('plan-badge');
const upgradeBtn = document.getElementById('upgrade-btn');
const upgradeBanner = document.getElementById('upgrade-banner');
const navExport = document.getElementById('nav-export');
const navApi = document.getElementById('nav-api');
badge.className = `badge badge-${plan}`;
badge.textContent = { free: 'Free', premium: 'Premium ⭐', pro: 'Pro 🚀' }[plan] || plan;
if (plan === 'free') {
upgradeBtn.style.display = '';
upgradeBanner.style.display = '';
document.getElementById('locked-section').style.display = '';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
document.getElementById('nav-value-bets').style.opacity = '.4';
} else if (plan === 'premium') {
upgradeBtn.style.display = '';
upgradeBtn.innerHTML = '🚀 Passer Pro';
navExport.style.opacity = '.4';
navApi.style.opacity = '.4';
}
}
function renderRaceCards(predictions, plan) {
const container = document.getElementById('races-container');
if (!predictions || predictions.length === 0) {
container.innerHTML = `<div class="empty-state"><div class="icon">🏇</div><h3>Aucune prédiction disponible</h3><p>Les prédictions d'aujourd'hui ne sont pas encore disponibles. Revenez plus tard.</p></div>`;
return;
}
// Group by race
const races = {};
predictions.forEach(p => {
const key = `${p.num_reunion}-${p.num_course}`;
if (!races[key]) races[key] = { label: p.race_label || `R${p.num_reunion}C${p.num_course}`, name: p.race_name || '', hippodrome: p.hippodrome || '', discipline: p.discipline || '', heure: p.heure || '', horses: [] };
races[key].horses.push(p);
});
const maxRaces = plan === 'free' ? 1 : 999;
const raceKeys = Object.keys(races).slice(0, maxRaces);
document.getElementById('race-count-label').textContent = `${raceKeys.length} course${raceKeys.length>1?'s':''} affichée${raceKeys.length>1?'s':''}`;
let html = '<div class="races-grid">';
raceKeys.forEach(key => {
const race = races[key];
const sorted = [...race.horses].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const vbCount = race.horses.filter(h => h.is_value_bet).length;
html += `<div class="race-card">
<div class="race-header">
<div>
<div class="race-name">${race.label}${race.name ? ' — ' + race.name : ''}</div>
<div class="race-meta">${race.hippodrome ? race.hippodrome + ' · ' : ''}${race.discipline || ''}</div>
</div>
<div class="race-time">${race.heure || ''}</div>
</div>
${vbCount > 0 ? `<span class="value-bet">💎 ${vbCount} value bet${vbCount>1?'s':''}</span>` : ''}
<div class="top3-row">
${sorted.map((h, i) => `<span class="horse-chip top${i+1}">${i===0?'🥇':i===1?'🥈':'🥉'} ${h.horse_name} (${h.odds ? h.odds.toFixed(1) : '—'})</span>`).join('')}
</div>
</div>`;
});
html += '</div>';
// Detailed table for first race
if (raceKeys.length > 0) {
const firstRace = races[raceKeys[0]];
const sorted = [...firstRace.horses].sort((a, b) => b.ml_score - a.ml_score);
html += `<div class="section-title" style="margin-top:8px">📋 Détail — ${firstRace.label}${firstRace.name ? ' · ' + firstRace.name : ''}</div>
<div class="race-table-wrap">
<table>
<thead><tr>
<th>#</th><th>Cheval</th><th>Cote</th><th>Prob Top-1</th><th>Prob Top-3</th><th>Score IA</th><th>Value</th>
</tr></thead>
<tbody>`;
sorted.forEach((h, i) => {
const prob1 = h.prob_top1 ? (h.prob_top1 * 100).toFixed(1) : '—';
const prob3 = h.prob_top3 ? (h.prob_top3 * 100).toFixed(1) : '—';
const score = h.ml_score ? h.ml_score.toFixed(2) : '—';
html += `<tr>
<td class="${i===0?'rank-1':i===1?'rank-2':i===2?'rank-3':''}">${i+1}</td>
<td class="horse-name">${h.horse_name || '—'}</td>
<td class="cote">${h.odds ? h.odds.toFixed(1) : '—'}</td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob1}%"></div></div>${prob1}%</div></td>
<td><div class="prob-bar-wrap"><div class="prob-bar"><div class="prob-bar-fill" style="width:${prob3}%;background:var(--blue)"></div></div>${prob3}%</div></td>
<td>${score}</td>
<td>${h.is_value_bet ? '<span class="value-bet">💎 VB</span>' : '—'}</td>
</tr>`;
});
html += '</tbody></table></div>';
}
container.innerHTML = html;
}
async function loadDashboard() {
const token = getToken();
if (!token) { location.href = '/login'; return; }
// Try loading from localStorage first
try {
const saved = JSON.parse(localStorage.getItem('turf_user') || '{}');
if (saved.firstname) {
document.getElementById('sidebar-name').textContent = `${saved.firstname} ${saved.lastname || ''}`;
document.getElementById('sidebar-avatar').textContent = saved.firstname[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = (saved.plan || 'free').toUpperCase();
setPlanUI(saved.plan || 'free');
currentUser = saved;
}
} catch(_) {}
// Fetch fresh user profile
const profile = await fetchJson(`${API}/auth/me`);
if (!profile) return;
currentUser = profile.user || profile;
const plan = currentUser.plan || 'free';
document.getElementById('sidebar-name').textContent = `${currentUser.firstname || ''} ${currentUser.lastname || ''}`.trim() || currentUser.email;
document.getElementById('sidebar-avatar').textContent = (currentUser.firstname || currentUser.email || '?')[0].toUpperCase();
document.getElementById('sidebar-plan').textContent = plan.toUpperCase();
localStorage.setItem('turf_user', JSON.stringify(currentUser));
setPlanUI(plan);
// Fetch stats
const statsData = await fetchJson(`${API}/stats/summary`);
if (statsData) {
document.getElementById('stat-courses').textContent = statsData.courses_today || '—';
document.getElementById('stat-accuracy').textContent = statsData.accuracy_top3 ? statsData.accuracy_top3 + '%' : '—';
document.getElementById('stat-vb').textContent = statsData.value_bets_today ?? '—';
document.getElementById('stat-vb-sub').textContent = plan === 'free' ? '(limité)' : 'identifiés';
if (statsData.next_race_time) {
document.getElementById('stat-next').textContent = statsData.next_race_time;
document.getElementById('stat-next-hip').textContent = statsData.next_race_hippodrome || '';
}
}
// Fetch predictions
const predsData = await fetchJson(`${API}/predictions/today`);
if (predsData && predsData.predictions) {
renderRaceCards(predsData.predictions, plan);
} else {
document.getElementById('races-container').innerHTML = '<div class="empty-state"><div class="icon">🏇</div><h3>Prédictions non disponibles</h3><p>L\'API de prédictions est en cours de démarrage. Réessayez dans quelques instants.</p></div>';
}
}
loadDashboard();
</script>
</body>
</html>

250
docker-compose.yml Normal file
View File

@@ -0,0 +1,250 @@
version: "3.9"
# ============================================================
# H3R7Tech Turf SaaS — Docker Compose
# Services: app (x4) + postgres + redis + prometheus + grafana + nginx
# ============================================================
x-app-common: &app-common
build:
context: .
dockerfile: Dockerfile
target: runner
restart: unless-stopped
env_file:
- .env
depends_on:
postgres:
condition: service_healthy
networks:
- turf-net
volumes:
- ml-models:/app/data/models
- app-logs:/app/logs
services:
# ----------------------------------------------------------
# PostgreSQL — primary database
# ----------------------------------------------------------
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-turf_saas}
POSTGRES_USER: ${POSTGRES_USER:-turf}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes:
- postgres-data:/var/lib/postgresql/data
- ./infra/postgres/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-turf} -d ${POSTGRES_DB:-turf_saas}"]
interval: 10s
timeout: 5s
retries: 5
start_period: 30s
networks:
- turf-net
ports:
- "127.0.0.1:5432:5432"
# ----------------------------------------------------------
# Redis — caching & session store
# ----------------------------------------------------------
redis:
image: redis:7-alpine
restart: unless-stopped
command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD}
volumes:
- redis-data:/data
healthcheck:
test: ["CMD", "redis-cli", "--pass", "${REDIS_PASSWORD}", "ping"]
interval: 10s
timeout: 5s
retries: 3
networks:
- turf-net
ports:
- "127.0.0.1:6379:6379"
# ----------------------------------------------------------
# Combined API — main predictions + ideas API (port 8790)
# ----------------------------------------------------------
combined-api:
<<: *app-common
container_name: turf-combined-api
command: gunicorn --bind 0.0.0.0:8790 --workers 2 --timeout 120 --access-logfile - --error-logfile - combined_api:app
ports:
- "127.0.0.1:8790:8790"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8790/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
environment:
PORT: 8790
SERVICE_NAME: combined-api
# ----------------------------------------------------------
# Dashboard API — analytics & ML scoring (port 8791)
# ----------------------------------------------------------
dashboard-api:
<<: *app-common
container_name: turf-dashboard-api
command: gunicorn --bind 0.0.0.0:8791 --workers 2 --timeout 120 --access-logfile - --error-logfile - dashboard_api:app
ports:
- "127.0.0.1:8791:8791"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8791/health"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
environment:
PORT: 8791
SERVICE_NAME: dashboard-api
# ----------------------------------------------------------
# Portal Server — frontend portal (port 8792)
# ----------------------------------------------------------
portal:
<<: *app-common
container_name: turf-portal
command: gunicorn --bind 0.0.0.0:8792 --workers 2 --timeout 60 --access-logfile - --error-logfile - portal_server:app
ports:
- "127.0.0.1:8792:8792"
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:8792/"]
interval: 30s
timeout: 10s
retries: 3
start_period: 30s
environment:
PORT: 8792
SERVICE_NAME: portal
# ----------------------------------------------------------
# Scheduler — background jobs (no external port)
# ----------------------------------------------------------
scheduler:
<<: *app-common
container_name: turf-scheduler
command: python turf_scheduler.py
environment:
SERVICE_NAME: scheduler
# ----------------------------------------------------------
# Prometheus — metrics scraping
# ----------------------------------------------------------
prometheus:
image: prom/prometheus:v2.53.4
restart: unless-stopped
command:
- "--config.file=/etc/prometheus/prometheus.yml"
- "--storage.tsdb.path=/prometheus"
- "--storage.tsdb.retention.time=30d"
- "--web.enable-lifecycle"
volumes:
- ./infra/prometheus/prometheus.yml:/etc/prometheus/prometheus.yml:ro
- ./infra/prometheus/alerts.yml:/etc/prometheus/alerts.yml:ro
- prometheus-data:/prometheus
ports:
- "127.0.0.1:9090:9090"
networks:
- turf-net
healthcheck:
test: ["CMD", "wget", "-q", "--tries=1", "--spider", "http://localhost:9090/-/healthy"]
interval: 30s
timeout: 10s
retries: 3
# ----------------------------------------------------------
# Grafana — dashboards
# ----------------------------------------------------------
grafana:
image: grafana/grafana:11.5.2
restart: unless-stopped
environment:
GF_SECURITY_ADMIN_USER: ${GRAFANA_ADMIN_USER:-admin}
GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD}
GF_USERS_ALLOW_SIGN_UP: "false"
GF_SERVER_DOMAIN: ${DOMAIN:-localhost}
GF_SERVER_ROOT_URL: https://${DOMAIN:-localhost}/grafana/
GF_SERVER_SERVE_FROM_SUB_PATH: "true"
volumes:
- grafana-data:/var/lib/grafana
- ./infra/grafana/provisioning:/etc/grafana/provisioning:ro
- ./infra/grafana/dashboards:/var/lib/grafana/dashboards:ro
ports:
- "127.0.0.1:3000:3000"
networks:
- turf-net
depends_on:
- prometheus
# ----------------------------------------------------------
# Nginx — reverse proxy + TLS termination
# ----------------------------------------------------------
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "80:80"
- "443:443"
volumes:
- ./infra/nginx/nginx.conf:/etc/nginx/nginx.conf:ro
- ./infra/nginx/conf.d:/etc/nginx/conf.d:ro
- certbot-www:/var/www/certbot:ro
- certbot-certs:/etc/letsencrypt:ro
networks:
- turf-net
depends_on:
- combined-api
- dashboard-api
- portal
healthcheck:
test: ["CMD", "nginx", "-t"]
interval: 60s
timeout: 10s
retries: 3
# ----------------------------------------------------------
# Certbot — Let's Encrypt TLS certificate renewal
# ----------------------------------------------------------
certbot:
image: certbot/certbot:latest
restart: "no"
volumes:
- certbot-www:/var/www/certbot
- certbot-certs:/etc/letsencrypt
command: certonly --webroot --webroot-path=/var/www/certbot --email ${ADMIN_EMAIL} --agree-tos --no-eff-email -d ${DOMAIN}
networks:
- turf-net
# ============================================================
# Named volumes — persistent storage
# ============================================================
volumes:
postgres-data:
driver: local
redis-data:
driver: local
ml-models:
driver: local
app-logs:
driver: local
prometheus-data:
driver: local
grafana-data:
driver: local
certbot-www:
driver: local
certbot-certs:
driver: local
# ============================================================
# Network
# ============================================================
networks:
turf-net:
driver: bridge

View File

@@ -0,0 +1,174 @@
{
"title": "Turf SaaS — Overview",
"uid": "turf-saas-overview",
"schemaVersion": 38,
"version": 1,
"refresh": "30s",
"time": { "from": "now-6h", "to": "now" },
"tags": ["turf-saas"],
"panels": [
{
"id": 1,
"type": "stat",
"title": "Request Rate (req/s)",
"gridPos": { "h": 4, "w": 6, "x": 0, "y": 0 },
"targets": [
{
"datasource": "Prometheus",
"expr": "sum(rate(http_requests_total[5m]))",
"legendFormat": "req/s"
}
],
"options": { "colorMode": "background", "graphMode": "area" }
},
{
"id": 2,
"type": "stat",
"title": "Error Rate (5xx)",
"gridPos": { "h": 4, "w": 6, "x": 6, "y": 0 },
"targets": [
{
"datasource": "Prometheus",
"expr": "sum(rate(http_requests_total{status_code=~\"5..\"}[5m])) / sum(rate(http_requests_total[5m])) * 100",
"legendFormat": "error %"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 0.5 },
{ "color": "red", "value": 1 }
]
}
}
}
},
{
"id": 3,
"type": "stat",
"title": "p95 Latency",
"gridPos": { "h": 4, "w": 6, "x": 12, "y": 0 },
"targets": [
{
"datasource": "Prometheus",
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
}
],
"fieldConfig": {
"defaults": {
"unit": "s",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "green", "value": null },
{ "color": "yellow", "value": 1 },
{ "color": "red", "value": 2 }
]
}
}
}
},
{
"id": 4,
"type": "stat",
"title": "ML Top-1 Accuracy",
"gridPos": { "h": 4, "w": 6, "x": 18, "y": 0 },
"targets": [
{
"datasource": "Prometheus",
"expr": "ml_prediction_accuracy_ratio{accuracy_type=\"top1\"} * 100",
"legendFormat": "top-1 %"
}
],
"fieldConfig": {
"defaults": {
"unit": "percent",
"thresholds": {
"mode": "absolute",
"steps": [
{ "color": "red", "value": null },
{ "color": "yellow", "value": 25 },
{ "color": "green", "value": 35 }
]
}
}
}
},
{
"id": 5,
"type": "timeseries",
"title": "HTTP Requests by Service",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 4 },
"targets": [
{
"datasource": "Prometheus",
"expr": "sum(rate(http_requests_total[5m])) by (service)",
"legendFormat": "{{ service }}"
}
],
"fieldConfig": {
"defaults": { "unit": "reqps" }
}
},
{
"id": 6,
"type": "timeseries",
"title": "Request Duration p50/p95/p99",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 4 },
"targets": [
{
"datasource": "Prometheus",
"expr": "histogram_quantile(0.50, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p50"
},
{
"datasource": "Prometheus",
"expr": "histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p95"
},
{
"datasource": "Prometheus",
"expr": "histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket[5m])) by (le))",
"legendFormat": "p99"
}
],
"fieldConfig": {
"defaults": { "unit": "s" }
}
},
{
"id": 7,
"type": "timeseries",
"title": "ML Predictions per Hour",
"gridPos": { "h": 8, "w": 12, "x": 0, "y": 12 },
"targets": [
{
"datasource": "Prometheus",
"expr": "sum(increase(ml_predictions_total[1h])) by (model_type)",
"legendFormat": "{{ model_type }}"
}
]
},
{
"id": 8,
"type": "timeseries",
"title": "DB Query Duration",
"gridPos": { "h": 8, "w": 12, "x": 12, "y": 12 },
"targets": [
{
"datasource": "Prometheus",
"expr": "histogram_quantile(0.95, sum(rate(db_query_duration_seconds_bucket[5m])) by (le, operation))",
"legendFormat": "{{ operation }} p95"
}
],
"fieldConfig": {
"defaults": { "unit": "s" }
}
}
]
}

View File

@@ -0,0 +1,11 @@
apiVersion: 1
providers:
- name: turf-saas-dashboards
type: file
disableDeletion: false
updateIntervalSeconds: 30
allowUiUpdates: true
options:
path: /var/lib/grafana/dashboards
foldersFromFilesStructure: true

View File

@@ -0,0 +1,13 @@
apiVersion: 1
datasources:
- name: Prometheus
type: prometheus
uid: prometheus-main
access: proxy
url: http://prometheus:9090
isDefault: true
editable: false
jsonData:
httpMethod: POST
timeInterval: "15s"

View File

@@ -0,0 +1,157 @@
# ============================================================
# Nginx Virtual Host — Turf SaaS
# ============================================================
# Upstream service pools
upstream combined_api {
server combined-api:8790;
keepalive 32;
}
upstream dashboard_api {
server dashboard-api:8791;
keepalive 16;
}
upstream portal {
server portal:8792;
keepalive 16;
}
upstream grafana {
server grafana:3000;
keepalive 4;
}
# ----------------------------------------------------------
# HTTP → HTTPS redirect
# ----------------------------------------------------------
server {
listen 80;
server_name _;
# Let's Encrypt ACME challenge
location /.well-known/acme-challenge/ {
root /var/www/certbot;
}
location / {
return 301 https://$host$request_uri;
}
}
# ----------------------------------------------------------
# HTTPS main server
# ----------------------------------------------------------
server {
listen 443 ssl;
http2 on;
server_name ${DOMAIN};
# TLS configuration
ssl_certificate /etc/letsencrypt/live/${DOMAIN}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/${DOMAIN}/privkey.pem;
ssl_session_cache shared:SSL:10m;
ssl_session_timeout 10m;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384;
ssl_prefer_server_ciphers on;
ssl_stapling on;
ssl_stapling_verify on;
# Security headers
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Frame-Options DENY always;
add_header X-Content-Type-Options nosniff always;
add_header X-XSS-Protection "1; mode=block" always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data:;" always;
# Limits
client_max_body_size 10M;
limit_conn conn_limit 20;
# ----------------------------------------------------------
# Portal (root)
# ----------------------------------------------------------
location / {
limit_req zone=global burst=50 nodelay;
proxy_pass http://portal;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 60s;
}
# ----------------------------------------------------------
# Combined API
# ----------------------------------------------------------
location /api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://combined_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 120s;
}
# ----------------------------------------------------------
# Dashboard API
# ----------------------------------------------------------
location /dashboard-api/ {
limit_req zone=api burst=20 nodelay;
proxy_pass http://dashboard_api/;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 120s;
}
# ----------------------------------------------------------
# Grafana (restricted to internal/admin)
# ----------------------------------------------------------
location /grafana/ {
# Restrict to admin IPs in production
# allow 10.0.0.0/8;
# deny all;
proxy_pass http://grafana;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
}
# ----------------------------------------------------------
# Health check (no rate limiting)
# ----------------------------------------------------------
location /health {
proxy_pass http://combined_api/health;
proxy_http_version 1.1;
access_log off;
}
# Block common attack vectors
location ~ /\. {
deny all;
access_log off;
log_not_found off;
}
location ~* \.(env|git|bak|sql|log)$ {
deny all;
access_log off;
log_not_found off;
}
}

65
infra/nginx/nginx.conf Normal file
View File

@@ -0,0 +1,65 @@
# ============================================================
# Nginx — Main config
# ============================================================
user nginx;
worker_processes auto;
error_log /var/log/nginx/error.log warn;
pid /var/run/nginx.pid;
events {
worker_connections 1024;
use epoll;
multi_accept on;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
# Logging
log_format json_combined escape=json
'{"time":"$time_iso8601",'
'"remote_addr":"$remote_addr",'
'"method":"$request_method",'
'"uri":"$request_uri",'
'"status":$status,'
'"body_bytes":$body_bytes_sent,'
'"duration":$request_time,'
'"referrer":"$http_referer",'
'"user_agent":"$http_user_agent",'
'"x_forwarded_for":"$http_x_forwarded_for"}';
access_log /var/log/nginx/access.log json_combined;
# Performance
sendfile on;
tcp_nopush on;
tcp_nodelay on;
keepalive_timeout 65;
types_hash_max_size 2048;
server_tokens off;
# Gzip
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_proxied any;
gzip_comp_level 5;
gzip_types
text/plain
text/css
text/javascript
application/javascript
application/json
application/xml
image/svg+xml;
# Rate limiting zones
limit_req_zone $binary_remote_addr zone=api:10m rate=30r/m;
limit_req_zone $binary_remote_addr zone=global:20m rate=100r/m;
limit_conn_zone $binary_remote_addr zone=conn_limit:10m;
# Include virtual hosts
include /etc/nginx/conf.d/*.conf;
}

12
infra/postgres/init.sql Normal file
View File

@@ -0,0 +1,12 @@
-- ============================================================
-- PostgreSQL init script for Turf SaaS
-- Runs on first container start (docker-entrypoint-initdb.d)
-- ============================================================
-- Create extensions
CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
CREATE EXTENSION IF NOT EXISTS "pg_trgm";
-- Grant privileges to the app user
GRANT ALL PRIVILEGES ON DATABASE turf_saas TO turf;
GRANT ALL ON SCHEMA public TO turf;

109
infra/prometheus/alerts.yml Normal file
View File

@@ -0,0 +1,109 @@
# ============================================================
# Prometheus Alert Rules — Turf SaaS
# ============================================================
groups:
# ----------------------------------------------------------
# HTTP / API Alerts
# ----------------------------------------------------------
- name: http_alerts
rules:
- alert: HighErrorRate
expr: |
sum(rate(http_requests_total{status_code=~"5.."}[5m])) by (service)
/
sum(rate(http_requests_total[5m])) by (service)
> 0.01
for: 2m
labels:
severity: critical
annotations:
summary: "High 5xx error rate on {{ $labels.service }}"
description: "Error rate is {{ $value | humanizePercentage }} (threshold: 1%)"
- alert: HighLatency
expr: |
histogram_quantile(0.95, sum(rate(http_request_duration_seconds_bucket[5m])) by (le, service))
> 2
for: 5m
labels:
severity: warning
annotations:
summary: "High p95 latency on {{ $labels.service }}"
description: "p95 latency is {{ $value | humanizeDuration }} (threshold: 2s)"
- alert: ServiceDown
expr: up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "Service {{ $labels.job }} is down"
description: "{{ $labels.instance }} has been unreachable for >1 minute"
# ----------------------------------------------------------
# Database Alerts
# ----------------------------------------------------------
- name: database_alerts
rules:
- alert: PostgresDown
expr: pg_up == 0
for: 1m
labels:
severity: critical
annotations:
summary: "PostgreSQL is down"
description: "Cannot connect to PostgreSQL database"
- alert: PostgresDiskUsageHigh
expr: |
(pg_database_size_bytes / (1024 * 1024 * 1024)) > 10
for: 5m
labels:
severity: warning
annotations:
summary: "PostgreSQL database size > 10GB"
description: "Database {{ $labels.datname }} is {{ $value | humanize }}GB"
- alert: DiskSpaceHigh
expr: |
(node_filesystem_size_bytes - node_filesystem_free_bytes) / node_filesystem_size_bytes * 100
> 80
for: 5m
labels:
severity: warning
annotations:
summary: "Disk usage > 80% on {{ $labels.instance }}"
description: "{{ $labels.mountpoint }} is at {{ $value | humanizePercentage }}"
# ----------------------------------------------------------
# ML Prediction Alerts
# ----------------------------------------------------------
- name: ml_alerts
rules:
- alert: MLAccuracyDegraded
expr: ml_prediction_accuracy_ratio{accuracy_type="top1"} < 0.30
for: 60m
labels:
severity: warning
annotations:
summary: "ML top-1 accuracy below 30%"
description: "Current accuracy: {{ $value | humanizePercentage }}"
- alert: MLPredictionDriftHigh
expr: ml_prediction_drift_score > 0.5
for: 30m
labels:
severity: warning
annotations:
summary: "ML feature drift detected"
description: "Drift score for {{ $labels.feature_group }}: {{ $value }}"
- alert: NoPredictionsGenerated
expr: increase(ml_predictions_total[1h]) == 0
for: 2h
labels:
severity: warning
annotations:
summary: "No ML predictions generated in the last 2 hours"
description: "Check if the scheduler is running and PMU data is being scraped"

View File

@@ -0,0 +1,68 @@
# ============================================================
# Prometheus Configuration — Turf SaaS
# ============================================================
global:
scrape_interval: 15s
evaluation_interval: 15s
external_labels:
project: turf-saas
env: production
# Alertmanager — wire up when available
alerting:
alertmanagers:
- static_configs:
- targets: []
# Load alert rules
rule_files:
- "alerts.yml"
# ============================================================
# Scrape targets
# ============================================================
scrape_configs:
# Prometheus self-monitoring
- job_name: prometheus
static_configs:
- targets: [localhost:9090]
# Combined API
- job_name: combined-api
static_configs:
- targets: [combined-api:8790]
metrics_path: /metrics
scrape_interval: 15s
# Dashboard API
- job_name: dashboard-api
static_configs:
- targets: [dashboard-api:8791]
metrics_path: /metrics
scrape_interval: 15s
# Portal
- job_name: portal
static_configs:
- targets: [portal:8792]
metrics_path: /metrics
scrape_interval: 30s
# PostgreSQL exporter (if deployed)
- job_name: postgres
static_configs:
- targets: [postgres-exporter:9187]
scrape_interval: 30s
# Redis exporter (if deployed)
- job_name: redis
static_configs:
- targets: [redis-exporter:9121]
scrape_interval: 30s
# Node exporter (host metrics)
- job_name: node
static_configs:
- targets: [host.docker.internal:9100]
scrape_interval: 30s

45
infra/scripts/backup_db.sh Executable file
View File

@@ -0,0 +1,45 @@
#!/bin/bash
# ============================================================
# Automated PostgreSQL Backup Script
# Run daily via cron: 0 2 * * * /opt/turf-saas/infra/scripts/backup_db.sh
# ============================================================
set -euo pipefail
BACKUP_DIR="${BACKUP_DIR:-/opt/backups/turf-saas}"
KEEP_DAYS="${KEEP_DAYS:-30}"
DB_NAME="${POSTGRES_DB:-turf_saas}"
DB_USER="${POSTGRES_USER:-turf}"
DB_HOST="${POSTGRES_HOST:-postgres}"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="${BACKUP_DIR}/turf_saas_${TIMESTAMP}.sql.gz"
echo "[$(date -Iseconds)] Starting backup: ${BACKUP_FILE}"
# Ensure backup directory exists
mkdir -p "${BACKUP_DIR}"
# Perform backup
PGPASSWORD="${POSTGRES_PASSWORD}" pg_dump \
-h "${DB_HOST}" \
-U "${DB_USER}" \
-d "${DB_NAME}" \
--no-owner \
--no-acl \
| gzip > "${BACKUP_FILE}"
SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1)
echo "[$(date -Iseconds)] Backup complete: ${BACKUP_FILE} (${SIZE})"
# Remove backups older than KEEP_DAYS
find "${BACKUP_DIR}" -name "turf_saas_*.sql.gz" -mtime "+${KEEP_DAYS}" -delete
echo "[$(date -Iseconds)] Old backups cleaned (kept last ${KEEP_DAYS} days)"
# Optional: notify on completion
if [ -n "${TELEGRAM_BOT_TOKEN:-}" ] && [ -n "${TELEGRAM_CHAT_ID:-}" ]; then
curl -s -X POST \
"https://api.telegram.org/bot${TELEGRAM_BOT_TOKEN}/sendMessage" \
-d chat_id="${TELEGRAM_CHAT_ID}" \
-d text="✅ DB Backup OK: turf_saas ${TIMESTAMP} (${SIZE})" \
> /dev/null || true
fi

467
landing.html Normal file
View File

@@ -0,0 +1,467 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Turf IA — Prédictions PMU par Intelligence Artificielle</title>
<meta name="description" content="Boostez vos paris PMU avec nos prédictions IA. Analyse XGBoost, value bets, alertes Telegram. Essai gratuit.">
<style>
/* ===== RESET & BASE ===== */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853;
--green-d: #009624;
--blue: #1565c0;
--blue-l: #1e88e5;
--gold: #ffd600;
--dark: #0d1117;
--dark2: #161b22;
--dark3: #21262d;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
--radius: 12px;
}
html { scroll-behavior: smooth; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--dark);
color: var(--text);
line-height: 1.6;
overflow-x: hidden;
}
a { color: inherit; text-decoration: none; }
img { max-width: 100%; }
/* ===== NAV ===== */
nav {
position: sticky; top: 0; z-index: 100;
display: flex; align-items: center; justify-content: space-between;
padding: 16px 5%;
background: rgba(13,17,23,0.95);
backdrop-filter: blur(10px);
border-bottom: 1px solid var(--border);
}
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.2rem; }
.nav-logo .badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 20px; font-size: .75rem; }
.nav-links { display: flex; align-items: center; gap: 28px; }
.nav-links a { color: var(--muted); font-size: .95rem; transition: color .2s; }
.nav-links a:hover { color: var(--text); }
.nav-cta { display: flex; gap: 10px; }
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-ghost:hover { border-color: var(--muted); }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); transform: translateY(-1px); }
.btn-lg { padding: 14px 32px; font-size: 1.05rem; border-radius: 10px; }
/* ===== HERO ===== */
.hero {
min-height: 90vh;
display: flex; flex-direction: column; align-items: center; justify-content: center;
text-align: center;
padding: 80px 5% 60px;
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(0,200,83,.08) 0%, transparent 70%);
position: relative;
}
.hero-eyebrow {
display: inline-flex; align-items: center; gap: 8px;
background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3);
padding: 6px 16px; border-radius: 20px;
font-size: .85rem; color: var(--green); margin-bottom: 24px;
}
.hero h1 { font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 800; line-height: 1.15; max-width: 780px; margin-bottom: 20px; }
.hero h1 span { color: var(--green); }
.hero p { font-size: 1.15rem; color: var(--muted); max-width: 580px; margin-bottom: 36px; }
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; }
.hero-stats {
display: flex; gap: 40px; margin-top: 60px; flex-wrap: wrap; justify-content: center;
}
.stat { text-align: center; }
.stat strong { display: block; font-size: 2rem; font-weight: 800; color: var(--green); }
.stat span { font-size: .85rem; color: var(--muted); }
/* ===== SECTIONS ===== */
section { padding: 80px 5%; }
.section-label { color: var(--green); font-size: .85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
h2 { font-size: clamp(1.6rem, 3vw, 2.4rem); font-weight: 800; margin-bottom: 16px; }
.subtitle { color: var(--muted); font-size: 1.05rem; max-width: 560px; margin: 0 auto 50px; text-align: center; }
.section-center { text-align: center; }
/* ===== FEATURES ===== */
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; max-width: 1100px; margin: 0 auto; }
.feature-card {
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 28px; transition: border-color .2s, transform .2s;
}
.feature-card:hover { border-color: var(--green); transform: translateY(-3px); }
.feature-icon { font-size: 2rem; margin-bottom: 14px; }
.feature-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
.feature-card p { color: var(--muted); font-size: .9rem; line-height: 1.6; }
/* ===== PRICING ===== */
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
.plan-card {
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
padding: 32px; position: relative; transition: transform .2s;
}
.plan-card:hover { transform: translateY(-4px); }
.plan-card.popular { border-color: var(--green); }
.popular-badge {
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
background: var(--green); color: #000; padding: 3px 16px; border-radius: 20px;
font-size: .75rem; font-weight: 700;
}
.plan-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
.plan-price { font-size: 2.6rem; font-weight: 800; margin: 12px 0 4px; }
.plan-price sup { font-size: 1.2rem; vertical-align: top; margin-top: 10px; }
.plan-price span { font-size: 1rem; font-weight: 400; color: var(--muted); }
.plan-desc { color: var(--muted); font-size: .88rem; margin-bottom: 20px; }
.plan-features { list-style: none; margin-bottom: 28px; }
.plan-features li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; font-size: .9rem; }
.plan-features li::before { content: "✓"; color: var(--green); font-weight: 700; flex-shrink: 0; }
.plan-features li.disabled { color: var(--muted); }
.plan-features li.disabled::before { content: "×"; color: var(--border); }
.btn-plan { width: 100%; text-align: center; justify-content: center; }
/* ===== HOW IT WORKS ===== */
.steps { display: flex; flex-direction: column; gap: 0; max-width: 700px; margin: 0 auto; }
.step { display: flex; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--border); }
.step:last-child { border-bottom: none; }
.step-num { width: 44px; height: 44px; border-radius: 50%; background: rgba(0,200,83,.1); border: 2px solid var(--green); display: flex; align-items: center; justify-content: center; font-weight: 800; color: var(--green); flex-shrink: 0; }
.step-body h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 6px; }
.step-body p { color: var(--muted); font-size: .9rem; }
/* ===== FAQ ===== */
.faq-list { max-width: 720px; margin: 0 auto; }
details { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 10px; overflow: hidden; }
details[open] { border-color: var(--green); }
summary {
padding: 18px 22px; font-weight: 600; cursor: pointer; list-style: none;
display: flex; justify-content: space-between; align-items: center;
}
summary::after { content: "+"; color: var(--green); font-size: 1.3rem; }
details[open] summary::after { content: ""; }
.faq-answer { padding: 0 22px 18px; color: var(--muted); font-size: .93rem; line-height: 1.7; }
/* ===== CTA BANNER ===== */
.cta-banner {
background: linear-gradient(135deg, rgba(0,200,83,.12) 0%, rgba(21,101,192,.12) 100%);
border: 1px solid var(--border); border-radius: 16px;
padding: 60px; text-align: center; max-width: 820px; margin: 0 auto;
}
.cta-banner h2 { margin-bottom: 12px; }
.cta-banner p { color: var(--muted); margin-bottom: 28px; }
/* ===== FOOTER ===== */
footer {
border-top: 1px solid var(--border);
padding: 40px 5%;
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px;
}
.footer-brand p { color: var(--muted); font-size: .88rem; margin-top: 10px; max-width: 240px; }
.footer-col h4 { font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 14px; color: var(--muted); }
.footer-col a { display: block; color: var(--muted); font-size: .88rem; margin-bottom: 8px; transition: color .2s; }
.footer-col a:hover { color: var(--text); }
.footer-bottom { border-top: 1px solid var(--border); padding: 20px 5%; text-align: center; color: var(--muted); font-size: .82rem; }
/* ===== TOAST ===== */
#toast {
position: fixed; bottom: 24px; right: 24px; z-index: 999;
padding: 14px 22px; border-radius: 10px; font-size: .9rem; font-weight: 600;
transform: translateY(80px); opacity: 0; transition: all .3s;
pointer-events: none;
}
#toast.show { transform: translateY(0); opacity: 1; }
#toast.success { background: var(--green); color: #000; }
#toast.error { background: #f85149; color: #fff; }
/* ===== RESPONSIVE ===== */
@media (max-width: 768px) {
.nav-links { display: none; }
footer { grid-template-columns: 1fr 1fr; }
.hero-stats { gap: 24px; }
.cta-banner { padding: 36px 24px; }
}
@media (max-width: 480px) {
footer { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<!-- NAV -->
<nav>
<div class="nav-logo">
🏇 Turf IA
<span class="badge">BETA</span>
</div>
<div class="nav-links">
<a href="#features">Fonctionnalités</a>
<a href="#pricing">Tarifs</a>
<a href="#how">Comment ça marche</a>
<a href="#faq">FAQ</a>
</div>
<div class="nav-cta">
<a href="/login" class="btn btn-ghost">Connexion</a>
<a href="/register" class="btn btn-primary">S'inscrire gratuitement</a>
</div>
</nav>
<!-- HERO -->
<section class="hero">
<div class="hero-eyebrow">
🤖 Intelligence Artificielle · XGBoost · Données PMU temps réel
</div>
<h1>Pariez plus <span>intelligemment</span><br>grâce à l'IA</h1>
<p>Nos modèles XGBoost analysent chaque course PMU en temps réel — cotes, historique, jockeys, météo — pour vous donner les meilleures prédictions du marché.</p>
<div class="hero-actions">
<a href="/register" class="btn btn-primary btn-lg">Commencer gratuitement</a>
<a href="#how" class="btn btn-ghost btn-lg">Voir comment ça marche</a>
</div>
<div class="hero-stats">
<div class="stat"><strong>+73%</strong><span>précision Top-3</span></div>
<div class="stat"><strong>150+</strong><span>courses analysées/jour</span></div>
<div class="stat"><strong>2.4s</strong><span>temps de réponse moyen</span></div>
<div class="stat"><strong>3 plans</strong><span>adaptés à chaque profil</span></div>
</div>
</section>
<!-- FEATURES -->
<section id="features">
<div class="section-center">
<div class="section-label">Fonctionnalités</div>
<h2>Tout ce dont vous avez besoin pour gagner</h2>
<p class="subtitle">Un moteur IA complet, des alertes instantanées, et des analyses détaillées pour chaque parieur.</p>
</div>
<div class="features-grid">
<div class="feature-card">
<div class="feature-icon">🧠</div>
<h3>Prédictions XGBoost</h3>
<p>Modèle entraîné sur des milliers de courses PMU. Probabilités Top-1 et Top-3 pour chaque partant, mis à jour en continu.</p>
</div>
<div class="feature-card">
<div class="feature-icon">💎</div>
<h3>Value Bets identifiés</h3>
<p>Détection automatique des cotes sous-évaluées par le marché. Seulement les paris où l'espérance mathématique est positive.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📱</div>
<h3>Alertes Telegram</h3>
<p>Recevez les meilleures opportunités directement sur votre téléphone, avant le départ, avec toutes les infos clés.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📊</div>
<h3>Dashboard temps réel</h3>
<p>Tableau de bord complet : courses du jour, historique de performance, ROI, statistiques par hippodrome et discipline.</p>
</div>
<div class="feature-card">
<div class="feature-icon">🌤️</div>
<h3>Analyse météo & terrain</h3>
<p>Impact des conditions météo et de l'état du terrain intégré dans chaque prédiction pour une précision maximale.</p>
</div>
<div class="feature-card">
<div class="feature-icon">📤</div>
<h3>Export CSV & API</h3>
<p>Exportez vos données, intégrez nos prédictions dans vos propres outils via notre API documentée (plan Pro).</p>
</div>
</div>
</section>
<!-- HOW IT WORKS -->
<section id="how" style="background: var(--dark2); margin: 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
<div class="section-center">
<div class="section-label">Comment ça marche</div>
<h2>En 3 étapes, prêt à parier</h2>
<p class="subtitle">De l'inscription à votre première prédiction en moins de 2 minutes.</p>
</div>
<div class="steps">
<div class="step">
<div class="step-num">1</div>
<div class="step-body">
<h3>Créez votre compte gratuitement</h3>
<p>Inscription en 30 secondes, sans carte bancaire. Accès immédiat au plan Free avec un aperçu des prédictions du jour.</p>
</div>
</div>
<div class="step">
<div class="step-num">2</div>
<div class="step-body">
<h3>Choisissez votre plan</h3>
<p>Free pour découvrir, Premium (9,90€/mois) pour toutes les courses et alertes, Pro (24,90€/mois) pour l'API et les exports.</p>
</div>
</div>
<div class="step">
<div class="step-num">3</div>
<div class="step-body">
<h3>Recevez vos premières prédictions</h3>
<p>Notre IA analyse les 150+ courses du jour. Accédez aux Top-3, value bets et probabilités depuis votre dashboard ou Telegram.</p>
</div>
</div>
</div>
</section>
<!-- PRICING -->
<section id="pricing">
<div class="section-center">
<div class="section-label">Tarifs</div>
<h2>Des prix transparents</h2>
<p class="subtitle">Commencez gratuitement. Passez au niveau supérieur quand vous êtes prêt.</p>
</div>
<div class="pricing-grid">
<!-- FREE -->
<div class="plan-card">
<div class="plan-name">Free</div>
<div class="plan-price">0<sup></sup><span>/mois</span></div>
<p class="plan-desc">Pour découvrir la puissance de l'IA turf.</p>
<ul class="plan-features">
<li>Aperçu Top-3 du jour (limité)</li>
<li>1 course complète par jour</li>
<li>Statistiques basiques</li>
<li class="disabled">Alertes Telegram</li>
<li class="disabled">Toutes les courses</li>
<li class="disabled">Value bets</li>
<li class="disabled">Export CSV</li>
<li class="disabled">Accès API</li>
</ul>
<a href="/register" class="btn btn-ghost btn-plan">Commencer gratuitement</a>
</div>
<!-- PREMIUM -->
<div class="plan-card popular">
<div class="popular-badge">⭐ Le plus populaire</div>
<div class="plan-name">Premium</div>
<div class="plan-price">9<sup></sup>,90<span>/mois</span></div>
<p class="plan-desc">Pour les parieurs sérieux qui veulent un vrai avantage.</p>
<ul class="plan-features">
<li>Toutes les courses du jour</li>
<li>Prédictions Top-1 et Top-3</li>
<li>Value bets identifiés</li>
<li>Alertes Telegram configurables</li>
<li>Historique 90 jours</li>
<li>Analyse météo & terrain</li>
<li class="disabled">Export CSV</li>
<li class="disabled">Accès API</li>
</ul>
<a href="/register?plan=premium" class="btn btn-primary btn-plan">Choisir Premium</a>
</div>
<!-- PRO -->
<div class="plan-card">
<div class="plan-name">Pro</div>
<div class="plan-price">24<sup></sup>,90<span>/mois</span></div>
<p class="plan-desc">Pour les professionnels et développeurs qui veulent tout.</p>
<ul class="plan-features">
<li>Tout du plan Premium</li>
<li>Export CSV illimité</li>
<li>Accès API REST documentée</li>
<li>Backtest personnalisé</li>
<li>Historique illimité</li>
<li>Support prioritaire</li>
<li>Webhook alertes personnalisées</li>
<li>Multi-compte (5 utilisateurs)</li>
</ul>
<a href="/register?plan=pro" class="btn btn-ghost btn-plan">Choisir Pro</a>
</div>
</div>
</section>
<!-- FAQ -->
<section id="faq" style="background: var(--dark2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
<div class="section-center">
<div class="section-label">FAQ</div>
<h2>Questions fréquentes</h2>
</div>
<div class="faq-list">
<details>
<summary>Comment fonctionne le modèle IA ?</summary>
<div class="faq-answer">Notre modèle XGBoost est entraîné sur plusieurs années de données PMU : cotes, historique des chevaux et drivers, conditions météo, état du terrain, statistiques par hippodrome. Il calcule pour chaque partant une probabilité d'arriver dans le Top-1 et Top-3, ainsi qu'un score de value bet comparant notre estimation à la cote du marché.</div>
</details>
<details>
<summary>Les prédictions garantissent-elles des gains ?</summary>
<div class="faq-answer">Non. Aucune prédiction ne garantit des gains. Le pari hippique reste un jeu de hasard. Notre IA améliore vos chances en identifiant les opportunités statistiquement favorables, mais les résultats passés ne préjugent pas des résultats futurs. Pariez de façon responsable.</div>
</details>
<details>
<summary>Puis-je annuler à tout moment ?</summary>
<div class="faq-answer">Oui, sans engagement. Vous pouvez annuler votre abonnement Premium ou Pro à tout moment depuis votre espace compte. L'accès reste actif jusqu'à la fin de la période payée.</div>
</details>
<details>
<summary>Les alertes Telegram fonctionnent-elles sur mobile ?</summary>
<div class="faq-answer">Oui. Après activation dans vos paramètres, vous recevrez les alertes value bets et top picks directement dans votre application Telegram, avec toutes les informations nécessaires pour parier rapidement avant le départ.</div>
</details>
<details>
<summary>L'API est-elle compatible avec d'autres outils ?</summary>
<div class="faq-answer">Oui. L'API REST du plan Pro est documentée (OpenAPI/Swagger) et compatible avec n'importe quel outil : Python, JavaScript, n8n, Zapier, Excel, etc. Un token personnel vous est fourni dans votre dashboard.</div>
</details>
<details>
<summary>Quelles disciplines sont couvertes ?</summary>
<div class="faq-answer">Nous couvrons toutes les disciplines PMU : Plat, Trot Attelé, Trot Monté, et Galop sur les hippodromes français. Les courses Quinté+ sont signalées et prioritaires dans votre dashboard.</div>
</details>
</div>
</section>
<!-- CTA BANNER -->
<section>
<div class="cta-banner">
<h2>Prêt à parier plus intelligemment ?</h2>
<p>Rejoignez des centaines de parieurs qui utilisent déjà Turf IA chaque jour. Essai gratuit, sans carte bancaire.</p>
<a href="/register" class="btn btn-primary btn-lg">Créer mon compte gratuit →</a>
</div>
</section>
<!-- FOOTER -->
<footer>
<div class="footer-brand">
<div style="font-weight: 700; font-size: 1.1rem;">🏇 Turf IA</div>
<p>Prédictions PMU par intelligence artificielle. Analyse XGBoost, value bets, alertes temps réel.</p>
</div>
<div class="footer-col">
<h4>Produit</h4>
<a href="#features">Fonctionnalités</a>
<a href="#pricing">Tarifs</a>
<a href="#how">Comment ça marche</a>
<a href="/dashboard">Dashboard</a>
</div>
<div class="footer-col">
<h4>Compte</h4>
<a href="/login">Connexion</a>
<a href="/register">Inscription</a>
<a href="/account">Mon compte</a>
</div>
<div class="footer-col">
<h4>Légal</h4>
<a href="/legal/cgu">CGU</a>
<a href="/legal/cgv">CGV</a>
<a href="/legal/privacy">Confidentialité</a>
<a href="/legal/cookies">Cookies</a>
</div>
</footer>
<div class="footer-bottom">
<p>© 2026 Turf IA — H3R7 Tech. Tous droits réservés. Le jeu peut être dangereux, jouez de façon responsable. <strong>18+</strong></p>
</div>
<!-- TOAST -->
<div id="toast"></div>
<script>
function showToast(msg, type = 'success') {
const t = document.getElementById('toast');
t.textContent = msg;
t.className = `show ${type}`;
setTimeout(() => { t.className = ''; }, 3500);
}
// Smooth scroll for anchor links
document.querySelectorAll('a[href^="#"]').forEach(a => {
a.addEventListener('click', e => {
const target = document.querySelector(a.getAttribute('href'));
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
});
});
// Track CTA clicks
document.querySelectorAll('[href="/register"]').forEach(btn => {
btn.addEventListener('click', () => {
try { localStorage.setItem('turf_signup_source', 'landing'); } catch(_) {}
});
});
</script>
</body>
</html>

112
log_config.py Normal file
View File

@@ -0,0 +1,112 @@
#!/usr/bin/env python3
"""
Structured JSON logging for Turf SaaS.
Replaces default Flask/Python logging with JSON output suitable for log aggregation.
"""
import logging
import sys
import os
import json
import traceback
from datetime import datetime, timezone
from typing import Optional
class JSONFormatter(logging.Formatter):
"""Format log records as JSON lines."""
def __init__(self, service_name: str = "turf-saas", env: str = "production"):
super().__init__()
self.service_name = service_name
self.env = env
def format(self, record: logging.LogRecord) -> str:
log_entry = {
"timestamp": datetime.now(timezone.utc).isoformat(),
"level": record.levelname,
"service": self.service_name,
"env": self.env,
"logger": record.name,
"message": record.getMessage(),
"module": record.module,
"function": record.funcName,
"line": record.lineno,
}
# Add extra fields if present
if hasattr(record, "request_id"):
log_entry["request_id"] = record.request_id
if hasattr(record, "user_id"):
log_entry["user_id"] = record.user_id
if hasattr(record, "duration_ms"):
log_entry["duration_ms"] = record.duration_ms
if hasattr(record, "status_code"):
log_entry["status_code"] = record.status_code
if hasattr(record, "endpoint"):
log_entry["endpoint"] = record.endpoint
# Exception info
if record.exc_info:
log_entry["exception"] = {
"type": record.exc_info[0].__name__ if record.exc_info[0] else None,
"message": str(record.exc_info[1]) if record.exc_info[1] else None,
"traceback": traceback.format_exception(*record.exc_info),
}
return json.dumps(log_entry, ensure_ascii=False)
def setup_logging(
service_name: str = "turf-saas",
level: Optional[str] = None,
use_json: bool = True,
) -> logging.Logger:
"""
Configure root logger with JSON or plain formatting.
Args:
service_name: Service name embedded in each log record.
level: Log level (default: from LOG_LEVEL env var, fallback INFO).
use_json: Use JSON formatter (True in production, False in dev).
Returns:
Root logger.
"""
log_level = level or os.environ.get("LOG_LEVEL", "INFO")
env = os.environ.get("FLASK_ENV", "production")
# Force plain text in dev/testing
if env in ("development", "testing"):
use_json = False
root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, log_level.upper(), logging.INFO))
# Remove existing handlers
root_logger.handlers.clear()
handler = logging.StreamHandler(sys.stdout)
if use_json:
handler.setFormatter(JSONFormatter(service_name=service_name, env=env))
else:
handler.setFormatter(
logging.Formatter(
fmt="%(asctime)s [%(levelname)s] %(name)s%(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
)
root_logger.addHandler(handler)
# Silence noisy third-party loggers
for noisy in ["werkzeug", "urllib3", "requests", "gunicorn.access"]:
logging.getLogger(noisy).setLevel(logging.WARNING)
return root_logger
def get_logger(name: str) -> logging.Logger:
"""Get a named logger."""
return logging.getLogger(name)

182
login.html Normal file
View File

@@ -0,0 +1,182 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Connexion — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--error: #f85149; --radius: 10px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
a { color: inherit; text-decoration: none; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
.nav-logo { font-weight: 700; font-size: 1.1rem; }
.nav-link { color: var(--muted); font-size: .9rem; }
.nav-link:hover { color: var(--text); }
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
.auth-card {
width: 100%; max-width: 420px;
background: var(--dark2); border: 1px solid var(--border);
border-radius: 14px; padding: 40px;
}
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
.form-group { margin-bottom: 18px; }
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
input {
width: 100%; padding: 11px 14px; background: var(--dark3);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
}
input:focus { border-color: var(--green); }
input.error { border-color: var(--error); }
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
.field-error.show { display: block; }
.forgot { float: right; font-size: .82rem; color: var(--muted); }
.forgot:hover { color: var(--text); }
.btn {
width: 100%; padding: 12px; border: none; border-radius: var(--radius);
font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s;
margin-top: 8px;
}
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
.auth-footer a { color: var(--green); font-weight: 600; }
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
.alert.show { display: block; }
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/register" class="nav-link">Pas encore de compte ? S'inscrire →</a>
</nav>
<main>
<div class="auth-card">
<h1 class="auth-title">Bon retour !</h1>
<p class="auth-subtitle">Connectez-vous à votre compte Turf IA.</p>
<div class="alert alert-error" id="alert-error"></div>
<form id="login-form" novalidate>
<div class="form-group">
<label for="email">Adresse email</label>
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
<div class="field-error" id="email-error">Email invalide.</div>
</div>
<div class="form-group">
<label for="password">
Mot de passe
<a href="/forgot-password" class="forgot">Mot de passe oublié ?</a>
</label>
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
<div class="field-error" id="password-error">Mot de passe requis.</div>
</div>
<button type="submit" class="btn btn-primary" id="submit-btn">Se connecter</button>
</form>
<div class="divider">ou</div>
<div class="auth-footer">
Pas encore de compte ? <a href="/register">Créer un compte gratuit</a>
</div>
</div>
</main>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API = '/api/v1';
const form = document.getElementById('login-form');
const emailInput = document.getElementById('email');
const passInput = document.getElementById('password');
const submitBtn = document.getElementById('submit-btn');
const alertBox = document.getElementById('alert-error');
function showError(msg) {
alertBox.textContent = msg;
alertBox.classList.add('show');
}
function hideError() { alertBox.classList.remove('show'); }
function setLoading(on) {
submitBtn.disabled = on;
submitBtn.innerHTML = on ? '<span class="loader"></span>Connexion…' : 'Se connecter';
}
function validateField(input, errId, condition, msg) {
const err = document.getElementById(errId);
if (condition) {
input.classList.add('error');
err.textContent = msg;
err.classList.add('show');
return false;
}
input.classList.remove('error');
err.classList.remove('show');
return true;
}
form.addEventListener('submit', async e => {
e.preventDefault();
hideError();
const email = emailInput.value.trim();
const pass = passInput.value;
const v1 = validateField(emailInput, 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Adresse email invalide.');
const v2 = validateField(passInput, 'password-error', !pass, 'Mot de passe requis.');
if (!v1 || !v2) return;
setLoading(true);
try {
const res = await fetch(`${API}/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password: pass })
});
const data = await res.json();
if (!res.ok) {
showError(data.error || 'Identifiants incorrects. Veuillez réessayer.');
} else {
localStorage.setItem('turf_token', data.token);
localStorage.setItem('turf_user', JSON.stringify(data.user));
const next = new URLSearchParams(location.search).get('next') || '/dashboard';
location.href = next;
}
} catch(_) {
showError('Erreur de connexion. Vérifiez votre réseau.');
} finally {
setLoading(false);
}
});
// Remove errors on typing
[emailInput, passInput].forEach(el => el.addEventListener('input', () => {
el.classList.remove('error');
document.getElementById(el.id + '-error')?.classList.remove('show');
hideError();
}));
// If already logged in, redirect
(function() {
const token = localStorage.getItem('turf_token');
if (token) location.href = '/dashboard';
})();
</script>
</body>
</html>

255
metrics.py Normal file
View File

@@ -0,0 +1,255 @@
#!/usr/bin/env python3
"""
Prometheus metrics instrumentation for Turf SaaS.
Import this module in Flask apps to expose /metrics endpoint.
"""
import time
import functools
import logging
from typing import Callable, Any
try:
from prometheus_client import (
Counter,
Histogram,
Gauge,
Summary,
generate_latest,
CONTENT_TYPE_LATEST,
CollectorRegistry,
multiprocess,
REGISTRY,
)
PROMETHEUS_AVAILABLE = True
except ImportError:
PROMETHEUS_AVAILABLE = False
logger = logging.getLogger(__name__)
# ============================================================
# Metric definitions
# ============================================================
if PROMETHEUS_AVAILABLE:
# HTTP metrics
HTTP_REQUESTS_TOTAL = Counter(
"http_requests_total",
"Total number of HTTP requests",
["method", "endpoint", "status_code", "service"],
)
HTTP_REQUEST_DURATION = Histogram(
"http_request_duration_seconds",
"HTTP request duration in seconds",
["method", "endpoint", "service"],
buckets=[0.01, 0.05, 0.1, 0.25, 0.5, 1.0, 2.0, 5.0, 10.0],
)
HTTP_REQUESTS_IN_PROGRESS = Gauge(
"http_requests_in_progress",
"Number of HTTP requests currently being processed",
["method", "endpoint", "service"],
)
# ML prediction metrics
ML_PREDICTIONS_TOTAL = Counter(
"ml_predictions_total",
"Total ML prediction requests",
["model_type", "race_type"],
)
ML_PREDICTION_DURATION = Histogram(
"ml_prediction_duration_seconds",
"ML prediction duration in seconds",
["model_type"],
buckets=[0.01, 0.05, 0.1, 0.5, 1.0, 2.0, 5.0],
)
ML_PREDICTION_ACCURACY = Gauge(
"ml_prediction_accuracy_ratio",
"Rolling ML prediction accuracy (top-1, top-3)",
["accuracy_type"],
)
ML_PREDICTION_DRIFT = Gauge(
"ml_prediction_drift_score",
"Feature drift score for ML models (0=no drift, 1=full drift)",
["feature_group"],
)
# Database metrics
DB_QUERIES_TOTAL = Counter(
"db_queries_total", "Total database queries", ["operation", "table"]
)
DB_QUERY_DURATION = Histogram(
"db_query_duration_seconds",
"Database query duration",
["operation"],
buckets=[0.001, 0.005, 0.01, 0.05, 0.1, 0.5, 1.0],
)
DB_CONNECTION_POOL_SIZE = Gauge(
"db_connection_pool_size", "Current database connection pool size"
)
# Business metrics
RACES_SCRAPED_TOTAL = Counter(
"races_scraped_total", "Total number of races scraped", ["source", "discipline"]
)
PREDICTIONS_ACCURACY_DAILY = Gauge(
"predictions_accuracy_daily_ratio",
"Daily prediction accuracy ratio",
["date", "race_type"],
)
ACTIVE_SUBSCRIPTIONS = Gauge(
"active_subscriptions_total", "Number of active SaaS subscriptions", ["plan"]
)
# App health
APP_INFO = Gauge(
"app_info", "Application build information", ["version", "service", "env"]
)
# ============================================================
# Flask integration
# ============================================================
def init_metrics(app, service_name: str = "unknown"):
"""
Register Prometheus metrics middleware on a Flask app.
Usage:
from metrics import init_metrics
init_metrics(app, service_name="combined-api")
"""
if not PROMETHEUS_AVAILABLE:
logger.warning("prometheus_client not installed — metrics disabled")
return
from flask import request, Response
# Set app info gauge
APP_INFO.labels(
version=app.config.get("VERSION", "unknown"),
service=service_name,
env=app.config.get("ENV", "unknown"),
).set(1)
@app.before_request
def before_request():
request._start_time = time.time()
HTTP_REQUESTS_IN_PROGRESS.labels(
method=request.method, endpoint=request.path, service=service_name
).inc()
@app.after_request
def after_request(response):
duration = time.time() - getattr(request, "_start_time", time.time())
endpoint = request.path
HTTP_REQUESTS_TOTAL.labels(
method=request.method,
endpoint=endpoint,
status_code=str(response.status_code),
service=service_name,
).inc()
HTTP_REQUEST_DURATION.labels(
method=request.method, endpoint=endpoint, service=service_name
).observe(duration)
HTTP_REQUESTS_IN_PROGRESS.labels(
method=request.method, endpoint=endpoint, service=service_name
).dec()
return response
@app.route("/metrics")
def metrics_endpoint():
"""Prometheus metrics scrape endpoint."""
return Response(generate_latest(), mimetype=CONTENT_TYPE_LATEST)
@app.route("/health")
def health_endpoint():
"""Docker / load-balancer health check endpoint."""
from flask import jsonify
return jsonify({"status": "ok", "service": service_name})
logger.info(f"Prometheus metrics initialized for service: {service_name}")
# ============================================================
# Decorator helpers
# ============================================================
def track_ml_prediction(model_type: str = "xgboost", race_type: str = "flat"):
"""Decorator to track ML prediction calls."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not PROMETHEUS_AVAILABLE:
return func(*args, **kwargs)
start = time.time()
try:
result = func(*args, **kwargs)
ML_PREDICTIONS_TOTAL.labels(
model_type=model_type, race_type=race_type
).inc()
return result
finally:
ML_PREDICTION_DURATION.labels(model_type=model_type).observe(
time.time() - start
)
return wrapper
return decorator
def track_db_query(operation: str = "select", table: str = "unknown"):
"""Decorator to track DB query calls."""
def decorator(func: Callable) -> Callable:
@functools.wraps(func)
def wrapper(*args, **kwargs):
if not PROMETHEUS_AVAILABLE:
return func(*args, **kwargs)
start = time.time()
try:
result = func(*args, **kwargs)
DB_QUERIES_TOTAL.labels(operation=operation, table=table).inc()
return result
finally:
DB_QUERY_DURATION.labels(operation=operation).observe(
time.time() - start
)
return wrapper
return decorator
def update_ml_accuracy(top1_accuracy: float, top3_accuracy: float):
"""Update ML accuracy gauges (call from scheduler)."""
if not PROMETHEUS_AVAILABLE:
return
ML_PREDICTION_ACCURACY.labels(accuracy_type="top1").set(top1_accuracy)
ML_PREDICTION_ACCURACY.labels(accuracy_type="top3").set(top3_accuracy)
def update_subscription_count(plan_counts: dict):
"""Update subscription count gauges."""
if not PROMETHEUS_AVAILABLE:
return
for plan, count in plan_counts.items():
ACTIVE_SUBSCRIPTIONS.labels(plan=plan).set(count)

90
middleware.py Normal file
View File

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

1
migrations/README Normal file
View File

@@ -0,0 +1 @@
Generic single-database configuration file

68
migrations/env.py Normal file
View File

@@ -0,0 +1,68 @@
"""Alembic env.py — Turf SaaS database migrations."""
import os
from logging.config import fileConfig
from sqlalchemy import engine_from_config, pool
from alembic import context
# Alembic Config object — gives access to .ini values
config = context.config
# Set logging from config
if config.config_file_name is not None:
fileConfig(config.config_file_name)
# Override sqlalchemy.url from environment variables
def get_db_url():
user = os.environ.get("POSTGRES_USER", "turf")
password = os.environ.get("POSTGRES_PASSWORD", "")
host = os.environ.get("POSTGRES_HOST", "localhost")
port = os.environ.get("POSTGRES_PORT", "5432")
db = os.environ.get("POSTGRES_DB", "turf_saas")
url = os.environ.get(
"DATABASE_URL", f"postgresql://{user}:{password}@{host}:{port}/{db}"
)
return url
config.set_main_option("sqlalchemy.url", get_db_url())
# No declarative model — we use raw DDL migrations
target_metadata = None
def run_migrations_offline() -> None:
"""Run migrations in 'offline' mode (no live DB connection needed)."""
url = config.get_main_option("sqlalchemy.url")
context.configure(
url=url,
target_metadata=target_metadata,
literal_binds=True,
dialect_opts={"paramstyle": "named"},
)
with context.begin_transaction():
context.run_migrations()
def run_migrations_online() -> None:
"""Run migrations in 'online' mode (uses live DB connection)."""
connectable = engine_from_config(
config.get_section(config.config_ini_section, {}),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
context.configure(
connection=connection,
target_metadata=target_metadata,
)
with context.begin_transaction():
context.run_migrations()
if context.is_offline_mode():
run_migrations_offline()
else:
run_migrations_online()

View File

@@ -0,0 +1,180 @@
#!/usr/bin/env python3
"""
SQLite → PostgreSQL Data Migration Script
Migrates existing turf_saas.db data to PostgreSQL.
Usage:
python migrations/migrate_sqlite_to_postgres.py \
--sqlite /path/to/turf_saas.db \
--pg-url postgresql://turf:password@localhost:5432/turf_saas
Run AFTER alembic upgrade head.
"""
import argparse
import sqlite3
import sys
import os
import logging
from datetime import datetime
logger = logging.getLogger("migrate")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
# Tables to migrate (in order to respect FK constraints)
TABLES = [
"predictions",
"results",
"performance",
"scraping_logs",
"pmu_reunions",
"pmu_meteo",
"pmu_courses",
"pmu_partants",
"ml_predictions_cache",
"users",
"subscriptions",
"refresh_tokens",
]
def get_sqlite_conn(sqlite_path: str):
conn = sqlite3.connect(sqlite_path)
conn.row_factory = sqlite3.Row
return conn
def get_pg_conn(pg_url: str):
try:
import psycopg2
import psycopg2.extras
conn = psycopg2.connect(pg_url)
return conn
except ImportError:
logger.error("psycopg2 not installed. Run: pip install psycopg2-binary")
sys.exit(1)
def migrate_table(sqlite_conn, pg_conn, table: str, batch_size: int = 500) -> int:
"""Migrate a single table from SQLite to PostgreSQL. Returns row count."""
import psycopg2.extras
sqlite_cur = sqlite_conn.cursor()
pg_cur = pg_conn.cursor()
# Get rows from SQLite
try:
sqlite_cur.execute(f"SELECT * FROM {table}")
except Exception as e:
logger.warning(f" Skipping {table}: {e}")
return 0
rows = sqlite_cur.fetchall()
if not rows:
logger.info(f" {table}: empty — skipping")
return 0
# Get column names
columns = [desc[0] for desc in sqlite_cur.description]
# Exclude 'id' to let PostgreSQL generate SERIAL
non_id_columns = [c for c in columns if c != "id"]
if not non_id_columns:
logger.warning(f" {table}: no columns to insert")
return 0
placeholders = ", ".join(["%s"] * len(non_id_columns))
col_list = ", ".join(non_id_columns)
insert_sql = f"INSERT INTO {table} ({col_list}) VALUES ({placeholders}) ON CONFLICT DO NOTHING"
inserted = 0
batch = []
for row in rows:
row_dict = dict(row)
values = tuple(row_dict.get(c) for c in non_id_columns)
batch.append(values)
if len(batch) >= batch_size:
try:
pg_cur.executemany(insert_sql, batch)
pg_conn.commit()
inserted += len(batch)
except Exception as e:
pg_conn.rollback()
logger.error(f" {table} batch error: {e}")
batch = []
# Final batch
if batch:
try:
pg_cur.executemany(insert_sql, batch)
pg_conn.commit()
inserted += len(batch)
except Exception as e:
pg_conn.rollback()
logger.error(f" {table} final batch error: {e}")
# Sync PostgreSQL sequence to max id
try:
pg_cur.execute(f"SELECT MAX(id) FROM {table}")
max_id = pg_cur.fetchone()[0]
if max_id:
seq_name = f"{table}_id_seq"
pg_cur.execute(f"SELECT setval('{seq_name}', {max_id})")
pg_conn.commit()
except Exception:
pass # Table may not have a sequence
return inserted
def run_migration(sqlite_path: str, pg_url: str):
logger.info(f"=== SQLite → PostgreSQL Migration ===")
logger.info(f"SQLite: {sqlite_path}")
logger.info(f"PostgreSQL: {pg_url.split('@')[-1]}") # Hide credentials in log
logger.info(f"Started: {datetime.now().isoformat()}")
if not os.path.exists(sqlite_path):
logger.error(f"SQLite file not found: {sqlite_path}")
sys.exit(1)
sqlite_conn = get_sqlite_conn(sqlite_path)
pg_conn = get_pg_conn(pg_url)
total = 0
for table in TABLES:
logger.info(f" Migrating: {table}...")
count = migrate_table(sqlite_conn, pg_conn, table)
logger.info(f"{table}: {count} rows migrated")
total += count
sqlite_conn.close()
pg_conn.close()
logger.info(f"=== Migration complete: {total} total rows ===")
logger.info(f"Finished: {datetime.now().isoformat()}")
if __name__ == "__main__":
parser = argparse.ArgumentParser(description="Migrate SQLite → PostgreSQL")
parser.add_argument(
"--sqlite",
default=os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf_saas.db"),
help="Path to SQLite database file",
)
parser.add_argument(
"--pg-url",
default=os.environ.get("DATABASE_URL", ""),
help="PostgreSQL connection URL",
)
parser.add_argument("--batch-size", type=int, default=500)
args = parser.parse_args()
if not args.pg_url:
logger.error("--pg-url or DATABASE_URL env var required")
sys.exit(1)
run_migration(args.sqlite, args.pg_url)

26
migrations/script.py.mako Normal file
View File

@@ -0,0 +1,26 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision: str = ${repr(up_revision)}
down_revision: Union[str, None] = ${repr(down_revision)}
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,345 @@
"""Initial schema — PostgreSQL migration from SQLite
Revision ID: 001_initial_schema
Revises: None
Create Date: 2026-04-25
Full migration of turf_saas SQLite schema to PostgreSQL.
Tables: predictions, results, performance, scraping_logs,
pmu_reunions, pmu_meteo, pmu_courses, pmu_partants,
ml_predictions_cache, users, subscriptions, refresh_tokens
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers
revision: str = "001_initial_schema"
down_revision: Union[str, None] = None
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
# ----------------------------------------------------------
# predictions
# ----------------------------------------------------------
op.create_table(
"predictions",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date", sa.Text, nullable=False),
sa.Column("race_name", sa.Text),
sa.Column("race_hippodrome", sa.Text),
sa.Column("race_time", sa.Text),
sa.Column("horse_number", sa.Integer),
sa.Column("horse_name", sa.Text),
sa.Column("odds", sa.Numeric(10, 2)),
sa.Column("prediction_rank", sa.Integer),
sa.Column("source", sa.Text),
sa.Column("jockey", sa.Text),
sa.Column("odds_time", sa.Text),
sa.Column("odds_prev", sa.Numeric(10, 2)),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
)
op.create_index("idx_predictions_date", "predictions", ["date"])
op.create_index("idx_predictions_horse", "predictions", ["horse_name"])
# ----------------------------------------------------------
# results
# ----------------------------------------------------------
op.create_table(
"results",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date", sa.Text, nullable=False),
sa.Column("race_name", sa.Text),
sa.Column("race_hippodrome", sa.Text),
sa.Column("position", sa.Integer),
sa.Column("horse_name", sa.Text),
sa.Column("odds", sa.Numeric(10, 2)),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
)
op.create_index("idx_results_date", "results", ["date"])
# ----------------------------------------------------------
# performance
# ----------------------------------------------------------
op.create_table(
"performance",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("prediction_date", sa.Text),
sa.Column("race_date", sa.Text),
sa.Column("horse_name", sa.Text),
sa.Column("predicted_rank", sa.Integer),
sa.Column("actual_position", sa.Integer),
sa.Column("hit", sa.Boolean),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
)
# ----------------------------------------------------------
# scraping_logs
# ----------------------------------------------------------
op.create_table(
"scraping_logs",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("timestamp", sa.Text),
sa.Column("runtime_sec", sa.Numeric(10, 3)),
sa.Column("total_pages", sa.Integer),
sa.Column("url", sa.Text),
sa.Column("site", sa.Text),
sa.Column("status", sa.Text),
)
# ----------------------------------------------------------
# pmu_reunions
# ----------------------------------------------------------
op.create_table(
"pmu_reunions",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date_programme", sa.Text, nullable=False),
sa.Column("num_reunion", sa.Integer, nullable=False),
sa.Column("num_externe", sa.Integer),
sa.Column("nature", sa.Text),
sa.Column("statut", sa.Text),
sa.Column("audience", sa.Text),
sa.Column("hippodrome_code", sa.Text),
sa.Column("hippodrome_court", sa.Text),
sa.Column("hippodrome_long", sa.Text),
sa.Column("pays_code", sa.Text),
sa.Column("pays_libelle", sa.Text),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_reunions"),
)
op.create_index("idx_reunions_date", "pmu_reunions", ["date_programme"])
# ----------------------------------------------------------
# pmu_meteo
# ----------------------------------------------------------
op.create_table(
"pmu_meteo",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date_programme", sa.Text, nullable=False),
sa.Column("num_reunion", sa.Integer, nullable=False),
sa.Column("nebulositecode", sa.Text),
sa.Column("nebulosite_court", sa.Text),
sa.Column("nebulosite_long", sa.Text),
sa.Column("temperature", sa.Integer),
sa.Column("force_vent", sa.Integer),
sa.Column("direction_vent", sa.Text),
sa.Column("date_prevision", sa.BigInteger),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
sa.UniqueConstraint("date_programme", "num_reunion", name="uq_pmu_meteo"),
)
# ----------------------------------------------------------
# pmu_courses
# ----------------------------------------------------------
op.create_table(
"pmu_courses",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date_programme", sa.Text, nullable=False),
sa.Column("num_reunion", sa.Integer, nullable=False),
sa.Column("num_course", sa.Integer, nullable=False),
sa.Column("num_externe", sa.Integer),
sa.Column("libelle", sa.Text),
sa.Column("libelle_court", sa.Text),
sa.Column("heure_depart", sa.BigInteger),
sa.Column("heure_depart_str", sa.Text),
sa.Column("distance", sa.Integer),
sa.Column("distance_unit", sa.Text),
sa.Column("parcours", sa.Text),
sa.Column("discipline", sa.Text),
sa.Column("specialite", sa.Text),
sa.Column("type_piste", sa.Text),
sa.Column("corde", sa.Text),
sa.Column("condition_age", sa.Text),
sa.Column("condition_sexe", sa.Text),
sa.Column("categorie_particularite", sa.Text),
sa.Column("nb_declares_partants", sa.Integer),
sa.Column("montant_prix", sa.Integer),
sa.Column("montant_1er", sa.Integer),
sa.Column("montant_2eme", sa.Integer),
sa.Column("montant_3eme", sa.Integer),
sa.Column("montant_4eme", sa.Integer),
sa.Column("montant_5eme", sa.Integer),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
sa.UniqueConstraint(
"date_programme", "num_reunion", "num_course", name="uq_pmu_courses"
),
)
op.create_index("idx_courses_date", "pmu_courses", ["date_programme"])
op.create_index("idx_courses_discipline", "pmu_courses", ["discipline"])
# ----------------------------------------------------------
# pmu_partants
# ----------------------------------------------------------
op.create_table(
"pmu_partants",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date_programme", sa.Text, nullable=False),
sa.Column("num_reunion", sa.Integer, nullable=False),
sa.Column("num_course", sa.Integer, nullable=False),
sa.Column("num_pmu", sa.Integer),
sa.Column("id_cheval", sa.BigInteger),
sa.Column("nom", sa.Text),
sa.Column("age", sa.Integer),
sa.Column("sexe", sa.Text),
sa.Column("race", sa.Text),
sa.Column("robe", sa.Text),
sa.Column("pays", sa.Text),
sa.Column("place_corde", sa.Integer),
sa.Column("nom_pere", sa.Text),
sa.Column("nom_mere", sa.Text),
sa.Column("nom_pere_mere", sa.Text),
sa.Column("driver", sa.Text),
sa.Column("driver_change", sa.Boolean),
sa.Column("entraineur", sa.Text),
sa.Column("proprietaire", sa.Text),
sa.Column("eleveur", sa.Text),
sa.Column("oeilleres", sa.Text),
sa.Column("supplement", sa.Boolean),
sa.Column("handicap_valeur", sa.Numeric(8, 2)),
sa.Column("handicap_poids", sa.Numeric(8, 2)),
sa.Column("musique", sa.Text),
sa.Column("nombre_courses", sa.Integer),
sa.Column("nombre_victoires", sa.Integer),
sa.Column("nombre_places", sa.Integer),
sa.Column("cote_direct", sa.Numeric(10, 2)),
sa.Column("cote_reference", sa.Numeric(10, 2)),
sa.Column("tendance_cote", sa.Text),
sa.Column("favoris", sa.Boolean),
sa.Column("ordre_arrivee", sa.Integer),
sa.Column("tx_victoire", sa.Numeric(6, 3)),
sa.Column("tx_place", sa.Numeric(6, 3)),
sa.Column("forme_recente", sa.Text),
sa.Column("gains_carriere", sa.BigInteger),
sa.Column("gains_annee_en_cours", sa.BigInteger),
sa.Column("tendance_forme", sa.Text),
sa.Column("distance_cheval_prec", sa.Integer),
sa.Column("commentaire_apres_course", sa.Text),
sa.Column("pays_entrainement", sa.Text),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
sa.UniqueConstraint(
"date_programme",
"num_reunion",
"num_course",
"num_pmu",
name="uq_pmu_partants",
),
)
op.create_index("idx_partants_date", "pmu_partants", ["date_programme"])
op.create_index("idx_partants_nom", "pmu_partants", ["nom"])
op.create_index("idx_partants_entraineur", "pmu_partants", ["entraineur"])
# ----------------------------------------------------------
# ml_predictions_cache
# ----------------------------------------------------------
op.create_table(
"ml_predictions_cache",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("date", sa.Text, nullable=False),
sa.Column("num_reunion", sa.Integer),
sa.Column("num_course", sa.Integer),
sa.Column("horse_name", sa.Text),
sa.Column("horse_number", sa.Integer),
sa.Column("odds", sa.Numeric(10, 2)),
sa.Column("prob_top1", sa.Numeric(6, 4)),
sa.Column("prob_top3", sa.Numeric(6, 4)),
sa.Column("ml_score", sa.Numeric(6, 4)),
sa.Column("recommendation", sa.Text),
sa.Column("is_value_bet", sa.Integer, server_default="0"),
sa.Column("is_outlier", sa.Integer, server_default="0"),
sa.Column("race_label", sa.Text),
sa.Column("race_name", sa.Text),
sa.Column("hippodrome", sa.Text),
sa.Column("discipline", sa.Text),
sa.Column("distance", sa.Numeric(8, 1)),
sa.Column("heure", sa.Text),
sa.Column("model_version", sa.Text, server_default="'xgboost_v1'"),
sa.Column("risque_label", sa.Text, server_default="'neutral'"),
sa.Column("risque_score", sa.Integer, server_default="50"),
sa.Column("created_at", sa.TIMESTAMP, server_default=sa.text("NOW()")),
sa.UniqueConstraint(
"date", "num_reunion", "num_course", "horse_name", name="uq_ml_cache"
),
)
op.create_index("idx_ml_cache_date", "ml_predictions_cache", ["date"])
# ----------------------------------------------------------
# users
# ----------------------------------------------------------
op.create_table(
"users",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("email", sa.Text, nullable=False, unique=True),
sa.Column("password_hash", sa.Text, nullable=False),
sa.Column(
"plan",
sa.Text,
nullable=False,
server_default="'free'",
),
sa.Column(
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
),
sa.Column("is_active", sa.Integer, nullable=False, server_default="1"),
sa.Column("daily_usage", sa.Integer, nullable=False, server_default="0"),
sa.Column("last_usage_date", sa.Text),
sa.CheckConstraint("plan IN ('free','premium','pro')", name="ck_users_plan"),
)
op.create_index("idx_users_email", "users", ["email"], unique=True)
# ----------------------------------------------------------
# subscriptions
# ----------------------------------------------------------
op.create_table(
"subscriptions",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
sa.Column("plan", sa.Text, nullable=False),
sa.Column(
"start_date", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
),
sa.Column("end_date", sa.TIMESTAMP),
sa.Column("stripe_customer_id", sa.Text),
sa.CheckConstraint(
"plan IN ('free','premium','pro')", name="ck_subscriptions_plan"
),
)
op.create_index("idx_subscriptions_user", "subscriptions", ["user_id"])
op.create_index("idx_subscriptions_stripe", "subscriptions", ["stripe_customer_id"])
# ----------------------------------------------------------
# refresh_tokens
# ----------------------------------------------------------
op.create_table(
"refresh_tokens",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column("user_id", sa.BigInteger, sa.ForeignKey("users.id"), nullable=False),
sa.Column("token_hash", sa.Text, nullable=False, unique=True),
sa.Column(
"created_at", sa.TIMESTAMP, nullable=False, server_default=sa.text("NOW()")
),
sa.Column("expires_at", sa.TIMESTAMP, nullable=False),
sa.Column("revoked", sa.Integer, nullable=False, server_default="0"),
)
op.create_index("idx_refresh_tokens_user", "refresh_tokens", ["user_id"])
op.create_index(
"idx_refresh_tokens_hash", "refresh_tokens", ["token_hash"], unique=True
)
def downgrade() -> None:
op.drop_table("refresh_tokens")
op.drop_table("subscriptions")
op.drop_table("users")
op.drop_table("ml_predictions_cache")
op.drop_table("pmu_partants")
op.drop_table("pmu_courses")
op.drop_table("pmu_meteo")
op.drop_table("pmu_reunions")
op.drop_table("scraping_logs")
op.drop_table("performance")
op.drop_table("results")
op.drop_table("predictions")

View File

@@ -15,7 +15,7 @@ import sqlite3
import re
import os
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
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',
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',

335
onboarding.html Normal file
View File

@@ -0,0 +1,335 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Bienvenue — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--radius: 12px; --gold: #ffd600;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
.onboarding-wrap { width: 100%; max-width: 580px; }
.step-indicator {
display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 36px;
}
.step-dot {
width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--border);
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
color: var(--muted); background: var(--dark2); transition: all .3s; flex-shrink: 0;
}
.step-dot.active { border-color: var(--green); color: var(--green); background: rgba(0,200,83,.1); }
.step-dot.done { border-color: var(--green); background: var(--green); color: #000; }
.step-line { flex: 1; height: 2px; background: var(--border); max-width: 80px; transition: background .3s; }
.step-line.done { background: var(--green); }
.step-panel { display: none; animation: fadeIn .3s ease; }
.step-panel.active { display: block; }
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: 16px; padding: 40px; }
.step-eyebrow { font-size: .8rem; color: var(--green); font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 10px; }
.step-subtitle { color: var(--muted); font-size: .95rem; margin-bottom: 28px; line-height: 1.6; }
.plan-options { display: flex; flex-direction: column; gap: 12px; margin-bottom: 28px; }
.plan-option {
display: flex; align-items: center; justify-content: space-between; gap: 14px;
padding: 16px 18px; border: 2px solid var(--border); border-radius: var(--radius);
cursor: pointer; transition: all .2s; background: var(--dark3);
}
.plan-option:hover { border-color: var(--muted); }
.plan-option.selected { border-color: var(--green); background: rgba(0,200,83,.06); }
.plan-option-left { display: flex; align-items: center; gap: 14px; }
.plan-icon { font-size: 1.6rem; }
.plan-info h3 { font-size: 1rem; font-weight: 700; }
.plan-info p { font-size: .82rem; color: var(--muted); margin-top: 2px; }
.plan-price-tag { font-weight: 800; font-size: 1rem; color: var(--green); text-align: right; }
.plan-price-tag .sub { font-size: .72rem; font-weight: 400; color: var(--muted); }
.plan-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; transition: all .2s; }
.plan-option.selected .plan-radio { border-color: var(--green); background: var(--green); }
.telegram-field { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; }
.telegram-field h3 { font-size: .95rem; font-weight: 700; margin-bottom: 6px; }
.telegram-field p { font-size: .83rem; color: var(--muted); margin-bottom: 12px; }
.telegram-input-row { display: flex; gap: 10px; }
input[type=text], input[type=email] {
flex: 1; padding: 10px 14px; background: var(--dark); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
}
input[type=text]:focus, input[type=email]:focus { border-color: var(--green); }
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; border: none; transition: all .2s; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 12px 20px; }
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
.btn-full { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
.success-icon { font-size: 4rem; text-align: center; margin-bottom: 20px; }
.checklist { list-style: none; margin: 20px 0; }
.checklist li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: .93rem; }
.checklist li:last-child { border-bottom: none; }
.checklist li::before { content: "✓"; background: var(--green); color: #000; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 800; flex-shrink: 0; }
.first-pred-preview { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin: 20px 0; }
.pred-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .88rem; }
.pred-row:last-child { border-bottom: none; }
.pred-rank { font-weight: 700; color: var(--gold); width: 24px; }
.pred-name { flex: 1; font-weight: 600; }
.pred-prob { color: var(--green); font-weight: 700; }
.pred-cote { color: var(--muted); font-size: .82rem; }
footer { margin-top: 24px; color: var(--muted); font-size: .78rem; text-align: center; }
</style>
</head>
<body>
<div class="onboarding-wrap">
<!-- Step indicator -->
<div class="step-indicator">
<div class="step-dot active" id="dot-1">1</div>
<div class="step-line" id="line-1"></div>
<div class="step-dot" id="dot-2">2</div>
<div class="step-line" id="line-2"></div>
<div class="step-dot" id="dot-3">3</div>
</div>
<!-- STEP 1: Welcome + plan confirm -->
<div class="step-panel active" id="step-1">
<div class="card">
<div class="step-eyebrow">Étape 1 sur 3</div>
<h2>Bienvenue sur Turf IA 🏇</h2>
<p class="step-subtitle">Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.</p>
<div class="plan-options" id="plan-options">
<div class="plan-option" data-plan="free">
<div class="plan-option-left">
<div class="plan-icon">🆓</div>
<div class="plan-info">
<h3>Free</h3>
<p>Aperçu quotidien, 1 course complète</p>
</div>
</div>
<div>
<div class="plan-price-tag">0€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
<div class="plan-option" data-plan="premium">
<div class="plan-option-left">
<div class="plan-icon"></div>
<div class="plan-info">
<h3>Premium</h3>
<p>Toutes les courses, alertes Telegram</p>
</div>
</div>
<div>
<div class="plan-price-tag">9,90€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
<div class="plan-option" data-plan="pro">
<div class="plan-option-left">
<div class="plan-icon">🚀</div>
<div class="plan-info">
<h3>Pro</h3>
<p>API, export CSV, support prioritaire</p>
</div>
</div>
<div>
<div class="plan-price-tag">24,90€<div class="sub">/mois</div></div>
</div>
<div class="plan-radio"></div>
</div>
</div>
<div class="btn-row">
<button class="btn btn-primary btn-full" id="step1-next">Continuer →</button>
</div>
</div>
</div>
<!-- STEP 2: Alerts & preferences -->
<div class="step-panel" id="step-2">
<div class="card">
<div class="step-eyebrow">Étape 2 sur 3</div>
<h2>Configurez vos alertes</h2>
<p class="step-subtitle">Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.</p>
<div class="telegram-field">
<h3>📱 Alertes Telegram</h3>
<p>Pour activer les alertes, envoyez <strong>/start</strong> à notre bot Telegram <strong>@TurfIABot</strong> et collez ci-dessous votre Chat ID.</p>
<div class="telegram-input-row">
<input type="text" id="telegram-id" placeholder="Votre Chat ID Telegram (ex: 123456789)">
<button class="btn btn-ghost" onclick="openTelegramBot()">Ouvrir le bot</button>
</div>
</div>
<div style="margin-bottom:24px">
<p style="font-size:.88rem;color:var(--muted);margin-bottom:12px">🔔 Quand recevoir les alertes :</p>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-vb" checked style="width:auto;accent-color:var(--green)">
Value bets identifiés (cote sous-évaluée)
</label>
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-top1" checked style="width:auto;accent-color:var(--green)">
Favori IA Top-1 de chaque course
</label>
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
<input type="checkbox" id="alert-quinte" style="width:auto;accent-color:var(--green)">
Quinté+ uniquement
</label>
</div>
<div class="btn-row">
<button class="btn btn-ghost" id="step2-skip">Passer cette étape</button>
<button class="btn btn-primary" id="step2-next">Enregistrer & continuer →</button>
</div>
</div>
</div>
<!-- STEP 3: First prediction preview -->
<div class="step-panel" id="step-3">
<div class="card">
<div class="step-eyebrow">Étape 3 sur 3</div>
<div class="success-icon">🎉</div>
<h2 style="text-align:center">Vous êtes prêt !</h2>
<p class="step-subtitle" style="text-align:center">Voici un aperçu de votre première prédiction du jour.</p>
<div class="first-pred-preview" id="first-pred">
<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION EXEMPLE — R1C1</div>
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">CHARGEMENT...</div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥈</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
<div class="pred-row"><div class="pred-rank">🥉</div><div class="pred-name"></div><div class="pred-prob">—%</div><div class="pred-cote"></div></div>
</div>
<ul class="checklist">
<li>Compte créé et configuré</li>
<li id="check-alerts">Alertes Telegram configurées</li>
<li>Dashboard accessible 24h/24</li>
</ul>
<button class="btn btn-primary btn-full" id="step3-finish">Accéder à mon dashboard →</button>
</div>
</div>
</div>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API = '/api/v1';
let currentStep = 1;
let selectedPlan = 'free';
function getToken() { return localStorage.getItem('turf_token'); }
async function fetchJson(url, opts = {}) {
const token = getToken();
const res = await fetch(url, {
...opts,
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
});
if (res.status === 401) { location.href = '/login'; return null; }
return res.ok ? res.json() : null;
}
// Pre-select plan from user storage
(function() {
if (!getToken()) { location.href = '/login'; return; }
try {
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
selectedPlan = user.plan || 'free';
// Mark paid plans as not selectable if not purchased
document.querySelectorAll('.plan-option').forEach(el => {
if (el.dataset.plan === selectedPlan) selectPlan(selectedPlan);
});
} catch(_) { selectPlan('free'); }
if (!document.querySelector('.plan-option.selected')) selectPlan('free');
})();
function selectPlan(plan) {
selectedPlan = plan;
document.querySelectorAll('.plan-option').forEach(el => {
el.classList.toggle('selected', el.dataset.plan === plan);
});
}
document.querySelectorAll('.plan-option').forEach(el => {
el.addEventListener('click', () => selectPlan(el.dataset.plan));
});
function goToStep(n) {
currentStep = n;
document.querySelectorAll('.step-panel').forEach((el, i) => el.classList.toggle('active', i + 1 === n));
for (let i = 1; i <= 3; i++) {
const dot = document.getElementById(`dot-${i}`);
dot.classList.toggle('done', i < n);
dot.classList.toggle('active', i === n);
if (i < 3) document.getElementById(`line-${i}`)?.classList.toggle('done', i < n);
}
if (n === 3) loadFirstPrediction();
}
document.getElementById('step1-next').addEventListener('click', async () => {
// Update plan via API
await fetchJson(`${API}/auth/update-plan`, {
method: 'POST',
body: JSON.stringify({ plan: selectedPlan })
});
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
user.plan = selectedPlan;
localStorage.setItem('turf_user', JSON.stringify(user));
// If paid plan, show payment notice (will be handled in Sprint 5-6)
if (selectedPlan !== 'free') {
goToStep(2);
} else {
goToStep(2);
}
});
document.getElementById('step2-skip').addEventListener('click', () => { document.getElementById('check-alerts').textContent = 'Alertes Telegram (non configurées)'; goToStep(3); });
document.getElementById('step2-next').addEventListener('click', async () => {
const chatId = document.getElementById('telegram-id').value.trim();
const alertVb = document.getElementById('alert-vb').checked;
const alertTop1 = document.getElementById('alert-top1').checked;
const alertQ = document.getElementById('alert-quinte').checked;
if (chatId) {
await fetchJson(`${API}/auth/update-preferences`, {
method: 'POST',
body: JSON.stringify({ telegram_chat_id: chatId, alert_value_bets: alertVb, alert_top1: alertTop1, alert_quinte_only: alertQ })
});
}
goToStep(3);
});
document.getElementById('step3-finish').addEventListener('click', () => {
localStorage.removeItem('turf_onboarding');
location.href = '/dashboard';
});
function openTelegramBot() {
window.open('https://t.me/TurfIABot', '_blank');
}
async function loadFirstPrediction() {
const data = await fetchJson(`${API}/predictions/today`);
if (!data || !data.predictions || data.predictions.length === 0) return;
const sorted = [...data.predictions].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
const container = document.getElementById('first-pred');
const label = data.predictions[0]?.race_label || 'R1C1';
container.innerHTML = `<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR — ${label}</div>` +
sorted.map((h, i) => `<div class="pred-row">
<div class="pred-rank">${['🥇','🥈','🥉'][i]}</div>
<div class="pred-name">${h.horse_name || '—'}</div>
<div class="pred-prob">${h.prob_top3 ? (h.prob_top3*100).toFixed(1)+'%' : '—'}</div>
<div class="pred-cote">${h.odds ? h.odds.toFixed(1) : '—'}</div>
</div>`).join('');
}
</script>
</body>
</html>

View File

@@ -38,7 +38,7 @@ from pathlib import Path
# ─────────────────────────────────────────────────────────
# 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")
API_BASE = "https://online.turfinfo.api.pmu.fr/rest/client/7"

View File

@@ -10,17 +10,72 @@ app = Flask(__name__)
DASHBOARD_API_URL = "http://localhost:8791"
COMBINED_API_URL = "http://localhost:8790"
COMBINED_API_URL = "http://localhost:8790"
SAAS_DIR = "/home/h3r7/turf_saas"
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
try:
from saas_auth import auth_bp
from saas_api_v1 import api_v1_bp
app.register_blueprint(auth_bp)
app.register_blueprint(api_v1_bp)
print("[portal] SaaS auth & API v1 blueprints registered ✅")
except Exception as e:
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
@app.route("/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
return {"status": "ok", "service": "portal"}, 200
@app.route("/")
def portal():
return send_from_directory("/home/h3r7/turf_saas", "portail.html")
def landing():
"""Marketing landing page."""
return send_from_directory(SAAS_DIR, "landing.html")
@app.route("/login")
def login_page():
return send_from_directory(SAAS_DIR, "login.html")
@app.route("/register")
def register_page():
return send_from_directory(SAAS_DIR, "register.html")
@app.route("/dashboard")
def dashboard_saas():
return send_from_directory(SAAS_DIR, "dashboard_saas.html")
@app.route("/onboarding")
def onboarding():
return send_from_directory(SAAS_DIR, "onboarding.html")
@app.route("/account")
def account():
return send_from_directory(SAAS_DIR, "account.html")
@app.route("/portal")
@app.route("/portail")
def portal_legacy():
"""Legacy portal redirect."""
return send_from_directory(SAAS_DIR, "portail.html")
@app.route("/favicon.ico")
def favicon():
return send_from_directory("/home/h3r7/turf_saas", "favicon.ico")
return send_from_directory(SAAS_DIR, "favicon.ico")
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
@@ -269,9 +324,7 @@ def niches_business():
@app.route("/template_restaurant_json.html")
def template_restaurant():
return send_from_directory(
"/home/h3r7/turf_saas", "template_restaurant_json.html"
)
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
@app.route("/template_boulangerie_final.html")
@@ -288,9 +341,7 @@ def template_artisan():
@app.route("/template_restaurant_final.html")
def template_restaurant_final():
return send_from_directory(
"/home/h3r7/turf_saas", "template_restaurant_final.html"
)
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
@app.route("/template_complet.html")
@@ -300,9 +351,7 @@ def template_complet():
@app.route("/boite_a_idees_dashboard")
def boite_a_idees_dashboard():
return send_from_directory(
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
)
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
@app.route("/datagouv_explorer.html")
@@ -345,13 +394,23 @@ def api_chat_workflows():
return jsonify([dict(w) for w in workflows])
except Exception as e:
return jsonify({"error": str(e)}), 500
@app.route("/api/chat/nvidia-models", methods=["GET"])
def api_nvidia_models():
return jsonify([
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v}
for k, v in NVIDIA_MODELS.items()
])
return jsonify(
[
{
"id": k,
"name": v.split("/")[-1]
.replace("-instruct", "")
.replace("-", " ")
.title(),
"full_id": v,
}
for k, v in NVIDIA_MODELS.items()
]
)
@app.route("/api/chat/sessions", methods=["GET"])
@@ -457,7 +516,9 @@ def api_chat_cleanup():
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
NVIDIA_API_KEY = "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"
NVIDIA_API_KEY = (
"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"
)
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
NVIDIA_MODELS = {
@@ -476,7 +537,6 @@ NVIDIA_MODELS = {
}
@app.route("/webhook/telegram", methods=["POST"])
def telegram_webhook():
try:
@@ -542,25 +602,25 @@ def webhook_proxy(workflow_slug):
model_key = request.json.get("model", "llama-3.1-8b")
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
resp = requests.post(
NVIDIA_API_URL,
headers={
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": [{"role": "user", "content": user_message}],
"max_tokens": 1024,
"temperature": 0.7,
},
timeout=60,
NVIDIA_API_URL,
headers={
"Authorization": f"Bearer {NVIDIA_API_KEY}",
"Content-Type": "application/json",
},
json={
"model": model_id,
"messages": [{"role": "user", "content": user_message}],
"max_tokens": 1024,
"temperature": 0.7,
},
timeout=60,
)
data = resp.json()
ai_response = (
data.get("choices", [{}])[0]
.get("message", {})
.get("content", str(data))
)
data.get("choices", [{}])[0]
.get("message", {})
.get("content", str(data))
)
else:
# Proxy vers webhook n8n
resp = requests.post(
@@ -702,12 +762,17 @@ def api_proxy(api_path=""):
url = f"{DASHBOARD_API_URL}/turf/api"
try:
fwd_method = request.method
fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None
fwd_json = (
request.get_json(silent=True)
if fwd_method in ("POST", "PUT", "PATCH")
else None
)
fwd_headers = {"Content-Type": "application/json"}
if request.headers.get("Authorization"):
fwd_headers["Authorization"] = request.headers.get("Authorization")
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30,
headers=fwd_headers)
resp = requests.request(
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
)
return resp.content, resp.status_code, {"Content-Type": "application/json"}
except Exception as e:
return jsonify({"error": str(e), "url": url}), 500
@@ -744,23 +809,26 @@ def opencode_api():
return jsonify({"error": str(e)}), 500
@app.route("/candidatures/")
def candidatures_index():
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
@app.route("/candidatures/<path:filename>")
def candidatures_static(filename):
return send_from_directory("/home/h3r7/turf_saas", filename)
@app.route("/map")
def map_visual():
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
@app.route("/architecture.json")
def architecture_json():
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
if __name__ == "__main__":
app.run(host="0.0.0.0", port=8792, debug=False)
@@ -827,5 +895,3 @@ def proxy_prompts_test():
return response
except Exception as e:
return f"Erreur proxy prompts: {e}", 502

271
register.html Normal file
View File

@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Inscription — Turf IA</title>
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--green: #00c853; --green-d: #009624;
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
--error: #f85149; --radius: 10px;
}
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
a { color: inherit; text-decoration: none; }
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
.nav-logo { font-weight: 700; font-size: 1.1rem; }
.nav-link { color: var(--muted); font-size: .9rem; }
.nav-link:hover { color: var(--text); }
main { flex: 1; display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; gap: 40px; flex-wrap: wrap; }
.auth-card { width: 100%; max-width: 440px; background: var(--dark2); border: 1px solid var(--border); border-radius: 14px; padding: 40px; }
.plan-preview { width: 100%; max-width: 300px; }
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
.form-group { margin-bottom: 16px; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
input, select {
width: 100%; padding: 11px 14px; background: var(--dark3);
border: 1px solid var(--border); border-radius: var(--radius);
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
}
input:focus, select:focus { border-color: var(--green); }
input.error { border-color: var(--error); }
select option { background: var(--dark3); }
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
.field-error.show { display: block; }
.btn { width: 100%; padding: 12px; border: none; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s; margin-top: 4px; }
.btn-primary { background: var(--green); color: #000; }
.btn-primary:hover { background: var(--green-d); }
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
.auth-footer a { color: var(--green); font-weight: 600; }
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
.alert.show { display: block; }
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
@keyframes spin { to { transform: rotate(360deg); } }
.terms { font-size: .8rem; color: var(--muted); line-height: 1.6; }
.terms a { color: var(--green); }
.password-strength { height: 4px; border-radius: 2px; margin-top: 6px; background: var(--border); overflow: hidden; }
.strength-bar { height: 100%; width: 0; transition: width .3s, background .3s; }
/* Plan preview sidebar */
.plan-card {
background: var(--dark2); border: 1px solid var(--border);
border-radius: 14px; padding: 24px; margin-bottom: 16px;
transition: border-color .3s;
}
.plan-card.selected { border-color: var(--green); }
.plan-card-name { font-weight: 700; font-size: 1rem; display: flex; justify-content: space-between; align-items: center; }
.plan-card-price { font-size: 1.5rem; font-weight: 800; margin: 8px 0; }
.plan-card-features { list-style: none; margin-top: 12px; }
.plan-card-features li { font-size: .83rem; color: var(--muted); padding: 4px 0; }
.plan-card-features li::before { content: "✓ "; color: var(--green); }
.plan-badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
@media (max-width: 768px) { .plan-preview { display: none; } }
</style>
</head>
<body>
<nav>
<a href="/" class="nav-logo">🏇 Turf IA</a>
<a href="/login" class="nav-link">Déjà un compte ? Se connecter →</a>
</nav>
<main>
<div class="auth-card">
<h1 class="auth-title">Créer votre compte</h1>
<p class="auth-subtitle">Commencez gratuitement, sans carte bancaire.</p>
<div class="alert alert-error" id="alert-error"></div>
<form id="register-form" novalidate>
<div class="form-row">
<div class="form-group">
<label for="firstname">Prénom</label>
<input type="text" id="firstname" name="firstname" placeholder="Jean" autocomplete="given-name" required>
<div class="field-error" id="firstname-error">Requis.</div>
</div>
<div class="form-group">
<label for="lastname">Nom</label>
<input type="text" id="lastname" name="lastname" placeholder="Dupont" autocomplete="family-name" required>
<div class="field-error" id="lastname-error">Requis.</div>
</div>
</div>
<div class="form-group">
<label for="email">Adresse email</label>
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
<div class="field-error" id="email-error">Email invalide.</div>
</div>
<div class="form-group">
<label for="password">Mot de passe</label>
<input type="password" id="password" name="password" placeholder="8 caractères minimum" autocomplete="new-password" required>
<div class="password-strength"><div class="strength-bar" id="strength-bar"></div></div>
<div class="field-error" id="password-error">8 caractères minimum requis.</div>
</div>
<div class="form-group">
<label for="plan">Plan de départ</label>
<select id="plan" name="plan">
<option value="free">Free — Gratuit</option>
<option value="premium">Premium — 9,90€/mois</option>
<option value="pro">Pro — 24,90€/mois</option>
</select>
</div>
<div class="form-group">
<p class="terms">En vous inscrivant, vous acceptez nos <a href="/legal/cgu">Conditions Générales d'Utilisation</a> et notre <a href="/legal/privacy">Politique de Confidentialité</a>. Vous devez avoir 18 ans ou plus.</p>
</div>
<button type="submit" class="btn btn-primary" id="submit-btn">Créer mon compte gratuit</button>
</form>
<div class="auth-footer">
Déjà un compte ? <a href="/login">Se connecter</a>
</div>
</div>
<!-- Plan preview sidebar -->
<div class="plan-preview">
<div id="plan-card-free" class="plan-card selected">
<div class="plan-card-name">Free <span></span></div>
<div class="plan-card-price">0€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
<ul class="plan-card-features">
<li>Aperçu Top-3 du jour</li>
<li>1 course complète/jour</li>
<li>Statistiques basiques</li>
</ul>
</div>
<div id="plan-card-premium" class="plan-card" style="display:none">
<div class="plan-card-name">Premium <span class="plan-badge"></span></div>
<div class="plan-card-price">9,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
<ul class="plan-card-features">
<li>Toutes les courses</li>
<li>Value bets identifiés</li>
<li>Alertes Telegram</li>
<li>Historique 90 jours</li>
</ul>
</div>
<div id="plan-card-pro" class="plan-card" style="display:none">
<div class="plan-card-name">Pro <span></span></div>
<div class="plan-card-price">24,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
<ul class="plan-card-features">
<li>Tout du Premium</li>
<li>Export CSV illimité</li>
<li>API REST documentée</li>
<li>Backtest personnalisé</li>
<li>Support prioritaire</li>
</ul>
</div>
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire requise pour le plan Free</p>
</div>
</main>
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
<script>
const API = '/api/v1';
const form = document.getElementById('register-form');
const submitBtn = document.getElementById('submit-btn');
const alertBox = document.getElementById('alert-error');
const planSelect = document.getElementById('plan');
// Pre-select plan from URL param
const urlPlan = new URLSearchParams(location.search).get('plan');
if (urlPlan && ['free','premium','pro'].includes(urlPlan)) planSelect.value = urlPlan;
function updatePlanPreview() {
['free','premium','pro'].forEach(p => {
const el = document.getElementById(`plan-card-${p}`);
if (!el) return;
if (planSelect.value === p) {
el.style.display = '';
el.classList.add('selected');
} else {
el.style.display = 'none';
el.classList.remove('selected');
}
});
}
planSelect.addEventListener('change', updatePlanPreview);
updatePlanPreview();
function showError(msg) { alertBox.textContent = msg; alertBox.classList.add('show'); }
function hideError() { alertBox.classList.remove('show'); }
function setLoading(on) {
submitBtn.disabled = on;
submitBtn.innerHTML = on ? '<span class="loader"></span>Création…' : 'Créer mon compte gratuit';
}
function validateField(input, errId, condition, msg) {
const err = document.getElementById(errId);
if (condition) { input.classList.add('error'); err.textContent = msg; err.classList.add('show'); return false; }
input.classList.remove('error'); err.classList.remove('show'); return true;
}
// Password strength
const passInput = document.getElementById('password');
const bar = document.getElementById('strength-bar');
passInput.addEventListener('input', () => {
const v = passInput.value;
let score = 0;
if (v.length >= 8) score++;
if (/[A-Z]/.test(v)) score++;
if (/[0-9]/.test(v)) score++;
if (/[^A-Za-z0-9]/.test(v)) score++;
const colors = ['#f85149','#e3b341','#58a6ff','#00c853'];
bar.style.width = (score * 25) + '%';
bar.style.background = colors[score - 1] || '#30363d';
});
form.addEventListener('submit', async e => {
e.preventDefault();
hideError();
const firstname = document.getElementById('firstname').value.trim();
const lastname = document.getElementById('lastname').value.trim();
const email = document.getElementById('email').value.trim();
const password = passInput.value;
const plan = planSelect.value;
let ok = true;
ok = validateField(document.getElementById('firstname'), 'firstname-error', !firstname, 'Prénom requis.') && ok;
ok = validateField(document.getElementById('lastname'), 'lastname-error', !lastname, 'Nom requis.') && ok;
ok = validateField(document.getElementById('email'), 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Email invalide.') && ok;
ok = validateField(passInput, 'password-error', password.length < 8, '8 caractères minimum.') && ok;
if (!ok) return;
setLoading(true);
try {
const res = await fetch(`${API}/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ firstname, lastname, email, password, plan })
});
const data = await res.json();
if (!res.ok) {
showError(data.error || 'Erreur lors de la création du compte.');
} else {
localStorage.setItem('turf_token', data.token);
localStorage.setItem('turf_user', JSON.stringify(data.user));
localStorage.setItem('turf_onboarding', 'pending');
location.href = '/onboarding';
}
} catch(_) {
showError('Erreur de connexion. Vérifiez votre réseau.');
} finally {
setLoading(false);
}
});
document.querySelectorAll('input').forEach(el => el.addEventListener('input', () => {
el.classList.remove('error');
document.getElementById(el.id + '-error')?.classList.remove('show');
hideError();
}));
(function() {
if (localStorage.getItem('turf_token')) location.href = '/dashboard';
})();
</script>
</body>
</html>

33
requirements.txt Normal file
View File

@@ -0,0 +1,33 @@
# Core web framework
Flask==3.1.3
flask-cors==6.0.2
gunicorn==23.0.0
# HTTP client
requests==2.32.3
# Data processing & ML
pandas==3.0.1
numpy==2.4.3
scikit-learn==1.6.1
xgboost==3.2.0
# Database - PostgreSQL
psycopg2-binary==2.9.12
SQLAlchemy==2.0.40
alembic==1.16.1
# Scheduling
schedule==1.2.2
# Monitoring
prometheus-client==0.21.1
# Logging
python-json-logger==3.3.0
# Security
python-dotenv==1.1.0
# Utilities
python-dateutil==2.9.0

247
saas_api.py Normal file
View File

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

282
saas_api_v1.py Normal file
View File

@@ -0,0 +1,282 @@
#!/usr/bin/env python3
"""
SaaS API v1 Blueprint — /api/v1/*
Stats, prédictions, résumés pour le dashboard SaaS.
Sprint 4-5 — HRT-30
"""
from flask import Blueprint, request, jsonify
import sqlite3
import os
from datetime import datetime
from saas_auth import require_auth
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def plan_allows(user_plan: str, required: str) -> bool:
order = {"free": 0, "premium": 1, "pro": 2}
return order.get(user_plan, 0) >= order.get(required, 0)
# ─── Stats ────────────────────────────────────────────────────────────────────
@api_v1_bp.route("/stats/summary", methods=["GET"])
@require_auth
def stats_summary():
"""GET /api/v1/stats/summary — résumé dashboard."""
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
# Courses today
courses_today = (
conn.execute(
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
(today,),
).fetchone()[0]
or 0
)
# Value bets today
value_bets_today = (
conn.execute(
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
(today,),
).fetchone()[0]
or 0
)
# Accuracy top3 (30 days)
acc_row = conn.execute("""
SELECT
CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
FROM ml_predictions_cache m
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
WHERE m.date >= date('now', '-30 days')
""").fetchone()
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
# Next race
next_race = conn.execute(
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
(today,),
).fetchone()
conn.close()
return jsonify(
{
"courses_today": courses_today,
"value_bets_today": value_bets_today,
"accuracy_top3": accuracy_top3,
"next_race_time": next_race["heure"] if next_race else None,
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
}
), 200
except Exception as e:
conn.close()
return jsonify(
{"error": str(e), "courses_today": 0, "value_bets_today": 0}
), 200
# ─── Predictions ──────────────────────────────────────────────────────────────
@api_v1_bp.route("/predictions/today", methods=["GET"])
@require_auth
def predictions_today():
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
try:
rows = conn.execute(
"""
SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
ml_score, recommendation, is_value_bet, is_outlier,
race_label, race_name, hippodrome, discipline, distance,
heure, risque_label, risque_score, num_reunion, num_course
FROM ml_predictions_cache
WHERE date=?
ORDER BY num_reunion, num_course, ml_score DESC
""",
(today,),
).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
# Free plan: return only 1 race
if plan == "free":
if predictions:
first = predictions[0]
first_key = (first["num_reunion"], first["num_course"])
predictions = [
p
for p in predictions
if (p["num_reunion"], p["num_course"]) == first_key
]
# Mask value bet flag in free
for p in predictions:
p["is_value_bet"] = 0
# Premium/Pro: full predictions
return jsonify(
{
"date": today,
"plan": plan,
"count": len(predictions),
"predictions": predictions,
}
), 200
except Exception as e:
conn.close()
return jsonify({"error": str(e), "predictions": []}), 200
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
@require_auth
def predictions_race(race_label):
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
user = request.current_user
plan = user.get("plan", "free")
today = datetime.now().strftime("%Y-%m-%d")
# Parse label like R1C3 → num_reunion=1, num_course=3
import re
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
if not m:
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
nr, nc = int(m.group(1)), int(m.group(2))
conn = get_db()
rows = conn.execute(
"""
SELECT * FROM ml_predictions_cache
WHERE date=? AND num_reunion=? AND num_course=?
ORDER BY ml_score DESC
""",
(today, nr, nc),
).fetchall()
conn.close()
predictions = [dict(r) for r in rows]
if plan == "free" and predictions:
# Only show first race
pass # allowed in detail view if they know the race label
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
# ─── Value Bets ───────────────────────────────────────────────────────────────
@api_v1_bp.route("/value-bets/today", methods=["GET"])
@require_auth
def value_bets_today():
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "premium"):
return jsonify(
{
"error": "Cette fonctionnalité requiert un plan Premium ou Pro.",
"upgrade_required": True,
}
), 403
today = datetime.now().strftime("%Y-%m-%d")
conn = get_db()
rows = conn.execute(
"""
SELECT horse_name, race_label, race_name, hippodrome, odds,
prob_top3, ml_score, risque_label, heure
FROM ml_predictions_cache
WHERE date=? AND is_value_bet=1
ORDER BY ml_score DESC
""",
(today,),
).fetchall()
conn.close()
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
# ─── Export ───────────────────────────────────────────────────────────────────
@api_v1_bp.route("/export/csv", methods=["GET"])
@require_auth
def export_csv():
"""GET /api/v1/export/csv — export CSV (Pro only)."""
from flask import Response
import csv, io
user = request.current_user
plan = user.get("plan", "free")
if not plan_allows(plan, "pro"):
return jsonify(
{"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}
), 403
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
conn = get_db()
rows = conn.execute(
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
(date_param,),
).fetchall()
conn.close()
output = io.StringIO()
if rows:
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
writer.writeheader()
writer.writerows([dict(r) for r in rows])
return Response(
output.getvalue(),
mimetype="text/csv",
headers={
"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}')

461
saas_auth.py Normal file
View File

@@ -0,0 +1,461 @@
#!/usr/bin/env python3
"""
SaaS Auth Blueprint — /api/v1/auth/*
Gestion des utilisateurs, JWT, plans, préférences.
Sprint 4-5 — HRT-30
"""
from flask import Blueprint, request, jsonify, current_app
import sqlite3
import hashlib
import secrets
import os
import time
import json
from functools import wraps
from datetime import datetime
# ─── 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 ───────────────────────────────────────────────────────────────────
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
JWT_SECRET = os.environ.get(
"JWT_SECRET", secrets.token_hex(32)
) # persist in env for prod
TOKEN_TTL = int(os.environ.get("JWT_TTL_SECONDS", 30 * 24 * 3600)) # 30 days
auth_bp = Blueprint("auth_v1", __name__, url_prefix="/api/v1/auth")
# ─── DB helpers ───────────────────────────────────────────────────────────────
def get_db():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
return conn
def init_users_table():
"""Ensure users table exists."""
conn = get_db()
conn.executescript("""
CREATE TABLE IF NOT EXISTS saas_users (
id TEXT PRIMARY KEY,
email TEXT UNIQUE NOT NULL,
firstname TEXT DEFAULT '',
lastname TEXT DEFAULT '',
password_hash TEXT NOT NULL,
plan TEXT DEFAULT 'free',
telegram_chat_id TEXT DEFAULT NULL,
alert_value_bets INTEGER DEFAULT 1,
alert_top1 INTEGER DEFAULT 1,
alert_quinte_only INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS saas_tokens (
token TEXT PRIMARY KEY,
user_id TEXT NOT NULL,
expires_at INTEGER NOT NULL,
created_at TEXT DEFAULT (datetime('now'))
);
""")
conn.commit()
conn.close()
try:
init_users_table()
except Exception as e:
print(f"[auth_bp] DB init warning: {e}")
# ─── Token helpers ────────────────────────────────────────────────────────────
def generate_token(user_id: str) -> str:
token = secrets.token_urlsafe(48)
expires = int(time.time()) + TOKEN_TTL
conn = get_db()
conn.execute(
"INSERT INTO saas_tokens (token, user_id, expires_at) VALUES (?,?,?)",
(token, user_id, expires),
)
conn.commit()
conn.close()
return token
def validate_token(token: str):
"""Returns user row dict or None."""
if not token:
return None
conn = get_db()
now = int(time.time())
row = conn.execute(
"SELECT t.user_id, u.* FROM saas_tokens t JOIN saas_users u ON t.user_id=u.id "
"WHERE t.token=? AND t.expires_at>?",
(token, now),
).fetchone()
conn.close()
return dict(row) if row else None
def hash_password(password: str) -> str:
return hashlib.sha256(password.encode("utf-8")).hexdigest()
def require_auth(f):
@wraps(f)
def decorated(*args, **kwargs):
auth = request.headers.get("Authorization", "")
token = (
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
)
user = validate_token(token)
if not user:
return jsonify({"error": "Non authentifié"}), 401
request.current_user = user
return f(*args, **kwargs)
return decorated
def user_to_dict(user) -> dict:
if isinstance(user, sqlite3.Row):
user = dict(user)
return {
"id": user.get("id"),
"email": user.get("email"),
"firstname": user.get("firstname", ""),
"lastname": user.get("lastname", ""),
"plan": user.get("plan", "free"),
"telegram_chat_id": user.get("telegram_chat_id"),
"alert_value_bets": bool(user.get("alert_value_bets", 1)),
"alert_top1": bool(user.get("alert_top1", 1)),
"alert_quinte_only": bool(user.get("alert_quinte_only", 0)),
"created_at": user.get("created_at"),
}
# ─── Routes ───────────────────────────────────────────────────────────────────
@auth_bp.route("/register", methods=["POST"])
def register():
"""POST /api/v1/auth/register"""
data = request.get_json(silent=True) or {}
email = (data.get("email") or "").strip().lower()
password = data.get("password") or ""
firstname = (data.get("firstname") or "").strip()
lastname = (data.get("lastname") or "").strip()
plan = data.get("plan", "free")
if not email or "@" not in email:
return jsonify({"error": "Adresse email invalide."}), 400
pwd_error = validate_password_strength(password)
if pwd_error:
return jsonify({"error": pwd_error}), 400
if plan not in ("free", "premium", "pro"):
plan = "free"
uid = secrets.token_hex(16)
pw_hash = hash_password(password)
conn = get_db()
try:
conn.execute(
"INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)",
(uid, email, firstname, lastname, pw_hash, plan),
)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return jsonify({"error": "Cette adresse email est déjà utilisée."}), 409
conn.close()
token = generate_token(uid)
user_row = validate_token(token)
return jsonify({"token": token, "user": user_to_dict(user_row)}), 201
@auth_bp.route("/login", methods=["POST"])
def login():
"""POST /api/v1/auth/login"""
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
pw_hash = hash_password(password)
conn = get_db()
user = conn.execute(
"SELECT * FROM saas_users WHERE email=? AND password_hash=?", (email, pw_hash)
).fetchone()
conn.close()
if not user:
return jsonify({"error": "Identifiants incorrects."}), 401
token = generate_token(user["id"])
return jsonify({"token": token, "user": user_to_dict(user)}), 200
@auth_bp.route("/me", methods=["GET"])
@require_auth
def me():
"""GET /api/v1/auth/me"""
return jsonify({"user": user_to_dict(request.current_user)}), 200
@auth_bp.route("/update-profile", methods=["POST"])
@require_auth
def update_profile():
"""POST /api/v1/auth/update-profile"""
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
fields = {}
if "firstname" in data:
fields["firstname"] = data["firstname"].strip()
if "lastname" in data:
fields["lastname"] = data["lastname"].strip()
if "email" in data:
email = data["email"].strip().lower()
if "@" not in email:
return jsonify({"error": "Email invalide."}), 400
fields["email"] = email
if not fields:
return jsonify({"ok": True}), 200
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
conn = get_db()
try:
conn.execute(
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
)
conn.commit()
except sqlite3.IntegrityError:
conn.close()
return jsonify({"error": "Cet email est déjà utilisé."}), 409
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/change-password", methods=["POST"])
@require_auth
def change_password():
"""POST /api/v1/auth/change-password"""
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
cur_pwd = data.get("current_password") or ""
new_pwd = data.get("new_password") or ""
pwd_error = validate_password_strength(new_pwd)
if pwd_error:
return jsonify({"error": pwd_error}), 400
conn = get_db()
user = conn.execute(
"SELECT * FROM saas_users WHERE id=? AND password_hash=?",
(uid, hash_password(cur_pwd)),
).fetchone()
if not user:
conn.close()
return jsonify({"error": "Mot de passe actuel incorrect."}), 401
conn.execute(
"UPDATE saas_users SET password_hash=?, updated_at=? WHERE id=?",
(hash_password(new_pwd), datetime.utcnow().isoformat(), uid),
)
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/update-plan", methods=["POST"])
@require_auth
def update_plan():
"""POST /api/v1/auth/update-plan"""
data = request.get_json(silent=True) or {}
plan = data.get("plan", "free")
if plan not in ("free", "premium", "pro"):
return jsonify({"error": "Plan invalide."}), 400
uid = request.current_user["id"]
conn = get_db()
conn.execute(
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
(plan, datetime.utcnow().isoformat(), uid),
)
conn.commit()
conn.close()
return jsonify({"ok": True, "plan": plan}), 200
@auth_bp.route("/update-preferences", methods=["POST"])
@require_auth
def update_preferences():
"""POST /api/v1/auth/update-preferences"""
data = request.get_json(silent=True) or {}
uid = request.current_user["id"]
fields = {}
if "telegram_chat_id" in data:
fields["telegram_chat_id"] = data["telegram_chat_id"] or None
if "alert_value_bets" in data:
fields["alert_value_bets"] = 1 if data["alert_value_bets"] else 0
if "alert_top1" in data:
fields["alert_top1"] = 1 if data["alert_top1"] else 0
if "alert_quinte_only" in data:
fields["alert_quinte_only"] = 1 if data["alert_quinte_only"] else 0
if fields:
set_clause = ", ".join(f"{k}=?" for k in fields)
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
conn = get_db()
conn.execute(
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
)
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/logout", methods=["POST"])
@require_auth
def logout():
"""POST /api/v1/auth/logout"""
auth = request.headers.get("Authorization", "")
token = auth.removeprefix("Bearer ").strip()
conn = get_db()
conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,))
conn.commit()
conn.close()
return jsonify({"ok": True}), 200
@auth_bp.route("/delete-account", methods=["DELETE"])
@require_auth
def delete_account():
"""DELETE /api/v1/auth/delete-account"""
uid = request.current_user["id"]
conn = get_db()
conn.execute("DELETE FROM saas_tokens WHERE user_id=?", (uid,))
conn.execute("DELETE FROM saas_users WHERE id=?", (uid,))
conn.commit()
conn.close()
return jsonify({"ok": True}), 200

View File

@@ -10,7 +10,7 @@ import json
import re
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'}
def get_cote_from_db(horse_name, date_course):

View File

@@ -303,6 +303,91 @@ 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}"
)
if __name__ == "__main__":
import subprocess

473
tests/test_api_v1.py Normal file
View File

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

404
tests/test_auth.py Normal file
View File

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