Compare commits
36 Commits
feature/la
...
feature/HR
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f300e44c74 | ||
|
|
bc5ee3fa1a | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
225295030b | ||
|
|
86e85aa1c6 | ||
| 5aa6013c52 | |||
|
|
4b4323f707 | ||
|
|
356bdf5bec | ||
|
|
f9a45e6deb | ||
|
|
cfc0f038f9 | ||
|
|
c999285895 | ||
|
|
e517741c97 | ||
| 837a0845ec | |||
|
|
4bf458f1b8 | ||
|
|
099286b078 | ||
|
|
d39c7d3319 | ||
|
|
8c5fdf1e9c | ||
|
|
7f5573f076 | ||
|
|
82d6bdafba | ||
|
|
36d93697bc | ||
| 2f57719b21 | |||
| bffc06c9b1 | |||
| f1ef2648b1 | |||
|
|
6b762068fd | ||
|
|
0e7bcff6b0 | ||
|
|
ce0ee150ec | ||
|
|
b8ef1ed35d | ||
|
|
793ee82c29 | ||
|
|
c8f1bfd478 | ||
|
|
5a23692ad1 | ||
|
|
dce1e9b744 |
68
.dockerignore
Normal file
68
.dockerignore
Normal 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
82
.env.example
Normal 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
205
.github/workflows/cd.yml
vendored
Normal 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
236
.github/workflows/ci.yml
vendored
Normal 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
28
.gitignore
vendored
@@ -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
132
API_AUTH.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# API Auth JWT — Documentation
|
||||
## Sprint 2-3 (HRT-28)
|
||||
|
||||
Base URL: `http://localhost:8792`
|
||||
|
||||
---
|
||||
|
||||
## Endpoints d'authentification
|
||||
|
||||
### `POST /api/v1/auth/register`
|
||||
Inscription d'un nouvel utilisateur (plan free par défaut).
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||
```
|
||||
**Réponse 201:**
|
||||
```json
|
||||
{ "message": "Compte créé avec succès", "user_id": 1 }
|
||||
```
|
||||
**Erreurs:** `400` (email invalide / mot de passe < 8 car.), `409` (email déjà utilisé)
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/login`
|
||||
Connexion — retourne access_token (15min) + refresh_token (30j).
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "email": "user@example.com", "password": "motdepasse123" }
|
||||
```
|
||||
**Réponse 200:**
|
||||
```json
|
||||
{
|
||||
"access_token": "<JWT>",
|
||||
"refresh_token": "<refresh_JWT>",
|
||||
"token_type": "Bearer",
|
||||
"plan": "free"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/refresh`
|
||||
Rotation du refresh token — invalide l'ancien, émet un nouveau.
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "refresh_token": "<refresh_JWT>" }
|
||||
```
|
||||
**Réponse 200:** identique à `/login`
|
||||
|
||||
---
|
||||
|
||||
### `POST /api/v1/auth/logout`
|
||||
Révocation du refresh token.
|
||||
|
||||
**Body JSON:**
|
||||
```json
|
||||
{ "refresh_token": "<refresh_JWT>" }
|
||||
```
|
||||
**Réponse 200:**
|
||||
```json
|
||||
{ "message": "Déconnexion réussie" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Routes protégées
|
||||
|
||||
Toutes les routes protégées nécessitent le header:
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### `GET /api/v1/predictions`
|
||||
| Plan | Accès |
|
||||
|---------|---------------------------------------------|
|
||||
| free | Top 3 uniquement, 1 course/jour |
|
||||
| premium | Toutes les courses + alertes Telegram |
|
||||
| pro | API complète + lien export CSV |
|
||||
|
||||
### `GET /api/v1/predictions/export`
|
||||
Export CSV — **plan pro uniquement** (`403` pour free/premium).
|
||||
|
||||
### `GET /api/v1/subscription/upgrade`
|
||||
Infos sur les plans disponibles et plan courant de l'utilisateur.
|
||||
|
||||
### `GET /api/v1/health`
|
||||
Vérification d'état du service (pas d'auth requise).
|
||||
|
||||
---
|
||||
|
||||
## Sécurité
|
||||
|
||||
- **Passwords:** hashés avec bcrypt (saltRounds=12)
|
||||
- **JWT access:** expiration 15 minutes (HS256)
|
||||
- **JWT refresh:** expiration 30 jours, stocké hashé (SHA-256) en DB, rotation à chaque usage
|
||||
- **Rate limiting:** 100 requêtes/min par IP — header `X-RateLimit-Remaining`
|
||||
- **CORS:** configuré pour `https://turf-ia.h3r7.tech` + localhost dev
|
||||
- **Logs d'accès:** horodatés ISO 8601 dans `logs/saas_api.log`
|
||||
|
||||
---
|
||||
|
||||
## Lancement
|
||||
|
||||
```bash
|
||||
JWT_SECRET_KEY="votre_cle_secrete" \
|
||||
CORS_ORIGINS="https://turf-ia.h3r7.tech" \
|
||||
./venv/bin/python saas_api.py
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
./venv/bin/pytest tests/test_auth.py -v
|
||||
# Avec couverture:
|
||||
./venv/bin/pytest tests/test_auth.py --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
|
||||
# Résultat: 27 tests OK, couverture globale 83%
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Structure des tables DB
|
||||
|
||||
```sql
|
||||
-- users: id, email, password_hash, plan(free/premium/pro), created_at, is_active, daily_usage, last_usage_date
|
||||
-- subscriptions: id, user_id, plan, start_date, end_date, stripe_customer_id
|
||||
-- refresh_tokens: id, user_id, token_hash, created_at, expires_at, revoked
|
||||
```
|
||||
68
Dockerfile
Normal file
68
Dockerfile
Normal 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
156
README_API_V1.md
Normal file
@@ -0,0 +1,156 @@
|
||||
# Turf SaaS — API v1 Reference
|
||||
|
||||
Sprint 3-4 · HRT-29 — Refacto API /v1/
|
||||
|
||||
## Base URL
|
||||
|
||||
```
|
||||
http://<host>:8792
|
||||
```
|
||||
|
||||
## Authentication
|
||||
|
||||
All endpoints (except `/api/v1/health` and `/api/v1/auth/*`) require a **Bearer JWT** token.
|
||||
|
||||
```
|
||||
Authorization: Bearer <access_token>
|
||||
```
|
||||
|
||||
### Get a token
|
||||
|
||||
```bash
|
||||
# Register
|
||||
curl -X POST http://localhost:8792/api/v1/auth/register \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
|
||||
# Login → returns access_token + refresh_token
|
||||
curl -X POST http://localhost:8792/api/v1/auth/login \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email": "user@example.com", "password": "mypassword"}'
|
||||
```
|
||||
|
||||
## Plans & Access Control
|
||||
|
||||
| Plan | Inclus |
|
||||
|-----------|----------------------------------------------------|
|
||||
| `free` | health, auth, courses/today, predictions/top3 (1/j)|
|
||||
| `premium` | + predictions/all, valuebets, metrics |
|
||||
| `pro` | + backtest, export/csv |
|
||||
|
||||
## Endpoints
|
||||
|
||||
### System
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------------------|------|----------------------|
|
||||
| GET | `/api/v1/health` | Non | Healthcheck public |
|
||||
| GET | `/api/v1/docs` | Non | Swagger UI |
|
||||
|
||||
### Auth
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|---------------------------|--------------------------------|
|
||||
| POST | `/api/v1/auth/register` | Créer un compte (plan=free) |
|
||||
| POST | `/api/v1/auth/login` | Login → JWT tokens |
|
||||
| POST | `/api/v1/auth/refresh` | Renouveler l'access token |
|
||||
| POST | `/api/v1/auth/logout` | Révoquer le refresh token |
|
||||
|
||||
### Courses
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------------------------|---------|------------------------------------|
|
||||
| GET | `/api/v1/courses/today` | free+ | Courses du jour (paginé) |
|
||||
| GET | `/api/v1/courses/{id}/predictions` | free+ | Prédictions ML pour une course |
|
||||
|
||||
Query params `courses/today`: `filter=[all|quinte|trot|plat]`, `limit`, `offset`
|
||||
|
||||
`{id}` format: `{num_reunion}-{num_course}` ex: `1-3`
|
||||
|
||||
### Prédictions
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------------|-----------|------------------------------|
|
||||
| GET | `/api/v1/predictions/top3`| free+ | Top 3 chevaux du jour |
|
||||
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions ML |
|
||||
|
||||
Query params: `date=YYYY-MM-DD`, `limit`, `offset`
|
||||
|
||||
### Value Bets
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|-----------|--------------------------|
|
||||
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour |
|
||||
|
||||
Query params: `date`, `min_odds` (défaut 2.0), `limit`, `offset`
|
||||
|
||||
### Backtest
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|------|----------------------------------|
|
||||
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
|
||||
|
||||
Query params: `start`, `end` (YYYY-MM-DD), `limit`, `offset`
|
||||
|
||||
### Export
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|-------------------------|------|----------------------|
|
||||
| GET | `/api/v1/export/csv` | pro | Export CSV |
|
||||
|
||||
Query params: `type=[predictions|bets]`, `date`, `start`, `end`
|
||||
|
||||
### Métriques
|
||||
|
||||
| Method | Path | Plan | Description |
|
||||
|--------|---------------------|----------|-----------------------|
|
||||
| GET | `/api/v1/metrics` | premium+ | Métriques ML et paris |
|
||||
|
||||
Query params: `days` (int, défaut 30)
|
||||
|
||||
## Réponse uniforme
|
||||
|
||||
Toutes les erreurs retournent :
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description de l'erreur",
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
Les listes paginées incluent :
|
||||
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python app_v1.py
|
||||
# ou
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
```
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/test_api_v1.py -v
|
||||
```
|
||||
|
||||
## Documentation Swagger
|
||||
|
||||
Accessible sur : `http://localhost:8792/api/v1/docs`
|
||||
412
account.html
412
account.html
@@ -1,63 +1,91 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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}
|
||||
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{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}
|
||||
.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-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{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-card{border-color:rgba(248,81,73,.3)}
|
||||
#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}
|
||||
#toast.info{background:var(--blue);color:#fff}
|
||||
@media(max-width:600px){.form-row{grid-template-columns:1fr}main{padding:20px 4%}}
|
||||
*, *::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>
|
||||
@@ -67,110 +95,288 @@
|
||||
</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.</div>
|
||||
<div class="alert alert-error" id="profile-error"></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>Email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
|
||||
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer</button>
|
||||
<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>
|
||||
<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="••••••••" required></div>
|
||||
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" required></div>
|
||||
<div class="form-group"><label>Confirmer</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" required></div>
|
||||
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour</button>
|
||||
<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.</p>
|
||||
<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/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></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></ul><button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button></div>
|
||||
<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 Stripe disponible dans le Sprint 5-6.</p>
|
||||
<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="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">
|
||||
<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>
|
||||
<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</button>
|
||||
<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)}}
|
||||
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')})});
|
||||
(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))}})();
|
||||
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('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;
|
||||
const plan=user.plan||'free';
|
||||
['free','premium','pro'].forEach(p=>{const card=document.getElementById('plan-card-'+p);if(!card)return;card.classList.toggle('current',p===plan);const ex=card.querySelector('.plan-current-badge');if(p===plan&&!ex){const b=document.createElement('div');b.className='plan-current-badge';b.textContent='✓ Actuel';card.prepend(b)}else if(p!==plan&&ex)ex.remove()});
|
||||
localStorage.setItem('turf_user',JSON.stringify(user));
|
||||
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);
|
||||
}
|
||||
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';if(res&&res.ok!==false){showAlert('profile-success');showToast('Profil mis à jour.')}else setAlertMsg('profile-error',res?.error||'Erreur.')});
|
||||
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,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;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;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.')});
|
||||
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')});
|
||||
['free','premium','pro'].forEach(p=>{document.getElementById('select-'+p+'-btn')?.addEventListener('click',()=>{showToast(p==='free'?'Vous êtes sur le plan Free.':'Paiement Stripe disponible dans le Sprint 5-6.','info')})});
|
||||
document.getElementById('delete-account-btn').addEventListener('click',()=>{if(confirm('Supprimer votre compte définitivement ?')){if(confirm('Confirmez la suppression.'))fetchJson(API+'/auth/delete-account',{method:'DELETE'}).then(()=>{localStorage.clear();location.href='/'})}});
|
||||
|
||||
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>
|
||||
|
||||
48
alembic.ini
Normal file
48
alembic.ini
Normal 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
|
||||
57
api_tokens_db.py
Normal file
57
api_tokens_db.py
Normal file
@@ -0,0 +1,57 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
api_tokens_db.py — DB migration for personal API tokens + user webhooks
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.api_tokens_db")
|
||||
|
||||
|
||||
def get_db() -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_api_tokens_tables() -> None:
|
||||
"""Idempotent migration: create user_api_tokens and user_webhooks."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS user_api_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
token_prefix TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
last_used_at DATETIME,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_user ON user_api_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_api_tokens_hash ON user_api_tokens(token_hash);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS user_webhooks (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL UNIQUE,
|
||||
url TEXT NOT NULL,
|
||||
secret TEXT NOT NULL,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_webhooks_user ON user_webhooks(user_id);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info(
|
||||
"[api_tokens_db] Tables user_api_tokens + user_webhooks created/verified."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_api_tokens_tables()
|
||||
print("[api_tokens_db] Migration complete.")
|
||||
55
api_v1/__init__.py
Normal file
55
api_v1/__init__.py
Normal file
@@ -0,0 +1,55 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API v1 Blueprint package — Turf SaaS
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
Sprint 5-6: HRT-31 — Billing Stripe
|
||||
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
|
||||
Registers sub-blueprints:
|
||||
/api/v1/health — public health-check
|
||||
/api/v1/courses/ — courses du jour
|
||||
/api/v1/predictions/— predictions ML
|
||||
/api/v1/valuebets — value bets (premium+)
|
||||
/api/v1/backtest — backtest historique (pro)
|
||||
/api/v1/export/ — export CSV (pro)
|
||||
/api/v1/metrics — métriques perf ML (premium+)
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||
/api/v1/user/api-token — Personal API token (Pro)
|
||||
/api/v1/user/webhook — Webhook config (Pro)
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/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
|
||||
from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
def register_api_v1(app):
|
||||
"""Register all API v1 blueprints onto the Flask app."""
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(courses_bp)
|
||||
app.register_blueprint(predictions_bp)
|
||||
app.register_blueprint(valuebets_bp)
|
||||
app.register_blueprint(backtest_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
0
api_v1/routes/__init__.py
Normal file
0
api_v1/routes/__init__.py
Normal file
195
api_v1/routes/backtest.py
Normal file
195
api_v1/routes/backtest.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backtest route for API v1.
|
||||
|
||||
GET /api/v1/backtest — Résultats backtest historiques (pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
backtest_bp = Blueprint("v1_backtest", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@backtest_bp.route("/backtest", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def backtest():
|
||||
"""
|
||||
Backtest historique
|
||||
---
|
||||
tags:
|
||||
- Backtest
|
||||
summary: Résultats backtest historiques des paris simulés — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD), défaut = -30j
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD), défaut = aujourd'hui
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Résultats backtest
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
|
||||
# Validate date formats
|
||||
for label, val in [("start", start), ("end", end)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if not start:
|
||||
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
if not end:
|
||||
end = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": 0,
|
||||
"message": "Aucune donnée bet_results",
|
||||
},
|
||||
"by_type": {},
|
||||
"details": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Summary
|
||||
summary_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
|
||||
total_bets = summary_row["total"] or 0
|
||||
gagne = summary_row["gagne"] or 0
|
||||
mise = float(summary_row["mise"] or 0)
|
||||
gain = float(summary_row["gain"] or 0)
|
||||
roi = round((gain - mise) / mise * 100, 1) if mise > 0 else 0.0
|
||||
precision = round(gagne / total_bets * 100, 1) if total_bets > 0 else 0.0
|
||||
|
||||
# By type
|
||||
by_type_rows = conn.execute(
|
||||
"""SELECT
|
||||
type_pari,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
GROUP BY type_pari""",
|
||||
(start, end),
|
||||
).fetchall()
|
||||
|
||||
by_type = {}
|
||||
for row in by_type_rows:
|
||||
t = row["total"] or 0
|
||||
g = row["gagne"] or 0
|
||||
m = float(row["mise"] or 0)
|
||||
gn = float(row["gain"] or 0)
|
||||
by_type[row["type_pari"]] = {
|
||||
"count": t,
|
||||
"gagne": g,
|
||||
"mise": round(m, 2),
|
||||
"gain": round(gn, 2),
|
||||
"roi": round((gn - m) / m * 100, 1) if m > 0 else 0.0,
|
||||
"precision": round(g / t * 100, 1) if t > 0 else 0.0,
|
||||
}
|
||||
|
||||
# Paginated details
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM bet_results WHERE date BETWEEN ? AND ?",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
detail_total = count_row["cnt"] if count_row else 0
|
||||
|
||||
detail_rows = conn.execute(
|
||||
"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC, id DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(start, end, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
details = [dict(r) for r in detail_rows]
|
||||
pagination = paginate_query(details, detail_total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": total_bets,
|
||||
"gagne": gagne,
|
||||
"perdu": total_bets - gagne,
|
||||
"precision": precision,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
"roi": roi,
|
||||
},
|
||||
"by_type": by_type,
|
||||
"details": details,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
664
api_v1/routes/billing.py
Normal file
664
api_v1/routes/billing.py
Normal file
@@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Billing Blueprint — Stripe integration
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
|
||||
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
|
||||
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
|
||||
GET /api/v1/billing/status — current subscription status (auth required)
|
||||
|
||||
Environment variables required:
|
||||
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
|
||||
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
|
||||
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
|
||||
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
|
||||
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
|
||||
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import stripe
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from billing_db import get_db, migrate_billing_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.billing")
|
||||
|
||||
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Stripe configuration
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
|
||||
|
||||
# Plan → Stripe Price ID mapping
|
||||
PLAN_PRICE_IDS = {
|
||||
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
|
||||
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
|
||||
}
|
||||
|
||||
# Plan display names
|
||||
PLAN_NAMES = {
|
||||
"free": "Free",
|
||||
"premium": "Premium",
|
||||
"pro": "Pro",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DB helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sget(obj, key, default=None):
|
||||
"""Safely get a value from a dict OR a Stripe StripeObject.
|
||||
|
||||
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
|
||||
"""
|
||||
try:
|
||||
# StripeObject supports [] but not .get(); dict supports both
|
||||
val = obj[key]
|
||||
return val if val is not None else default
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_active_subscription(db, user_id):
|
||||
"""Return the most recent active subscription row for a user."""
|
||||
return db.execute(
|
||||
"""SELECT * FROM saas_subscriptions
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1""",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id, **fields):
|
||||
"""
|
||||
Update existing subscription or insert a new one.
|
||||
fields: plan, stripe_customer_id, stripe_subscription_id,
|
||||
status, current_period_end, grace_period_end, end_date
|
||||
"""
|
||||
existing = _get_active_subscription(db, user_id)
|
||||
if existing:
|
||||
# Build SET clause dynamically from provided fields
|
||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [existing["id"]]
|
||||
db.execute(f"UPDATE saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [str(user_id)] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
|
||||
|
||||
def _update_user_plan(db, user_id, plan: str):
|
||||
"""Sync saas_users.plan field to match active subscription."""
|
||||
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(user_id)))
|
||||
|
||||
|
||||
def _get_or_create_stripe_customer(user, db) -> str:
|
||||
"""Return existing stripe_customer_id or create a new Stripe Customer."""
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
if sub and sub["stripe_customer_id"]:
|
||||
return sub["stripe_customer_id"]
|
||||
|
||||
# Create new customer in Stripe
|
||||
customer = stripe.Customer.create(
|
||||
email=user["email"],
|
||||
metadata={"user_id": str(user["id"])},
|
||||
)
|
||||
return customer["id"]
|
||||
|
||||
|
||||
def _record_billing_event(
|
||||
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
|
||||
):
|
||||
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
|
||||
try:
|
||||
db.execute(
|
||||
"""INSERT OR IGNORE INTO billing_events
|
||||
(stripe_event_id, event_type, user_id, payload)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(
|
||||
stripe_event_id,
|
||||
event_type,
|
||||
user_id,
|
||||
json.dumps(payload) if payload else None,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/checkout
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/checkout", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_checkout():
|
||||
"""
|
||||
Create a Stripe Checkout session for upgrading to Premium or Pro.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [plan]
|
||||
properties:
|
||||
plan:
|
||||
type: string
|
||||
enum: [premium, pro]
|
||||
responses:
|
||||
200:
|
||||
description: Checkout session URL
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
checkout_url:
|
||||
type: string
|
||||
session_id:
|
||||
type: string
|
||||
400:
|
||||
description: Invalid plan or Stripe not configured
|
||||
503:
|
||||
description: Stripe API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
plan = body.get("plan", "").lower()
|
||||
|
||||
if plan not in ("premium", "pro"):
|
||||
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
|
||||
|
||||
price_id = PLAN_PRICE_IDS.get(plan)
|
||||
if not price_id:
|
||||
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
||||
|
||||
user = request.current_user
|
||||
if user["plan"] == plan:
|
||||
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
customer_id = _get_or_create_stripe_customer(user, db)
|
||||
# Persist customer_id early to prevent duplicates
|
||||
_upsert_subscription(
|
||||
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
mode="subscription",
|
||||
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{APP_BASE_URL}/billing/cancel",
|
||||
metadata={"user_id": str(user["id"]), "plan": plan},
|
||||
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"checkout_url": session.url,
|
||||
"session_id": session.id,
|
||||
"plan": plan,
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/portal
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/portal", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_portal():
|
||||
"""
|
||||
Create a Stripe Customer Portal session for managing subscription.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Portal session URL
|
||||
400:
|
||||
description: No Stripe customer found
|
||||
503:
|
||||
description: Stripe not configured or API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
customer_id = sub["stripe_customer_id"] if sub else None
|
||||
|
||||
if not customer_id:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Aucun abonnement Stripe trouvé. "
|
||||
"Souscrivez d'abord à un plan payant."
|
||||
}
|
||||
), 400
|
||||
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=f"{APP_BASE_URL}/account",
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe portal error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"portal_url": session.url}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/billing/status
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/status", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def billing_status():
|
||||
"""
|
||||
Return current subscription status for the authenticated user.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Subscription status
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not sub:
|
||||
return jsonify(
|
||||
{
|
||||
"plan": "free",
|
||||
"status": "active",
|
||||
"stripe_customer_id": None,
|
||||
"stripe_subscription_id": None,
|
||||
"current_period_end": None,
|
||||
"grace_period_end": None,
|
||||
}
|
||||
), 200
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"plan": sub["plan"],
|
||||
"status": sub["status"] or "active",
|
||||
"stripe_customer_id": sub["stripe_customer_id"],
|
||||
"stripe_subscription_id": sub["stripe_subscription_id"],
|
||||
"start_date": sub["start_date"],
|
||||
"end_date": sub["end_date"],
|
||||
"current_period_end": sub["current_period_end"],
|
||||
"grace_period_end": sub["grace_period_end"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/webhook
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/webhook", methods=["POST"])
|
||||
def stripe_webhook():
|
||||
"""
|
||||
Stripe webhook handler — no auth, signature-verified.
|
||||
|
||||
Handled events:
|
||||
checkout.session.completed → activate subscription
|
||||
customer.subscription.updated → sync plan/status
|
||||
customer.subscription.deleted → downgrade to free
|
||||
invoice.payment_failed → set past_due + 3-day grace period
|
||||
invoice.payment_succeeded → clear grace period
|
||||
"""
|
||||
payload = request.get_data()
|
||||
sig_header = request.headers.get("Stripe-Signature", "")
|
||||
|
||||
# Verify webhook signature (required in production)
|
||||
if STRIPE_WEBHOOK_SECRET:
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except stripe.SignatureVerificationError as e:
|
||||
logger.warning("Stripe webhook signature invalid: %s", e)
|
||||
return jsonify({"error": "Signature invalide"}), 400
|
||||
except ValueError as e:
|
||||
logger.warning("Stripe webhook payload invalid: %s", e)
|
||||
return jsonify({"error": "Payload invalide"}), 400
|
||||
else:
|
||||
# Dev/test: accept without verification (log a warning)
|
||||
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
|
||||
try:
|
||||
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
|
||||
|
||||
event_type = event["type"]
|
||||
event_id = event["id"]
|
||||
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
if event_type == "checkout.session.completed":
|
||||
_handle_checkout_completed(db, event)
|
||||
|
||||
elif event_type in (
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.created",
|
||||
):
|
||||
_handle_subscription_updated(db, event)
|
||||
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
_handle_subscription_deleted(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_failed":
|
||||
_handle_payment_failed(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
_handle_payment_succeeded(db, event)
|
||||
|
||||
else:
|
||||
logger.debug("Unhandled Stripe event type: %s", event_type)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Webhook handlers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_user_from_customer(db, customer_id: str):
|
||||
"""Look up user_id via subscriptions.stripe_customer_id."""
|
||||
row = db.execute(
|
||||
"SELECT user_id FROM saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||
(customer_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["user_id"]
|
||||
|
||||
# Fallback: query Stripe for user_id metadata
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
meta = _sget(customer, "metadata") or {}
|
||||
uid = _sget(meta, "user_id")
|
||||
if uid:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_plan_from_price(price_id: str) -> str:
|
||||
"""Map Stripe price ID to internal plan name."""
|
||||
for plan, pid in PLAN_PRICE_IDS.items():
|
||||
if pid and pid == price_id:
|
||||
return plan
|
||||
# Unknown price — default to premium (safer than pro)
|
||||
return "premium"
|
||||
|
||||
|
||||
def _handle_checkout_completed(db, event):
|
||||
"""checkout.session.completed → activate subscription for the user."""
|
||||
session = event["data"]["object"]
|
||||
customer_id = _sget(session, "customer")
|
||||
subscription_id = _sget(session, "subscription")
|
||||
metadata = _sget(session, "metadata") or {}
|
||||
plan = _sget(metadata, "plan") or "premium"
|
||||
user_id = _sget(metadata, "user_id")
|
||||
|
||||
if user_id:
|
||||
user_id = str(user_id)
|
||||
else:
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"checkout.session.completed: cannot resolve user for customer %s",
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch subscription details from Stripe
|
||||
current_period_end = None
|
||||
if subscription_id:
|
||||
try:
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
sub["current_period_end"], tz=timezone.utc
|
||||
).isoformat()
|
||||
# Sync plan from price if metadata plan is missing
|
||||
if sub["items"]["data"]:
|
||||
price_id = sub["items"]["data"][0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status="active",
|
||||
current_period_end=current_period_end,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
|
||||
|
||||
|
||||
def _handle_subscription_updated(db, event):
|
||||
"""customer.subscription.updated → sync status and plan."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
subscription_id = _sget(sub_obj, "id")
|
||||
stripe_status = _sget(sub_obj, "status") or "active"
|
||||
current_period_end = None
|
||||
|
||||
cpe = _sget(sub_obj, "current_period_end")
|
||||
if cpe:
|
||||
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
|
||||
|
||||
# Resolve plan from price
|
||||
plan = "premium"
|
||||
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
|
||||
if items_data:
|
||||
price_id = items_data[0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
# Try metadata
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.updated: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status=stripe_status,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info(
|
||||
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
|
||||
)
|
||||
|
||||
|
||||
def _handle_subscription_deleted(db, event):
|
||||
"""customer.subscription.deleted → downgrade to free."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.deleted: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan="free",
|
||||
stripe_subscription_id=None,
|
||||
status="canceled",
|
||||
end_date=datetime.now(timezone.utc).isoformat(),
|
||||
current_period_end=None,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, "free")
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("subscription.deleted: user %s downgraded to free", user_id)
|
||||
|
||||
|
||||
def _handle_payment_failed(db, event):
|
||||
"""invoice.payment_failed → mark past_due + 3-day grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
subscription_id = _sget(invoice, "subscription")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
|
||||
|
||||
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
|
||||
_record_billing_event(
|
||||
db,
|
||||
event["id"],
|
||||
event["type"],
|
||||
user_id=user_id,
|
||||
payload={"subscription_id": subscription_id},
|
||||
)
|
||||
|
||||
# TODO: send notification email via /api/notifications
|
||||
logger.warning(
|
||||
"invoice.payment_failed: user %s past_due, grace period until %s",
|
||||
user_id,
|
||||
grace_end,
|
||||
)
|
||||
|
||||
|
||||
def _handle_payment_succeeded(db, event):
|
||||
"""invoice.payment_succeeded → clear past_due / grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
# Refresh subscription period end
|
||||
current_period_end = None
|
||||
lines = _sget(invoice, "lines") or {}
|
||||
lines_data = _sget(lines, "data") or []
|
||||
if lines_data:
|
||||
period = lines_data[0].get("period") or {}
|
||||
period_end = (
|
||||
period.get("end") if isinstance(period, dict) else _sget(period, "end")
|
||||
)
|
||||
if period_end:
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
period_end, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
status="active",
|
||||
grace_period_end=None,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import: ensure DB migration ran
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_billing_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("billing_db migration skipped (test env?): %s", _e)
|
||||
277
api_v1/routes/courses.py
Normal file
277
api_v1/routes/courses.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Courses routes for API v1.
|
||||
|
||||
GET /api/v1/courses/today — liste des courses du jour (public, paginated)
|
||||
GET /api/v1/courses/{id}/predictions — prédictions ML pour une course (free tier, 1/day limit)
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
error_response,
|
||||
bad_request,
|
||||
not_found,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, free_daily_limit_check
|
||||
|
||||
courses_bp = Blueprint("v1_courses", __name__, url_prefix="/api/v1/courses")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/today
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/today", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def courses_today():
|
||||
"""
|
||||
Courses du jour
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Liste toutes les courses du jour avec info course
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
type: string
|
||||
enum: [all, quinte, trot, plat]
|
||||
default: all
|
||||
description: Filtre par type de course
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Liste des courses du jour
|
||||
401:
|
||||
description: Token manquant ou invalide
|
||||
"""
|
||||
race_filter = request.args.get("filter", "all").lower()
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Build SQL condition
|
||||
if race_filter == "quinte":
|
||||
cond = "AND (c.libelle LIKE '%Quinté%' OR c.libelle LIKE '%Quinte%')"
|
||||
elif race_filter == "trot":
|
||||
cond = "AND c.discipline LIKE '%Trot%'"
|
||||
elif race_filter == "plat":
|
||||
cond = "AND c.discipline LIKE '%Plat%'"
|
||||
else:
|
||||
cond = ""
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Graceful handling if pmu_courses table doesn't exist yet
|
||||
if not table_exists(conn, "pmu_courses"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Count total
|
||||
count_row = conn.execute(
|
||||
f"""SELECT COUNT(*) as cnt
|
||||
FROM pmu_courses c
|
||||
WHERE c.date_programme = ? {cond}""",
|
||||
(today,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT
|
||||
c.date_programme,
|
||||
c.num_reunion,
|
||||
c.num_course,
|
||||
c.libelle,
|
||||
c.discipline,
|
||||
c.distance,
|
||||
c.hippodrome,
|
||||
c.px_type,
|
||||
COUNT(p.id_cheval) as nb_partants
|
||||
FROM pmu_courses c
|
||||
LEFT JOIN pmu_partants p
|
||||
ON p.date_programme = c.date_programme
|
||||
AND p.num_reunion = c.num_reunion
|
||||
AND p.num_course = c.num_course
|
||||
WHERE c.date_programme = ? {cond}
|
||||
GROUP BY c.date_programme, c.num_reunion, c.num_course
|
||||
ORDER BY c.num_reunion ASC, c.num_course ASC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(today, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
courses = []
|
||||
for r in rows:
|
||||
course_id = f"{r['num_reunion']}-{r['num_course']}"
|
||||
courses.append(
|
||||
{
|
||||
"id": course_id,
|
||||
"date": r["date_programme"],
|
||||
"num_reunion": r["num_reunion"],
|
||||
"num_course": r["num_course"],
|
||||
"libelle": r["libelle"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"hippodrome": r["hippodrome"],
|
||||
"type_pari": r["px_type"],
|
||||
"nb_partants": r["nb_partants"],
|
||||
}
|
||||
)
|
||||
|
||||
pagination = paginate_query(courses, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": courses,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/<course_id>/predictions
|
||||
# course_id format: "{num_reunion}-{num_course}" e.g. "1-3"
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/<course_id>/predictions", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def course_predictions(course_id):
|
||||
"""
|
||||
Prédictions pour une course
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Prédictions ML pour une course identifiée par {num_reunion}-{num_course}
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: course_id
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
description: Identifiant de la course (format num_reunion-num_course, ex "1-3")
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de la course (YYYY-MM-DD), défaut = aujourd'hui
|
||||
responses:
|
||||
200:
|
||||
description: Prédictions ML pour la course
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
404:
|
||||
description: Course introuvable
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
# Parse course_id
|
||||
parts = course_id.split("-")
|
||||
if len(parts) != 2:
|
||||
return bad_request(
|
||||
"course_id doit être au format {num_reunion}-{num_course}, ex: 1-3"
|
||||
)
|
||||
|
||||
try:
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except ValueError:
|
||||
return bad_request("num_reunion et num_course doivent être des entiers")
|
||||
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Fetch course info
|
||||
course_row = conn.execute(
|
||||
"""SELECT libelle, discipline, distance, hippodrome, px_type
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchone()
|
||||
|
||||
if not course_row:
|
||||
return not_found(
|
||||
f"Course R{num_reunion}C{num_course} introuvable pour le {date_param}"
|
||||
)
|
||||
|
||||
# Fetch ML predictions from cache
|
||||
preds = []
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
preds = conn.execute(
|
||||
"""SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
# Fetch partants
|
||||
partants = conn.execute(
|
||||
"""SELECT nom, num_pmu, cote_direct, cote_reference, tendance_cote, favoris,
|
||||
tx_victoire, tx_place, forme_recente, driver, entraineur, musique
|
||||
FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY num_pmu ASC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"course": {
|
||||
"id": course_id,
|
||||
"libelle": course_row["libelle"],
|
||||
"discipline": course_row["discipline"],
|
||||
"distance": course_row["distance"],
|
||||
"hippodrome": course_row["hippodrome"],
|
||||
"type_pari": course_row["px_type"],
|
||||
},
|
||||
"predictions": [dict(p) for p in preds],
|
||||
"partants": [dict(p) for p in partants],
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
185
api_v1/routes/export.py
Normal file
185
api_v1/routes/export.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export route for API v1.
|
||||
|
||||
GET /api/v1/export/csv — Export CSV des prédictions ou paris (pro)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
export_bp = Blueprint("v1_export", __name__, url_prefix="/api/v1/export")
|
||||
|
||||
# Maximum rows exportable in one request
|
||||
EXPORT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
@export_bp.route("/csv", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def export_csv():
|
||||
"""
|
||||
Export CSV
|
||||
---
|
||||
tags:
|
||||
- Export
|
||||
summary: Export CSV des prédictions ML ou des paris historiques — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
type: string
|
||||
enum: [predictions, bets]
|
||||
default: predictions
|
||||
description: Type de données à exporter
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD)
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date unique (YYYY-MM-DD), ignoré si start/end fournis
|
||||
responses:
|
||||
200:
|
||||
description: Fichier CSV
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
export_type = request.args.get("type", "predictions").lower()
|
||||
if export_type not in ("predictions", "bets"):
|
||||
return bad_request(
|
||||
"Paramètre 'type' invalide. Valeurs acceptées: predictions, bets"
|
||||
)
|
||||
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
for label, val in [("start", start), ("end", end), ("date", date)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Build date range
|
||||
if start and end:
|
||||
date_cond = "date BETWEEN ? AND ?"
|
||||
date_params = [start, end]
|
||||
elif start:
|
||||
date_cond = "date >= ?"
|
||||
date_params = [start]
|
||||
else:
|
||||
date_cond = "date = ?"
|
||||
date_params = [date]
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type == "predictions":
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return bad_request("Table ml_predictions_cache introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label
|
||||
FROM ml_predictions_cache
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_label",
|
||||
"hippodrome",
|
||||
"discipline",
|
||||
"distance",
|
||||
"heure",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"odds",
|
||||
"prob_top1",
|
||||
"prob_top3",
|
||||
"ml_score",
|
||||
"recommendation",
|
||||
"is_value_bet",
|
||||
"risque_label",
|
||||
]
|
||||
|
||||
else: # bets
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return bad_request("Table bet_results introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_name",
|
||||
"type_pari",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"cote",
|
||||
"mise",
|
||||
"resultat",
|
||||
"gain",
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(dict(row))
|
||||
|
||||
filename = f"turf_{export_type}_{date_params[0]}.csv"
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
status=200,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
44
api_v1/routes/health.py
Normal file
44
api_v1/routes/health.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GET /api/v1/health — public healthcheck endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from datetime import datetime, timezone
|
||||
|
||||
health_bp = Blueprint("v1_health", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""
|
||||
Health check
|
||||
---
|
||||
tags:
|
||||
- System
|
||||
summary: Public healthcheck — returns API status and timestamp
|
||||
responses:
|
||||
200:
|
||||
description: API is healthy
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
version:
|
||||
type: string
|
||||
example: "1.0"
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
"""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0",
|
||||
"api": "Turf SaaS API v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
), 200
|
||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
History routes for API v1.
|
||||
|
||||
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||
|
||||
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware
|
||||
|
||||
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plan limits (days of history accessible; None = unlimited)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_DAYS = {
|
||||
"free": 7,
|
||||
"premium": 90,
|
||||
"pro": None, # illimité
|
||||
}
|
||||
|
||||
# Fallback for unknown plans: treat like free
|
||||
_DEFAULT_LIMIT = 7
|
||||
|
||||
|
||||
def _get_plan_max_days(plan: str):
|
||||
"""Return the max history days allowed for the given plan, or default."""
|
||||
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||
|
||||
|
||||
def _parse_date(date_str: str, param_name: str):
|
||||
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/history
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@history_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def get_history():
|
||||
"""
|
||||
Historique des prédictions ML avec filtre date range
|
||||
---
|
||||
tags:
|
||||
- Historique
|
||||
summary: |
|
||||
Historique des prédictions sur une plage de dates.
|
||||
Limite selon le plan :
|
||||
- Free : 7 derniers jours
|
||||
- Premium : 90 derniers jours
|
||||
- Pro : illimité
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
description: Nombre de résultats par page (max 500)
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Historique des prédictions ML
|
||||
400:
|
||||
description: Paramètre de date invalide
|
||||
401:
|
||||
description: Token invalide ou manquant
|
||||
403:
|
||||
description: Plage de dates hors limite du plan — upgrade requis
|
||||
"""
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().date()
|
||||
max_days = _get_plan_max_days(plan)
|
||||
|
||||
# ── Parse end date ────────────────────────────────────────
|
||||
end_str = request.args.get("end", today.isoformat())
|
||||
try:
|
||||
end_date = _parse_date(end_str, "end")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Parse start date ─────────────────────────────────────
|
||||
if max_days is not None:
|
||||
default_start = today - timedelta(days=max_days - 1)
|
||||
else:
|
||||
# Pro: default to 30 days back when no start provided
|
||||
default_start = today - timedelta(days=29)
|
||||
|
||||
start_str = request.args.get("start", default_start.isoformat())
|
||||
try:
|
||||
start_date = _parse_date(start_str, "start")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Validate ordering ─────────────────────────────────────
|
||||
if start_date > end_date:
|
||||
return bad_request(
|
||||
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||
)
|
||||
|
||||
# ── Enforce plan window ───────────────────────────────────
|
||||
if max_days is not None:
|
||||
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||
if start_date < earliest_allowed:
|
||||
return forbidden(
|
||||
message=(
|
||||
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||
),
|
||||
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||
current_plan=plan,
|
||||
)
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
# ── Query ─────────────────────────────────────────────────
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": [],
|
||||
**paginate_query([], 0, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?""",
|
||||
(start_date.isoformat(), end_date.isoformat()),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
id, date, horse_name, prob_top1, prob_top3,
|
||||
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(
|
||||
sql,
|
||||
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||
).fetchall()
|
||||
|
||||
history = [dict(r) for r in rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"history_limit_days": max_days,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": history,
|
||||
**paginate_query(history, total, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
144
api_v1/routes/metrics.py
Normal file
144
api_v1/routes/metrics.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metrics route for API v1.
|
||||
|
||||
GET /api/v1/metrics — Métriques performances ML (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@metrics_bp.route("/metrics", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def metrics():
|
||||
"""
|
||||
Métriques ML
|
||||
---
|
||||
tags:
|
||||
- Métriques
|
||||
summary: Métriques de performance du modèle ML (precision, ROI, top-3 rate) — premium+
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
type: integer
|
||||
default: 30
|
||||
description: Nombre de jours à analyser (max 365)
|
||||
responses:
|
||||
200:
|
||||
description: Métriques de performance ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
try:
|
||||
days = int(request.args.get("days", 30))
|
||||
except (ValueError, TypeError):
|
||||
return bad_request("Paramètre 'days' doit être un entier")
|
||||
|
||||
days = max(1, min(days, 365))
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# ── Bet-level metrics from bet_results ──
|
||||
bet_metrics = {
|
||||
"available": False,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
}
|
||||
ml_metrics = {"available": False}
|
||||
daily_stats = []
|
||||
|
||||
if table_exists(conn, "bet_results"):
|
||||
row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
total = row["total"] or 0
|
||||
gagne = row["gagne"] or 0
|
||||
mise = float(row["mise"] or 0)
|
||||
gain = float(row["gain"] or 0)
|
||||
|
||||
bet_metrics = {
|
||||
"available": True,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"total_bets": total,
|
||||
"precision_pct": round(gagne / total * 100, 2) if total > 0 else 0.0,
|
||||
"roi_pct": round((gain - mise) / mise * 100, 2) if mise > 0 else 0.0,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
}
|
||||
|
||||
# ── ML predictions cache metrics ──
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
cache_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(is_value_bet) AS value_bets,
|
||||
AVG(prob_top1) AS avg_prob_top1,
|
||||
AVG(prob_top3) AS avg_prob_top3,
|
||||
AVG(ml_score) AS avg_ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
if cache_row and cache_row["total"]:
|
||||
ml_metrics = {
|
||||
"available": True,
|
||||
"total_predictions": cache_row["total"],
|
||||
"value_bets": cache_row["value_bets"] or 0,
|
||||
"avg_prob_top1": round(float(cache_row["avg_prob_top1"] or 0), 4),
|
||||
"avg_prob_top3": round(float(cache_row["avg_prob_top3"] or 0), 4),
|
||||
"avg_ml_score": round(float(cache_row["avg_ml_score"] or 0), 4),
|
||||
}
|
||||
|
||||
# ── Daily breakdown ──
|
||||
if table_exists(conn, "daily_stats"):
|
||||
daily_rows = conn.execute(
|
||||
"""SELECT date, total_bets, bets_gagne, precision_pct, roi_pct,
|
||||
mise_totale, gain_total
|
||||
FROM daily_stats
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC
|
||||
LIMIT 60""",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
daily_stats = [dict(r) for r in daily_rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"bet_metrics": bet_metrics,
|
||||
"ml_metrics": ml_metrics,
|
||||
"daily": daily_stats,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
163
api_v1/routes/predictions.py
Normal file
163
api_v1/routes/predictions.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Predictions routes for API v1.
|
||||
|
||||
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
|
||||
GET /api/v1/predictions/all — Toutes prédictions (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
not_found,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required, free_daily_limit_check
|
||||
|
||||
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
|
||||
|
||||
|
||||
def _fetch_ml_predictions(conn, date: str, limit: int = None, offset: int = 0):
|
||||
"""Shared helper — returns rows from ml_predictions_cache."""
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return [], 0
|
||||
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(date,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
ORDER BY ml_score DESC"""
|
||||
params = [date]
|
||||
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params += [limit, offset]
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
return [dict(r) for r in rows], total
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/top3
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/top3", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def predictions_top3():
|
||||
"""
|
||||
Top 3 prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
responses:
|
||||
200:
|
||||
description: Top 3 prédictions ML du jour
|
||||
401:
|
||||
description: Token invalide
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"top3": predictions,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/all
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/all", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def predictions_all():
|
||||
"""
|
||||
Toutes les prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Toutes les prédictions ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, total = _fetch_ml_predictions(
|
||||
conn, date_param, limit=limit, offset=offset
|
||||
)
|
||||
pagination = paginate_query(predictions, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"predictions": predictions,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
216
api_v1/routes/user.py
Normal file
216
api_v1/routes/user.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User route for API v1 — Telegram alert configuration
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||
|
||||
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import internal_error, bad_request
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||
import os
|
||||
|
||||
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def get_telegram_config():
|
||||
"""
|
||||
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Lire la config alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Configuration Telegram courante
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
nullable: true
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
alert_top1:
|
||||
type: boolean
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": row["telegram_chat_id"],
|
||||
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||
"alert_top1": bool(row["alert_top1"]),
|
||||
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Colonnes absentes : migration non appliquée
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": None,
|
||||
"alert_value_bets": True,
|
||||
"alert_top1": True,
|
||||
"alert_quinte_only": False,
|
||||
"_warning": "Migration Telegram non appliquée",
|
||||
}
|
||||
), 200
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def update_telegram_config():
|
||||
"""
|
||||
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Configurer les alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
description: Chat ID Telegram (ou null pour désactiver)
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_top1:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
200:
|
||||
description: Configuration mise à jour
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return bad_request("Corps JSON requis")
|
||||
|
||||
# Validation et extraction des champs
|
||||
telegram_chat_id = data.get("telegram_chat_id")
|
||||
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||
if isinstance(telegram_chat_id, str):
|
||||
telegram_chat_id = telegram_chat_id.strip() or None
|
||||
|
||||
alert_value_bets = data.get("alert_value_bets", True)
|
||||
alert_top1 = data.get("alert_top1", True)
|
||||
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||
|
||||
if not isinstance(alert_value_bets, bool):
|
||||
return bad_request("alert_value_bets doit être un booléen")
|
||||
if not isinstance(alert_top1, bool):
|
||||
return bad_request("alert_top1 doit être un booléen")
|
||||
if not isinstance(alert_quinte_only, bool):
|
||||
return bad_request("alert_quinte_only doit être un booléen")
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET telegram_chat_id = ?,
|
||||
alert_value_bets = ?,
|
||||
alert_top1 = ?,
|
||||
alert_quinte_only = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
telegram_chat_id,
|
||||
int(alert_value_bets),
|
||||
int(alert_top1),
|
||||
int(alert_quinte_only),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"telegram_chat_id": telegram_chat_id,
|
||||
"alert_value_bets": alert_value_bets,
|
||||
"alert_top1": alert_top1,
|
||||
"alert_quinte_only": alert_quinte_only,
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Migration Telegram non appliquée — contacter le support",
|
||||
"detail": str(exc),
|
||||
}
|
||||
), 500
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
|
||||
HRT-80
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/user/api-token
|
||||
DELETE /api/v1/user/api-token
|
||||
POST /api/v1/user/webhook
|
||||
DELETE /api/v1/user/webhook
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from api_tokens_db import get_db, migrate_api_tokens_tables
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
logger = logging.getLogger("turf_saas.user_tokens")
|
||||
|
||||
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
try:
|
||||
migrate_api_tokens_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_prefix, created_at FROM user_api_tokens "
|
||||
"WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
|
||||
"existing_prefix": existing["token_prefix"],
|
||||
"created_at": existing["created_at"],
|
||||
}
|
||||
), 409
|
||||
|
||||
raw_token = "trf_" + secrets.token_urlsafe(40)
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_prefix = raw_token[:12]
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
|
||||
(user_id, token_hash, token_prefix),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
created_at = row["created_at"] if row else None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
|
||||
return jsonify(
|
||||
{
|
||||
"token": raw_token,
|
||||
"prefix": token_prefix,
|
||||
"created_at": created_at,
|
||||
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def revoke_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute(
|
||||
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
revoked_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("revoke_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if revoked_count == 0:
|
||||
return jsonify({"error": "Aucun token actif trouvé"}), 404
|
||||
|
||||
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
|
||||
return jsonify({"revoked": True, "count": revoked_count}), 200
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
data = request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "URL du webhook manquante"}), 400
|
||||
if not url.startswith("https://"):
|
||||
return jsonify(
|
||||
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
|
||||
), 400
|
||||
|
||||
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
|
||||
|
||||
conn = get_db()
|
||||
existing = None
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
|
||||
"WHERE user_id = ?",
|
||||
(url, secret, user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
|
||||
(user_id, url, secret),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
action = "mis à jour" if existing else "configuré"
|
||||
logger.info("Webhook %s for user %s: %s", action, user_id, url)
|
||||
return jsonify(
|
||||
{
|
||||
"webhook_url": url,
|
||||
"secret": secret,
|
||||
"message": f"Webhook {action} avec succès",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def delete_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
deleted_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("delete_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted_count == 0:
|
||||
return jsonify({"error": "Aucun webhook configuré"}), 404
|
||||
|
||||
logger.info("Webhook deleted for user %s", user_id)
|
||||
return jsonify({"deleted": True}), 200
|
||||
111
api_v1/routes/valuebets.py
Normal file
111
api_v1/routes/valuebets.py
Normal file
@@ -0,0 +1,111 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Value bets route for API v1.
|
||||
|
||||
GET /api/v1/valuebets — Value bets du jour (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
valuebets_bp = Blueprint("v1_valuebets", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@valuebets_bp.route("/valuebets", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def valuebets():
|
||||
"""
|
||||
Value bets du jour
|
||||
---
|
||||
tags:
|
||||
- Value Bets
|
||||
summary: Value bets du jour — chevaux à cote surévaluée par le marché (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: min_odds
|
||||
in: query
|
||||
type: number
|
||||
default: 2.0
|
||||
description: Cote minimale pour filtrer les value bets
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Value bets du jour
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=20, max_limit=100)
|
||||
|
||||
try:
|
||||
min_odds = float(request.args.get("min_odds", 2.0))
|
||||
except (ValueError, TypeError):
|
||||
min_odds = 2.0
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
rows = []
|
||||
total = 0
|
||||
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?""",
|
||||
(date_param, min_odds),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
"""SELECT race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
|
||||
ORDER BY ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
valuebets_list = [dict(r) for r in rows]
|
||||
pagination = paginate_query(valuebets_list, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"min_odds": min_odds,
|
||||
"valuebets": valuebets_list,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
98
api_v1/utils.py
Normal file
98
api_v1/utils.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared utilities for API v1 — error helpers, pagination, DB access.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from flask import jsonify, request
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Database
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Return a SQLite connection with Row factory."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def table_exists(conn, table_name: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Uniform error responses
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def error_response(message: str, code: int, status: str = "error"):
|
||||
"""Return a JSON error envelope consistent with the API contract.
|
||||
|
||||
Shape: {"status": "error", "message": "...", "code": 400}
|
||||
"""
|
||||
return jsonify({"status": status, "message": message, "code": code}), code
|
||||
|
||||
|
||||
def not_found(message: str = "Resource not found"):
|
||||
return error_response(message, 404)
|
||||
|
||||
|
||||
def bad_request(message: str = "Bad request"):
|
||||
return error_response(message, 400)
|
||||
|
||||
|
||||
def forbidden(message: str = "Forbidden", required_plans=None, current_plan=None):
|
||||
payload = {"status": "error", "message": message, "code": 403}
|
||||
if required_plans:
|
||||
payload["required_plans"] = required_plans
|
||||
if current_plan:
|
||||
payload["current_plan"] = current_plan
|
||||
payload["upgrade_url"] = "/api/v1/subscription/upgrade"
|
||||
return jsonify(payload), 403
|
||||
|
||||
|
||||
def internal_error(message: str = "Internal server error"):
|
||||
return error_response(message, 500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pagination helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_pagination_params(default_limit: int = 20, max_limit: int = 100):
|
||||
"""Extract and validate limit/offset from query-string."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", default_limit))
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
|
||||
try:
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
|
||||
def paginate_query(rows, total: int, limit: int, offset: int):
|
||||
"""Wrap a list of rows in a pagination envelope."""
|
||||
return {
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total,
|
||||
}
|
||||
}
|
||||
80
api_v1/utils_webhook.py
Normal file
80
api_v1/utils_webhook.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256)
|
||||
HRT-80
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from api_tokens_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.webhook")
|
||||
|
||||
EVENT_NEW_PREDICTION = "new_prediction"
|
||||
EVENT_VALUE_BET = "value_bet"
|
||||
|
||||
|
||||
def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None:
|
||||
"""
|
||||
Send HMAC-signed webhook POST to URL configured by user.
|
||||
Fire-and-forget: errors logged, never re-raised. Timeout: 5s.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT url, secret FROM user_webhooks WHERE user_id = ?",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return
|
||||
|
||||
url = row["url"]
|
||||
secret = row["secret"]
|
||||
body = json.dumps(
|
||||
{"event": event_type, "data": payload},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Turf-Signature": f"sha256={signature}",
|
||||
"X-Turf-Event": event_type,
|
||||
"User-Agent": "TurfSaaS-Webhook/1.0",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=body, headers=headers, timeout=5)
|
||||
logger.info(
|
||||
"Webhook dispatched to user %s (event=%s, status=%s)",
|
||||
user_id,
|
||||
event_type,
|
||||
resp.status_code,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(
|
||||
"Webhook failed for user %s (event=%s): %s", user_id, event_type, e
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Webhook unexpected error for user %s (event=%s): %s",
|
||||
user_id,
|
||||
event_type,
|
||||
e,
|
||||
)
|
||||
138
app_v1.py
Normal file
138
app_v1.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
app_v1.py — Turf SaaS Flask application with versioned API /v1/
|
||||
|
||||
This module creates the Flask app, registers:
|
||||
- Auth JWT (from Sprint 2-3)
|
||||
- API v1 blueprints
|
||||
- Swagger/OpenAPI documentation at /api/v1/docs
|
||||
|
||||
Usage:
|
||||
python app_v1.py
|
||||
# or via gunicorn:
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flasgger import Swagger
|
||||
|
||||
from auth_db import init_auth_tables
|
||||
from auth import auth_bp
|
||||
from api_v1 import register_api_v1
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("turf_saas.app_v1")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory."""
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── CORS ──
|
||||
CORS(app, origins=["*"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
|
||||
# ── JWT config ──
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "change-me-in-production-use-strong-random-secret"
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
|
||||
JWTManager(app)
|
||||
|
||||
# ── Swagger / OpenAPI ──
|
||||
swagger_config = {
|
||||
"headers": [],
|
||||
"specs": [
|
||||
{
|
||||
"endpoint": "apispec_v1",
|
||||
"route": "/api/v1/apispec.json",
|
||||
"rule_filter": lambda rule: str(rule).startswith("/api/v1"),
|
||||
"model_filter": lambda tag: True,
|
||||
}
|
||||
],
|
||||
"static_url_path": "/flasgger_static",
|
||||
"swagger_ui": True,
|
||||
"specs_route": "/api/v1/docs",
|
||||
}
|
||||
|
||||
swagger_template = {
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "Turf SaaS API",
|
||||
"description": (
|
||||
"API v1 — Prédictions turf IA, value bets, backtest & métriques.\n\n"
|
||||
"**Plans:** `free` | `premium` | `pro`\n\n"
|
||||
"**Auth:** Bearer JWT — obtenir un token via `POST /api/v1/auth/login`"
|
||||
),
|
||||
"version": "1.0.0",
|
||||
"contact": {"name": "H3R7 Tech"},
|
||||
},
|
||||
"basePath": "/",
|
||||
"schemes": ["http", "https"],
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "Entrer: **Bearer <token>**",
|
||||
}
|
||||
},
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
}
|
||||
|
||||
Swagger(app, config=swagger_config, template=swagger_template)
|
||||
|
||||
# ── Auth DB init ──
|
||||
with app.app_context():
|
||||
try:
|
||||
init_auth_tables()
|
||||
except Exception as e:
|
||||
logger.warning("init_auth_tables warning: %s", e)
|
||||
|
||||
# ── Register auth blueprint ──
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# ── Register API v1 blueprints ──
|
||||
register_api_v1(app)
|
||||
|
||||
# ── Global error handlers ──
|
||||
@app.errorhandler(404)
|
||||
def not_found_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Route introuvable", "code": 404}
|
||||
), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Méthode non autorisée", "code": 405}
|
||||
), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error_handler(e):
|
||||
logger.exception("Unhandled 500 error")
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Erreur serveur interne", "code": 500}
|
||||
), 500
|
||||
|
||||
logger.info("Turf SaaS API v1 ready — docs at /api/v1/docs")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8792))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
408
auth.py
Normal file
408
auth.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth Blueprint — JWT authentication + multi-tenant plan enforcement
|
||||
Sprint 2-3: HRT-28
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/auth/register — email/password registration
|
||||
POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d)
|
||||
POST /api/v1/auth/refresh — rotate refresh token, issue new access_token
|
||||
POST /api/v1/auth/logout — revoke refresh token
|
||||
|
||||
Middleware exposed:
|
||||
jwt_required_middleware() — decorator: valid access JWT required
|
||||
plan_required(plans) — decorator: user plan must be in given list
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, g, current_app
|
||||
from flask_jwt_extended import (
|
||||
JWTManager,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_jwt_identity,
|
||||
verify_jwt_in_request,
|
||||
)
|
||||
from flask_jwt_extended.exceptions import JWTExtendedException
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
from auth_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.auth")
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""SHA-256 hash of a token string for secure DB storage."""
|
||||
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_user_by_email(email: str):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _get_user_by_id(user_id: int):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)",
|
||||
(user_id, token_hash, expires_at.isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _revoke_refresh_token(raw_token: str):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool:
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""SELECT id FROM refresh_tokens
|
||||
WHERE token_hash = ? AND user_id = ? AND revoked = 0
|
||||
AND expires_at > datetime('now')""",
|
||||
(token_hash, user_id),
|
||||
).fetchone()
|
||||
db.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth endpoints
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""POST /api/v1/auth/register — create a new user account (plan=free)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400
|
||||
|
||||
# Check uniqueness
|
||||
existing = _get_user_by_email(email)
|
||||
if existing:
|
||||
return jsonify({"error": "Email déjà enregistré"}), 409
|
||||
|
||||
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||
(email, password_hash, "free"),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
# Create initial subscription record
|
||||
db.execute(
|
||||
"INSERT INTO subscriptions (user_id, plan) VALUES (?,?)",
|
||||
(user_id, "free"),
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("register error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("New user registered: %s (id=%s)", email, user_id)
|
||||
return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""POST /api/v1/auth/login — returns JWT access_token + refresh_token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email et mot de passe requis"}), 400
|
||||
|
||||
user = _get_user_by_email(email)
|
||||
if not user:
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
|
||||
logger.warning("Failed login attempt for %s", email)
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
# Create tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=identity,
|
||||
additional_claims=additional_claims,
|
||||
)
|
||||
raw_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], raw_refresh, refresh_expires)
|
||||
|
||||
logger.info("User %s logged in (plan=%s)", email, user["plan"])
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"refresh_token": raw_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/refresh", methods=["POST"])
|
||||
def refresh():
|
||||
"""POST /api/v1/auth/refresh — rotate refresh token, issue new access_token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if not raw_refresh:
|
||||
return jsonify({"error": "refresh_token manquant"}), 400
|
||||
|
||||
# Decode without verifying in DB first (to get user_id)
|
||||
try:
|
||||
decoded = decode_token(raw_refresh)
|
||||
except Exception:
|
||||
return jsonify({"error": "Refresh token invalide ou expiré"}), 401
|
||||
|
||||
user_id = int(decoded.get("sub", 0))
|
||||
|
||||
if not _is_refresh_token_valid(raw_refresh, user_id):
|
||||
return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401
|
||||
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
|
||||
# Revoke old refresh token (rotation)
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
# Issue new tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
new_access = create_access_token(
|
||||
identity=identity, additional_claims=additional_claims
|
||||
)
|
||||
new_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], new_refresh, refresh_expires)
|
||||
|
||||
logger.info("Token refreshed for user_id=%s", user_id)
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""POST /api/v1/auth/logout — revoke refresh token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if raw_refresh:
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
return jsonify({"message": "Déconnexion réussie"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JWT-protected middleware
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_api_key(raw_key: str):
|
||||
"""
|
||||
Validate a personal API token (X-API-Key header).
|
||||
Returns user dict or None. Updates last_used_at on success.
|
||||
HRT-80: Personal API token support.
|
||||
"""
|
||||
if not raw_key:
|
||||
return None
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
db = get_db()
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||
"JOIN users u ON CAST(t.user_id AS INTEGER) = u.id "
|
||||
"WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1",
|
||||
(key_hash,),
|
||||
).fetchone()
|
||||
if row:
|
||||
db.execute(
|
||||
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||
"WHERE token_hash = ?",
|
||||
(key_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.warning("validate_api_key error: %s", e)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def jwt_required_middleware(fn):
|
||||
"""
|
||||
Decorator: require a valid Bearer JWT access token OR X-API-Key personal token.
|
||||
HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only).
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 1. Try Bearer JWT (existing flow — unchanged)
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
user_id = int(get_jwt_identity())
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
g.current_user = dict(user)
|
||||
g.current_user_id = user_id
|
||||
return fn(*args, **kwargs)
|
||||
except (JWTExtendedException, PyJWTError) as e:
|
||||
logger.debug("JWT auth failed: %s", e)
|
||||
|
||||
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||
api_key = request.headers.get("X-API-Key", "").strip()
|
||||
if api_key:
|
||||
user = validate_api_key(api_key)
|
||||
if user:
|
||||
g.current_user = user
|
||||
g.current_user_id = user.get("id")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Token invalide ou expiré"}), 401
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def plan_required(*allowed_plans):
|
||||
"""
|
||||
Decorator factory: user's plan must be in allowed_plans.
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
|
||||
Example:
|
||||
@app.route("/api/v1/predictions")
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def premium_predictions():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user["plan"] not in allowed_plans:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": list(allowed_plans),
|
||||
"current_plan": user["plan"],
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def free_daily_limit_check(fn):
|
||||
"""
|
||||
Decorator: enforce free plan daily limit (1 course/jour).
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user or user["plan"] != "free":
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT daily_usage, last_usage_date FROM users WHERE id = ?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
db.close()
|
||||
|
||||
if row and row["last_usage_date"] == today and row["daily_usage"] >= 1:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Limite quotidienne atteinte (plan free: 1 course/jour)",
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 429
|
||||
|
||||
# Increment usage
|
||||
db = get_db()
|
||||
if row and row["last_usage_date"] == today:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?",
|
||||
(user["id"],),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?",
|
||||
(today, user["id"]),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
99
auth_db.py
Normal file
99
auth_db.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth DB — users and subscriptions schema for turf_saas.db
|
||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||
HRT-79: migration Telegram columns
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_auth_tables():
|
||||
"""Create users and subscriptions tables if they don't exist."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free'
|
||||
CHECK(plan IN ('free','premium','pro')),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
daily_usage INTEGER NOT NULL DEFAULT 0,
|
||||
last_usage_date TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')),
|
||||
start_date DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||
|
||||
# Apply Telegram columns migration (idempotent)
|
||||
migrate_telegram_columns()
|
||||
|
||||
|
||||
def migrate_telegram_columns():
|
||||
"""
|
||||
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||
HRT-79
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
columns = [
|
||||
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||
("alert_top1", "INTEGER DEFAULT 1"),
|
||||
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for col, definition in columns:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||
except sqlite3.OperationalError:
|
||||
# Column already exists — safe to ignore
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[auth_db] Migration Telegram columns OK.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_auth_tables()
|
||||
143
billing_db.py
Normal file
143
billing_db.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DB Migration — Billing Stripe
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Adds stripe_subscription_id and status columns to subscriptions table,
|
||||
and an invoices / grace-period tracking table.
|
||||
|
||||
Run once:
|
||||
./venv/bin/python billing_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.billing_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_billing_tables():
|
||||
"""Idempotent migration: add billing columns and billing_events table.
|
||||
|
||||
Requires auth tables (users, subscriptions) to exist first.
|
||||
Calls init_auth_tables() automatically if subscriptions is absent.
|
||||
"""
|
||||
from auth_db import init_auth_tables as _init_auth
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Ensure base auth tables exist
|
||||
tables = {
|
||||
row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
|
||||
if "subscriptions" not in tables:
|
||||
_init_auth()
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Add stripe_subscription_id if missing
|
||||
columns = {row[1] for row in c.execute("PRAGMA table_info(subscriptions)")}
|
||||
|
||||
if "stripe_subscription_id" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN stripe_subscription_id TEXT")
|
||||
logger.info("[billing_db] Added stripe_subscription_id column to subscriptions")
|
||||
|
||||
if "status" not in columns:
|
||||
c.execute(
|
||||
"ALTER TABLE subscriptions ADD COLUMN "
|
||||
"status TEXT NOT NULL DEFAULT 'active' "
|
||||
"CHECK(status IN ('active','past_due','canceled','trialing','incomplete'))"
|
||||
)
|
||||
logger.info("[billing_db] Added status column to subscriptions")
|
||||
|
||||
if "grace_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN grace_period_end DATETIME")
|
||||
logger.info("[billing_db] Added grace_period_end column to subscriptions")
|
||||
|
||||
if "current_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN current_period_end DATETIME")
|
||||
logger.info("[billing_db] Added current_period_end column to subscriptions")
|
||||
|
||||
# billing_events table — audit trail for all webhook events
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS billing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stripe_event_id TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
payload TEXT,
|
||||
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
start_date DATETIME DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
grace_period_end DATETIME,
|
||||
current_period_end DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_user ON saas_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(
|
||||
"[billing_db] Migration complete: subscriptions + billing_events tables ready."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_billing_tables()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Re-exported helpers for test usage
|
||||
# (primary implementations live in api_v1/routes/billing.py)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id: int, **fields):
|
||||
"""
|
||||
Update existing subscription row or insert a new one.
|
||||
Convenience re-export for test helpers.
|
||||
"""
|
||||
existing = db.execute(
|
||||
"SELECT id FROM subscriptions WHERE user_id = ? ORDER BY start_date DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [existing["id"]]
|
||||
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [user_id] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
259
combined_api.py
259
combined_api.py
@@ -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")
|
||||
@@ -3519,7 +3537,6 @@ def brave_search():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@app.route("/turf/api/predictions_analysis", methods=["GET"])
|
||||
def api_predictions_analysis():
|
||||
"""Analyse des predictions vs resultats reels"""
|
||||
@@ -3533,13 +3550,25 @@ def api_predictions_analysis():
|
||||
cursor = conn.cursor()
|
||||
|
||||
stats = {
|
||||
"canalturf": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
||||
"scoring": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
|
||||
"canalturf": {
|
||||
"total": 0,
|
||||
"top1_pct": 0,
|
||||
"top3_pct": 0,
|
||||
"top5_pct": 0,
|
||||
"ze2_pct": 0,
|
||||
},
|
||||
"scoring": {
|
||||
"total": 0,
|
||||
"top1_pct": 0,
|
||||
"top3_pct": 0,
|
||||
"top5_pct": 0,
|
||||
"ze2_pct": 0,
|
||||
},
|
||||
}
|
||||
|
||||
for source in ["canalturf", "scoring"]:
|
||||
pred_table = "predictions" if source == "canalturf" else "scoring"
|
||||
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
||||
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
|
||||
try:
|
||||
cursor.execute(
|
||||
f"""
|
||||
@@ -3566,16 +3595,16 @@ def api_predictions_analysis():
|
||||
top1_hit = top3_hit = 0
|
||||
total = len(races)
|
||||
for race, data in races.items():
|
||||
actual = set(data["actual"][:3])
|
||||
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
||||
actual_top1 = data["actual"][0] if data["actual"] else None
|
||||
actual = set(data["actual"][:3])
|
||||
pred_top1 = data["predicted"][0] if data["predicted"] else None
|
||||
actual_top1 = data["actual"][0] if data["actual"] else None
|
||||
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
|
||||
top1_hit += 1
|
||||
if len(set(data["predicted"][:3]) & actual) >= 1:
|
||||
top3_hit += 1
|
||||
|
||||
if total > 0:
|
||||
stats[source]["total"] = total
|
||||
stats[source]["total"] = total
|
||||
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
|
||||
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
|
||||
except Exception as e:
|
||||
@@ -3585,5 +3614,219 @@ def api_predictions_analysis():
|
||||
return jsonify({"stats": stats, "period": {"start": start_date, "end": end_date}})
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
# /api/v1/predictions — Ensemble model endpoint (Sprint 6-7 ML Upgrade)
|
||||
# ─────────────────────────────────────────────────────────────────────────────
|
||||
_predict_v2 = None
|
||||
|
||||
|
||||
def _load_predict_v2():
|
||||
"""Lazy import of predict_v2 module (ensemble model)."""
|
||||
global _predict_v2
|
||||
if _predict_v2 is None:
|
||||
try:
|
||||
import importlib.util, sys
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
_predict_v2 = mod
|
||||
except Exception as e:
|
||||
import logging
|
||||
|
||||
logging.error(f"[v1/predictions] predict_v2 import failed: {e}")
|
||||
return _predict_v2
|
||||
|
||||
|
||||
@app.route("/api/v1/predictions", methods=["GET"])
|
||||
@app.route("/turf/api/v1/predictions", methods=["GET"])
|
||||
def api_v1_predictions():
|
||||
"""
|
||||
Ensemble ML predictions using XGBoost + LightGBM + MLP (Optuna-tuned).
|
||||
Query params:
|
||||
- date: YYYY-MM-DD (default: today / latest available)
|
||||
- reunion: int (default: all)
|
||||
- course: int (default: all)
|
||||
"""
|
||||
import time as _time
|
||||
|
||||
t0 = _time.perf_counter()
|
||||
|
||||
mod = _load_predict_v2()
|
||||
if mod is None:
|
||||
# Graceful fallback: redirect to legacy ml_predictions
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Ensemble model not available yet",
|
||||
"fallback": "/api/ml_predictions",
|
||||
"message": "Model is still training. Use /api/ml_predictions for legacy XGBoost predictions.",
|
||||
}
|
||||
), 503
|
||||
|
||||
ensemble = mod.load_ensemble()
|
||||
if ensemble is None:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Ensemble model file not found",
|
||||
"model_path": str(mod.ENSEMBLE_PATH),
|
||||
"message": "Run train_ensemble.py to generate the model.",
|
||||
"fallback": "/api/ml_predictions",
|
||||
}
|
||||
), 503
|
||||
|
||||
date_param = request.args.get("date", None)
|
||||
reunion_param = request.args.get("reunion", None)
|
||||
course_param = request.args.get("course", None)
|
||||
|
||||
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
# Determine date to use
|
||||
if date_param:
|
||||
date_used = date_param
|
||||
else:
|
||||
row = conn.execute(
|
||||
"SELECT MAX(date_programme) as d FROM pmu_partants"
|
||||
).fetchone()
|
||||
date_used = (
|
||||
row["d"] if row and row["d"] else datetime.now().strftime("%Y-%m-%d")
|
||||
)
|
||||
|
||||
# Build query
|
||||
where_clauses = ["p.date_programme = ?"]
|
||||
params = [date_used]
|
||||
if reunion_param:
|
||||
where_clauses.append("p.num_reunion = ?")
|
||||
params.append(int(reunion_param))
|
||||
if course_param:
|
||||
where_clauses.append("p.num_course = ?")
|
||||
params.append(int(course_param))
|
||||
|
||||
query = f"""
|
||||
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule,
|
||||
c.libelle as course_libelle, c.libelle_court as hippodrome,
|
||||
c.heure_depart_str, c.parcours
|
||||
FROM pmu_partants p
|
||||
LEFT JOIN pmu_courses c ON p.date_programme = c.date_programme
|
||||
AND p.num_reunion = c.num_reunion AND p.num_course = c.num_course
|
||||
WHERE {" AND ".join(where_clauses)}
|
||||
ORDER BY p.num_reunion, p.num_course, p.num_pmu
|
||||
"""
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
return jsonify(
|
||||
{
|
||||
"date": date_used,
|
||||
"model_version": mod.get_model_version(),
|
||||
"predictions": [],
|
||||
"message": f"No partants found for date {date_used}",
|
||||
}
|
||||
)
|
||||
|
||||
# Convert to list of dicts
|
||||
partants = [dict(r) for r in rows]
|
||||
|
||||
# Run ensemble prediction
|
||||
preds = mod.predict_top3(partants, model=ensemble)
|
||||
|
||||
# Group by race
|
||||
races = {}
|
||||
for pred in preds:
|
||||
key = f"R{pred.get('num_reunion', 0)}C{pred.get('num_course', 0)}"
|
||||
if key not in races:
|
||||
# Find race metadata from partants
|
||||
for p in partants:
|
||||
if p.get("num_reunion") == pred.get("num_reunion") and p.get(
|
||||
"num_course"
|
||||
) == pred.get("num_course"):
|
||||
races[key] = {
|
||||
"reunion": pred.get("num_reunion"),
|
||||
"course": pred.get("num_course"),
|
||||
"label": key,
|
||||
"race_name": p.get("course_libelle", ""),
|
||||
"hippodrome": p.get("hippodrome", ""),
|
||||
"heure": p.get("heure_depart_str", ""),
|
||||
"discipline": p.get("discipline", ""),
|
||||
"distance": p.get("distance", 0),
|
||||
"horses": [],
|
||||
}
|
||||
break
|
||||
if key in races:
|
||||
races[key]["horses"].append(pred)
|
||||
|
||||
latency_ms = (_time.perf_counter() - t0) * 1000
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"date": date_used,
|
||||
"model_version": mod.get_model_version(),
|
||||
"latency_ms": round(latency_ms, 1),
|
||||
"total_horses": len(preds),
|
||||
"races": list(races.values()),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/v1/model/invalidate-cache", methods=["POST"])
|
||||
@app.route("/turf/api/v1/model/invalidate-cache", methods=["POST"])
|
||||
def api_v1_invalidate_cache():
|
||||
"""Force reload of ensemble model on next prediction call."""
|
||||
mod = _load_predict_v2()
|
||||
if mod:
|
||||
mod.invalidate_model_cache()
|
||||
return jsonify({"status": "ok", "message": "Model cache invalidated"})
|
||||
return jsonify({"status": "error", "message": "predict_v2 module not loaded"}), 500
|
||||
|
||||
|
||||
@app.route("/api/v1/model/status", methods=["GET"])
|
||||
@app.route("/turf/api/v1/model/status", methods=["GET"])
|
||||
def api_v1_model_status():
|
||||
"""Return ensemble model status and version."""
|
||||
import os as _os
|
||||
from pathlib import Path as _Path
|
||||
|
||||
ensemble_path = _Path("/home/h3r7/turf_saas/models/ensemble_top3.pkl")
|
||||
benchmark_path = _Path("/home/h3r7/turf_saas/models/benchmark_report.json")
|
||||
|
||||
status = {
|
||||
"ensemble_available": ensemble_path.exists(),
|
||||
"ensemble_path": str(ensemble_path),
|
||||
}
|
||||
if ensemble_path.exists():
|
||||
mtime = _os.path.getmtime(str(ensemble_path))
|
||||
status["last_trained"] = datetime.fromtimestamp(mtime).isoformat()
|
||||
|
||||
if benchmark_path.exists():
|
||||
try:
|
||||
with open(benchmark_path) as f:
|
||||
import json as _json
|
||||
|
||||
report = _json.load(f)
|
||||
status["benchmark"] = {
|
||||
"baseline_precision_at3": report.get("baseline", {}).get(
|
||||
"precision_at3"
|
||||
),
|
||||
"ensemble_precision_at3": report.get("ensemble", {}).get(
|
||||
"precision_at3"
|
||||
),
|
||||
"delta": report.get("delta_precision_at3"),
|
||||
"deployed": report.get("deploy"),
|
||||
"run_date": report.get("run_date"),
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
mod = _load_predict_v2()
|
||||
if mod and ensemble_path.exists():
|
||||
status["model_version"] = mod.get_model_version()
|
||||
|
||||
return jsonify(status)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8790, debug=False)
|
||||
|
||||
219
dashboard_api.py
219
dashboard_api.py
@@ -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)
|
||||
|
||||
1673
dashboard_saas.html
1673
dashboard_saas.html
File diff suppressed because it is too large
Load Diff
250
docker-compose.yml
Normal file
250
docker-compose.yml
Normal 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
|
||||
174
infra/grafana/dashboards/turf-saas-overview.json
Normal file
174
infra/grafana/dashboards/turf-saas-overview.json
Normal 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" }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal file
11
infra/grafana/provisioning/dashboards/dashboards.yml
Normal 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
|
||||
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal file
13
infra/grafana/provisioning/datasources/prometheus.yml
Normal 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"
|
||||
157
infra/nginx/conf.d/turf.conf
Normal file
157
infra/nginx/conf.d/turf.conf
Normal 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
65
infra/nginx/nginx.conf
Normal 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
12
infra/postgres/init.sql
Normal 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
109
infra/prometheus/alerts.yml
Normal 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"
|
||||
68
infra/prometheus/prometheus.yml
Normal file
68
infra/prometheus/prometheus.yml
Normal 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
45
infra/scripts/backup_db.sh
Executable 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
|
||||
21
infra/turf-saas-leadhunter.service
Normal file
21
infra/turf-saas-leadhunter.service
Normal file
@@ -0,0 +1,21 @@
|
||||
[Unit]
|
||||
Description=H3R7Tech LeadHunter API (Port 8775)
|
||||
Documentation=https://portal-kolifee.duckdns.org
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=h3r7
|
||||
WorkingDirectory=/home/h3r7/turf_saas
|
||||
|
||||
# Charger les variables d'environnement depuis /home/h3r7/.env
|
||||
# (notamment GOOGLE_PLACES_API_KEY)
|
||||
EnvironmentFile=/home/h3r7/.env
|
||||
|
||||
ExecStart=/home/h3r7/turf_saas/venv/bin/python3 /home/h3r7/turf_saas/leadhunter_api.py
|
||||
Restart=always
|
||||
RestartSec=10
|
||||
Environment=PYTHONPATH=/home/h3r7/turf_saas
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
343
landing.html
343
landing.html
@@ -6,16 +6,42 @@
|
||||
<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;
|
||||
--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; }
|
||||
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; }
|
||||
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); }
|
||||
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; }
|
||||
@@ -28,32 +54,64 @@
|
||||
.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 { 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%); }
|
||||
.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 ===== */
|
||||
.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; }
|
||||
.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; }
|
||||
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-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; }
|
||||
|
||||
/* ===== 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-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; }
|
||||
|
||||
/* ===== 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; }
|
||||
.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; }
|
||||
@@ -65,35 +123,79 @@
|
||||
.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 {
|
||||
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 { 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 ===== */
|
||||
.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 { border-top: 1px solid var(--border); padding: 40px 5%; display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px; }
|
||||
|
||||
/* ===== 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; }
|
||||
@media (max-width:768px) { .nav-links{display:none;} footer{grid-template-columns:1fr 1fr;} .cta-banner{padding:36px 24px;} }
|
||||
@media (max-width:480px) { footer{grid-template-columns:1fr;} }
|
||||
|
||||
/* ===== 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-logo">
|
||||
🏇 Turf IA
|
||||
<span class="badge">BETA</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Fonctionnalités</a>
|
||||
<a href="#pricing">Tarifs</a>
|
||||
@@ -105,8 +207,12 @@
|
||||
<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>
|
||||
<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">
|
||||
@@ -120,6 +226,8 @@
|
||||
<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>
|
||||
@@ -127,26 +235,72 @@
|
||||
<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 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>
|
||||
<section id="how" style="background:var(--dark2);margin:0;border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
|
||||
|
||||
<!-- 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 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>
|
||||
@@ -154,52 +308,96 @@
|
||||
<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>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>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>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>
|
||||
<section id="faq" style="background:var(--dark2);border-top:1px solid var(--border);border-bottom:1px solid var(--border);">
|
||||
|
||||
<!-- 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.</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. 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.</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.</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.</div></details>
|
||||
<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>
|
||||
@@ -207,18 +405,61 @@
|
||||
<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="/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/privacy">Confidentialité</a><a href="/legal/cookies">Cookies</a></div>
|
||||
<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>
|
||||
<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 t = document.querySelector(a.getAttribute('href'));
|
||||
if (t) { e.preventDefault(); t.scrollIntoView({behavior:'smooth'}); }
|
||||
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>
|
||||
|
||||
303
leadhunter_api.py
Normal file
303
leadhunter_api.py
Normal file
@@ -0,0 +1,303 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter API
|
||||
===========================
|
||||
Service Flask sur port 8775 exposant les endpoints LeadHunter.
|
||||
|
||||
Endpoints :
|
||||
GET /api/leads — Liste les leads (filtres: status, limit, offset)
|
||||
POST /api/leads/scrape — Lance un job de scraping asynchrone
|
||||
GET /api/leads/stats — Statistiques globales du CRM
|
||||
GET /api/leads/export — Export CSV des leads
|
||||
PATCH /api/leads/<id>/status — Met à jour le statut d'un lead
|
||||
|
||||
Port : 8775 (8769 occupé par depenses_trello/app.py, 8770 occupé par turf_scraper/crm_api.py — corrigé HRT-66)
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import os
|
||||
import threading
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from flask import Flask, jsonify, request, Response
|
||||
from flask_cors import CORS
|
||||
|
||||
# Import des modules LeadHunter
|
||||
from leadhunter_crm import (
|
||||
init_db,
|
||||
insert_leads,
|
||||
get_leads,
|
||||
get_lead_by_id,
|
||||
update_lead_status,
|
||||
get_stats,
|
||||
export_csv,
|
||||
VALID_STATUSES,
|
||||
DB_PATH,
|
||||
)
|
||||
from leadhunter_scraper import run_scraping, GOOGLE_PLACES_API_KEY
|
||||
from leadhunter_scorer import LeadScorer
|
||||
|
||||
# ─── Assertions au démarrage ─────────────────────────────────────────────────
|
||||
# Vérification obligatoire : la clé API doit être présente au démarrage
|
||||
assert os.environ.get("GOOGLE_PLACES_API_KEY"), (
|
||||
"GOOGLE_PLACES_API_KEY manquante. "
|
||||
"Ajouter dans /home/h3r7/.env : export GOOGLE_PLACES_API_KEY=xxx"
|
||||
)
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.api")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── App Flask ───────────────────────────────────────────────────────────────
|
||||
app = Flask(__name__)
|
||||
CORS(app)
|
||||
|
||||
# Scorer singleton
|
||||
scorer = LeadScorer()
|
||||
|
||||
# État global du job de scraping (simple flag — pas de celery nécessaire pour le POC)
|
||||
_scrape_job = {
|
||||
"running": False,
|
||||
"last_run": None,
|
||||
"last_count": 0,
|
||||
"last_error": None,
|
||||
}
|
||||
_scrape_lock = threading.Lock()
|
||||
|
||||
# ─── Init DB ─────────────────────────────────────────────────────────────────
|
||||
init_db(DB_PATH)
|
||||
logger.info("LeadHunter API démarrée — DB initialisée.")
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _run_scrape_job(max_leads: int, use_google: bool, use_osm: bool) -> None:
|
||||
"""Job de scraping exécuté dans un thread séparé."""
|
||||
with _scrape_lock:
|
||||
_scrape_job["running"] = True
|
||||
_scrape_job["last_error"] = None
|
||||
|
||||
try:
|
||||
leads_raw = run_scraping(
|
||||
max_leads=max_leads,
|
||||
use_google=use_google,
|
||||
use_osm=use_osm,
|
||||
)
|
||||
leads_scored = scorer.score_leads(leads_raw)
|
||||
inserted_ids = insert_leads(leads_scored)
|
||||
|
||||
with _scrape_lock:
|
||||
_scrape_job["last_count"] = len(inserted_ids)
|
||||
from datetime import datetime
|
||||
|
||||
_scrape_job["last_run"] = datetime.utcnow().isoformat() + "Z"
|
||||
|
||||
logger.info(f"Scrape job terminé : {len(inserted_ids)} leads insérés.")
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Scrape job erreur : {e}")
|
||||
with _scrape_lock:
|
||||
_scrape_job["last_error"] = str(e)
|
||||
|
||||
finally:
|
||||
with _scrape_lock:
|
||||
_scrape_job["running"] = False
|
||||
|
||||
|
||||
# ─── Routes ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/api/leads", methods=["GET"])
|
||||
def api_get_leads():
|
||||
"""
|
||||
Liste les leads du CRM.
|
||||
|
||||
Query params :
|
||||
- status (str, optional) : filtre sur new/contacted/closed/rejected
|
||||
- limit (int, default=50) : pagination
|
||||
- offset (int, default=0) : pagination
|
||||
"""
|
||||
status = request.args.get("status")
|
||||
try:
|
||||
limit = int(request.args.get("limit", 50))
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except ValueError:
|
||||
return jsonify({"error": "limit et offset doivent être des entiers"}), 400
|
||||
|
||||
if status and status not in VALID_STATUSES:
|
||||
return jsonify(
|
||||
{"error": f"status invalide. Valeurs acceptées : {VALID_STATUSES}"}
|
||||
), 400
|
||||
|
||||
leads = get_leads(status=status, limit=limit, offset=offset)
|
||||
return jsonify(
|
||||
{
|
||||
"leads": leads,
|
||||
"count": len(leads),
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"status_filter": status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leads/scrape", methods=["POST"])
|
||||
def api_scrape():
|
||||
"""
|
||||
Lance un job de scraping asynchrone.
|
||||
|
||||
Body JSON (optionnel) :
|
||||
- max_leads (int, default=100)
|
||||
- use_google (bool, default=true)
|
||||
- use_osm (bool, default=true)
|
||||
|
||||
Retourne immédiatement avec le statut du job.
|
||||
"""
|
||||
with _scrape_lock:
|
||||
if _scrape_job["running"]:
|
||||
return jsonify(
|
||||
{
|
||||
"status": "already_running",
|
||||
"message": "Un job de scraping est déjà en cours.",
|
||||
}
|
||||
), 409
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
max_leads = int(body.get("max_leads", 100))
|
||||
use_google = bool(body.get("use_google", True))
|
||||
use_osm = bool(body.get("use_osm", True))
|
||||
|
||||
thread = threading.Thread(
|
||||
target=_run_scrape_job,
|
||||
args=(max_leads, use_google, use_osm),
|
||||
daemon=True,
|
||||
)
|
||||
thread.start()
|
||||
|
||||
logger.info(
|
||||
f"Job de scraping lancé (max_leads={max_leads}, "
|
||||
f"use_google={use_google}, use_osm={use_osm})"
|
||||
)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "started",
|
||||
"message": "Job de scraping démarré en arrière-plan.",
|
||||
"params": {
|
||||
"max_leads": max_leads,
|
||||
"use_google": use_google,
|
||||
"use_osm": use_osm,
|
||||
},
|
||||
}
|
||||
), 202
|
||||
|
||||
|
||||
@app.route("/api/leads/scrape/status", methods=["GET"])
|
||||
def api_scrape_status():
|
||||
"""Retourne l'état courant du job de scraping."""
|
||||
with _scrape_lock:
|
||||
return jsonify(dict(_scrape_job))
|
||||
|
||||
|
||||
@app.route("/api/leads/stats", methods=["GET"])
|
||||
def api_stats():
|
||||
"""
|
||||
Statistiques globales du CRM LeadHunter.
|
||||
|
||||
Retourne : total, by_status, by_source, avg_score, top_leads_count
|
||||
"""
|
||||
stats = get_stats()
|
||||
if not stats:
|
||||
return jsonify({"error": "Impossible de calculer les statistiques"}), 500
|
||||
return jsonify(stats)
|
||||
|
||||
|
||||
@app.route("/api/leads/export", methods=["GET"])
|
||||
def api_export():
|
||||
"""
|
||||
Export CSV de tous les leads (ou filtrés par status).
|
||||
|
||||
Query params :
|
||||
- status (str, optional)
|
||||
"""
|
||||
status = request.args.get("status")
|
||||
if status and status not in VALID_STATUSES:
|
||||
return jsonify({"error": f"status invalide : {VALID_STATUSES}"}), 400
|
||||
|
||||
csv_content = export_csv(status=status)
|
||||
filename = f"leadhunter_leads{'_' + status if status else ''}.csv"
|
||||
|
||||
return Response(
|
||||
csv_content,
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename={filename}",
|
||||
"Content-Type": "text/csv; charset=utf-8",
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/leads/<int:lead_id>/status", methods=["PATCH"])
|
||||
def api_update_status(lead_id: int):
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
Body JSON :
|
||||
- status (str) : new | contacted | closed | rejected
|
||||
"""
|
||||
body = request.get_json(silent=True)
|
||||
if not body or "status" not in body:
|
||||
return jsonify({"error": "Body JSON requis avec le champ 'status'"}), 400
|
||||
|
||||
new_status = body["status"]
|
||||
if new_status not in VALID_STATUSES:
|
||||
return jsonify({"error": f"status invalide. Valeurs : {VALID_STATUSES}"}), 400
|
||||
|
||||
lead = get_lead_by_id(lead_id)
|
||||
if not lead:
|
||||
return jsonify({"error": f"Lead id={lead_id} introuvable"}), 404
|
||||
|
||||
success = update_lead_status(lead_id, new_status)
|
||||
if not success:
|
||||
return jsonify({"error": "Mise à jour échouée"}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"lead_id": lead_id,
|
||||
"new_status": new_status,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""Healthcheck pour systemd / monitoring."""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"service": "leadhunter-api",
|
||||
"port": 8775,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
# ─── Entrypoint ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8775, debug=False)
|
||||
349
leadhunter_crm.py
Normal file
349
leadhunter_crm.py
Normal file
@@ -0,0 +1,349 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter CRM (SQLite)
|
||||
=====================================
|
||||
Couche de persistance SQLite pour les leads LeadHunter.
|
||||
|
||||
Schéma validé CTO (HRT-66) :
|
||||
CREATE TABLE leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL, -- 'google_places' ou 'osm'
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
rating REAL,
|
||||
reviews_count INTEGER,
|
||||
website TEXT,
|
||||
score INTEGER,
|
||||
rgpd_ok BOOLEAN DEFAULT 1,
|
||||
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'new' -- new, contacted, closed, rejected
|
||||
);
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import logging
|
||||
import csv
|
||||
import io
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
from typing import Optional
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.crm")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── Chemin DB ───────────────────────────────────────────────────────────────
|
||||
DB_PATH = "/home/h3r7/leadhunter.db"
|
||||
|
||||
# Statuts valides pour un lead
|
||||
VALID_STATUSES = {"new", "contacted", "closed", "rejected"}
|
||||
|
||||
|
||||
# ─── Initialisation ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def init_db(db_path: str = DB_PATH) -> None:
|
||||
"""
|
||||
Crée la base SQLite et la table leads si elle n'existe pas.
|
||||
Idempotent — peut être appelé au démarrage de l'API.
|
||||
"""
|
||||
with sqlite3.connect(db_path) as conn:
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
source TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
address TEXT,
|
||||
phone TEXT,
|
||||
rating REAL,
|
||||
reviews_count INTEGER,
|
||||
website TEXT,
|
||||
score INTEGER,
|
||||
rgpd_ok BOOLEAN DEFAULT 1,
|
||||
scraped_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
status TEXT DEFAULT 'new'
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
logger.info(f"DB initialisée : {db_path}")
|
||||
|
||||
|
||||
# ─── Context manager ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _get_conn(db_path: str = DB_PATH):
|
||||
"""Fournit une connexion SQLite avec row_factory."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
try:
|
||||
yield conn
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.warning(f"DB transaction rollback : {e}")
|
||||
raise
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ─── CRUD ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def insert_lead(lead: dict, db_path: str = DB_PATH) -> Optional[int]:
|
||||
"""
|
||||
Insère un lead normalisé dans la DB.
|
||||
|
||||
Args:
|
||||
lead: dict avec les champs normalisés (source, name, address, ...)
|
||||
db_path: chemin vers la DB SQLite.
|
||||
|
||||
Returns:
|
||||
L'id SQLite du lead inséré, ou None en cas d'erreur.
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
cursor = conn.execute(
|
||||
"""
|
||||
INSERT INTO leads
|
||||
(source, name, address, phone, rating, reviews_count,
|
||||
website, score, rgpd_ok, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
lead.get("source", "unknown"),
|
||||
lead.get("name", ""),
|
||||
lead.get("address", ""),
|
||||
lead.get("phone", ""),
|
||||
lead.get("rating"),
|
||||
lead.get("reviews_count"),
|
||||
lead.get("website", ""),
|
||||
lead.get("score"),
|
||||
1 if lead.get("rgpd_ok", True) else 0,
|
||||
lead.get("status", "new"),
|
||||
),
|
||||
)
|
||||
lead_id = cursor.lastrowid
|
||||
logger.info(f"Lead inséré id={lead_id} : {lead.get('name')}")
|
||||
return lead_id
|
||||
except Exception as e:
|
||||
logger.warning(f"insert_lead error : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def insert_leads(leads: list[dict], db_path: str = DB_PATH) -> list[int]:
|
||||
"""
|
||||
Insère une liste de leads en batch.
|
||||
|
||||
Returns:
|
||||
Liste des ids insérés.
|
||||
"""
|
||||
ids = []
|
||||
for lead in leads:
|
||||
lead_id = insert_lead(lead, db_path)
|
||||
if lead_id is not None:
|
||||
ids.append(lead_id)
|
||||
logger.info(f"insert_leads : {len(ids)}/{len(leads)} insérés.")
|
||||
return ids
|
||||
|
||||
|
||||
def get_leads(
|
||||
status: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
offset: int = 0,
|
||||
db_path: str = DB_PATH,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Récupère les leads avec filtre optionnel sur le statut.
|
||||
|
||||
Args:
|
||||
status: filtre sur le champ 'status' (new, contacted, closed, rejected).
|
||||
limit: pagination — nombre de résultats max.
|
||||
offset: pagination — décalage.
|
||||
|
||||
Returns:
|
||||
Liste de dicts (tous les champs de la table leads).
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
if status:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads WHERE status = ? ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||
(status, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM leads ORDER BY score DESC, scraped_at DESC LIMIT ? OFFSET ?",
|
||||
(limit, offset),
|
||||
).fetchall()
|
||||
return [dict(r) for r in rows]
|
||||
except Exception as e:
|
||||
logger.warning(f"get_leads error : {e}")
|
||||
return []
|
||||
|
||||
|
||||
def get_lead_by_id(lead_id: int, db_path: str = DB_PATH) -> Optional[dict]:
|
||||
"""Récupère un lead par son id."""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
row = conn.execute(
|
||||
"SELECT * FROM leads WHERE id = ?", (lead_id,)
|
||||
).fetchone()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.warning(f"get_lead_by_id error : {e}")
|
||||
return None
|
||||
|
||||
|
||||
def update_lead_status(lead_id: int, status: str, db_path: str = DB_PATH) -> bool:
|
||||
"""
|
||||
Met à jour le statut d'un lead.
|
||||
|
||||
Args:
|
||||
lead_id: id du lead.
|
||||
status: nouveau statut ('new', 'contacted', 'closed', 'rejected').
|
||||
|
||||
Returns:
|
||||
True si mise à jour réussie, False sinon.
|
||||
"""
|
||||
if status not in VALID_STATUSES:
|
||||
logger.warning(f"update_lead_status : statut invalide '{status}'")
|
||||
return False
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
conn.execute(
|
||||
"UPDATE leads SET status = ? WHERE id = ?",
|
||||
(status, lead_id),
|
||||
)
|
||||
logger.info(f"Lead id={lead_id} statut → {status}")
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.warning(f"update_lead_status error : {e}")
|
||||
return False
|
||||
|
||||
|
||||
def get_stats(db_path: str = DB_PATH) -> dict:
|
||||
"""
|
||||
Retourne les statistiques globales du CRM.
|
||||
|
||||
Returns:
|
||||
Dict avec total, by_status, by_source, avg_score, top_leads_count
|
||||
"""
|
||||
try:
|
||||
with _get_conn(db_path) as conn:
|
||||
total = conn.execute("SELECT COUNT(*) FROM leads").fetchone()[0]
|
||||
|
||||
by_status_rows = conn.execute(
|
||||
"SELECT status, COUNT(*) as cnt FROM leads GROUP BY status"
|
||||
).fetchall()
|
||||
by_status = {r["status"]: r["cnt"] for r in by_status_rows}
|
||||
|
||||
by_source_rows = conn.execute(
|
||||
"SELECT source, COUNT(*) as cnt FROM leads GROUP BY source"
|
||||
).fetchall()
|
||||
by_source = {r["source"]: r["cnt"] for r in by_source_rows}
|
||||
|
||||
avg_score_row = conn.execute(
|
||||
"SELECT AVG(score) FROM leads WHERE score IS NOT NULL"
|
||||
).fetchone()
|
||||
avg_score = round(avg_score_row[0] or 0, 2)
|
||||
|
||||
# Leads "chauds" = score ≥ 5
|
||||
top_count = conn.execute(
|
||||
"SELECT COUNT(*) FROM leads WHERE score >= 5"
|
||||
).fetchone()[0]
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"by_status": by_status,
|
||||
"by_source": by_source,
|
||||
"avg_score": avg_score,
|
||||
"top_leads_count": top_count,
|
||||
"generated_at": datetime.utcnow().isoformat() + "Z",
|
||||
}
|
||||
except Exception as e:
|
||||
logger.warning(f"get_stats error : {e}")
|
||||
return {}
|
||||
|
||||
|
||||
def export_csv(
|
||||
status: Optional[str] = None,
|
||||
db_path: str = DB_PATH,
|
||||
) -> str:
|
||||
"""
|
||||
Exporte les leads en CSV (string).
|
||||
|
||||
Args:
|
||||
status: filtre optionnel sur le statut.
|
||||
|
||||
Returns:
|
||||
Contenu CSV en string UTF-8.
|
||||
"""
|
||||
leads = get_leads(status=status, limit=10000, db_path=db_path)
|
||||
|
||||
output = io.StringIO()
|
||||
fieldnames = [
|
||||
"id",
|
||||
"source",
|
||||
"name",
|
||||
"address",
|
||||
"phone",
|
||||
"rating",
|
||||
"reviews_count",
|
||||
"website",
|
||||
"score",
|
||||
"rgpd_ok",
|
||||
"scraped_at",
|
||||
"status",
|
||||
]
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(leads)
|
||||
|
||||
logger.info(f"export_csv : {len(leads)} leads exportés.")
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_db()
|
||||
|
||||
# Test insertion
|
||||
test_lead = {
|
||||
"source": "google_places",
|
||||
"name": "Restaurant Test",
|
||||
"address": "10 rue de la Paix, 59000 Lille",
|
||||
"phone": "+33 3 20 00 00 01",
|
||||
"rating": 4.5,
|
||||
"reviews_count": 120,
|
||||
"website": "",
|
||||
"score": 8,
|
||||
"rgpd_ok": True,
|
||||
"status": "new",
|
||||
}
|
||||
lead_id = insert_lead(test_lead)
|
||||
print(f"Lead inséré : id={lead_id}")
|
||||
|
||||
leads = get_leads()
|
||||
print(f"Leads en DB : {len(leads)}")
|
||||
|
||||
stats = get_stats()
|
||||
print(f"Stats : {stats}")
|
||||
193
leadhunter_scorer.py
Normal file
193
leadhunter_scorer.py
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter Scorer
|
||||
================================
|
||||
Moteur de scoring des leads restaurants MEL.
|
||||
|
||||
Critères (ordre de priorité métier) :
|
||||
1. [+3] Site web absent ← CRITIQUE : raison d'être du produit
|
||||
2. [+2] Nombre d'avis élevé (≥ 50) : forte activité = bon prospect de vente
|
||||
3. [+2] Note Google élevée (≥ 4.0) : établissement sérieux
|
||||
4. [+1] Téléphone présent : facilite la prise de contact
|
||||
5. [-1] Note faible (< 3.0) : risque reputationnel pour la prestation web
|
||||
|
||||
Score maximum théorique : 8
|
||||
Score minimum : 0 (leads avec site web ne doivent pas passer ici)
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import logging
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.scorer")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
|
||||
# ─── Scorer ──────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class LeadScorer:
|
||||
"""
|
||||
Calcule le score de priorité d'un lead.
|
||||
|
||||
Le score sert à trier les leads dans le CRM :
|
||||
- Score élevé = prospect chaud (sans site + actif + bien noté)
|
||||
- Score faible = prospect froid (peut être ignoré ou traité en dernier)
|
||||
"""
|
||||
|
||||
def _calculate_score(self, lead: dict) -> int:
|
||||
"""
|
||||
Calcule le score d'un lead.
|
||||
|
||||
Args:
|
||||
lead: dict avec les champs normalisés du scraper
|
||||
(name, website, rating, reviews_count, phone, ...)
|
||||
|
||||
Returns:
|
||||
Score entier (0–8)
|
||||
"""
|
||||
score = 0
|
||||
|
||||
# ── Critère 1 : site web absent [CRITIQUE — logique métier centrale] ──
|
||||
# C'est le critère n°1 : on cherche des restaurants SANS site web
|
||||
# pour leur proposer une création de site à 800–1500€.
|
||||
website = lead.get("website", "")
|
||||
if not website or not website.strip():
|
||||
score += 3
|
||||
logger.debug(f"{lead.get('name')}: +3 (site web absent)")
|
||||
else:
|
||||
# Si le lead a un site web, score = 0 immédiatement.
|
||||
# Ce cas ne devrait pas se produire (filtre scraper),
|
||||
# mais on reste défensif.
|
||||
logger.warning(
|
||||
f"{lead.get('name')}: site web présent ({website}), "
|
||||
"lead ignoré pour scoring."
|
||||
)
|
||||
return 0
|
||||
|
||||
# ── Critère 2 : nombre d'avis élevé (≥ 50) ──────────────────────────
|
||||
reviews = lead.get("reviews_count")
|
||||
if reviews is not None:
|
||||
try:
|
||||
reviews = int(reviews)
|
||||
if reviews >= 50:
|
||||
score += 2
|
||||
logger.debug(f"{lead.get('name')}: +2 (avis ≥ 50 : {reviews})")
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"reviews_count invalide pour {lead.get('name')}: {e}")
|
||||
|
||||
# ── Critère 3 : bonne note Google (≥ 4.0) ───────────────────────────
|
||||
rating = lead.get("rating")
|
||||
if rating is not None:
|
||||
try:
|
||||
rating = float(rating)
|
||||
if rating >= 4.0:
|
||||
score += 2
|
||||
logger.debug(f"{lead.get('name')}: +2 (note ≥ 4.0 : {rating})")
|
||||
elif rating < 3.0:
|
||||
score -= 1
|
||||
logger.debug(f"{lead.get('name')}: -1 (note < 3.0 : {rating})")
|
||||
except (TypeError, ValueError) as e:
|
||||
logger.warning(f"rating invalide pour {lead.get('name')}: {e}")
|
||||
|
||||
# ── Critère 4 : téléphone présent ────────────────────────────────────
|
||||
phone = lead.get("phone", "")
|
||||
if phone and phone.strip():
|
||||
score += 1
|
||||
logger.debug(f"{lead.get('name')}: +1 (téléphone présent)")
|
||||
|
||||
# Plancher à 0
|
||||
score = max(0, score)
|
||||
logger.info(f"Score calculé pour '{lead.get('name')}' : {score}/8")
|
||||
return score
|
||||
|
||||
def score_lead(self, lead: dict) -> dict:
|
||||
"""
|
||||
Enrichit un lead avec son score.
|
||||
|
||||
Args:
|
||||
lead: dict normalisé du scraper.
|
||||
|
||||
Returns:
|
||||
Même dict avec le champ 'score' ajouté/mis à jour.
|
||||
"""
|
||||
lead = dict(lead) # copie défensive
|
||||
lead["score"] = self._calculate_score(lead)
|
||||
return lead
|
||||
|
||||
def score_leads(self, leads: list[dict]) -> list[dict]:
|
||||
"""
|
||||
Score et trie une liste de leads (score décroissant).
|
||||
|
||||
Args:
|
||||
leads: liste de dicts normalisés.
|
||||
|
||||
Returns:
|
||||
Liste triée par score décroissant.
|
||||
"""
|
||||
scored = [self.score_lead(lead) for lead in leads]
|
||||
scored.sort(key=lambda l: l.get("score", 0), reverse=True)
|
||||
logger.info(
|
||||
f"score_leads terminé : {len(scored)} leads scorés. "
|
||||
f"Score max = {scored[0]['score'] if scored else 0}, "
|
||||
f"Score min = {scored[-1]['score'] if scored else 0}"
|
||||
)
|
||||
return scored
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Exemple de test rapide sans appel API
|
||||
test_leads = [
|
||||
{
|
||||
"name": "Restaurant A",
|
||||
"website": "",
|
||||
"rating": 4.5,
|
||||
"reviews_count": 120,
|
||||
"phone": "+33 3 20 00 00 01",
|
||||
},
|
||||
{
|
||||
"name": "Restaurant B",
|
||||
"website": "",
|
||||
"rating": 3.8,
|
||||
"reviews_count": 30,
|
||||
"phone": "",
|
||||
},
|
||||
{
|
||||
"name": "Café C",
|
||||
"website": "",
|
||||
"rating": 2.5,
|
||||
"reviews_count": 5,
|
||||
"phone": "+33 3 20 00 00 03",
|
||||
},
|
||||
{
|
||||
"name": "Bar D avec site",
|
||||
"website": "https://bar-d.fr",
|
||||
"rating": 4.2,
|
||||
"reviews_count": 80,
|
||||
"phone": "+33 3 20 00 00 04",
|
||||
},
|
||||
]
|
||||
|
||||
scorer = LeadScorer()
|
||||
results = scorer.score_leads(test_leads)
|
||||
|
||||
print("\n=== Résultats scoring ===")
|
||||
for r in results:
|
||||
print(f" [{r['score']:2d}/8] {r['name']}")
|
||||
397
leadhunter_scraper.py
Normal file
397
leadhunter_scraper.py
Normal file
@@ -0,0 +1,397 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
H3R7Tech — LeadHunter Scraper
|
||||
================================
|
||||
Agent de scraping pour la détection de restaurants sans site web
|
||||
dans la MEL (Métropole Européenne de Lille).
|
||||
|
||||
Sources :
|
||||
- Google Places API (primary)
|
||||
- OpenStreetMap / Overpass API (fallback)
|
||||
|
||||
Quota Google Places Free Tier :
|
||||
- 28 500 requêtes/mois ≈ 950/jour
|
||||
- Compteur persistent dans /home/h3r7/leadhunter_quota.json
|
||||
|
||||
Auteur: H3R7Tech Backend Engineer
|
||||
Issue: HRT-66
|
||||
"""
|
||||
|
||||
import os
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
import requests
|
||||
from datetime import date, datetime
|
||||
from logging.handlers import RotatingFileHandler
|
||||
|
||||
# ─── Logging ────────────────────────────────────────────────────────────────
|
||||
logger = logging.getLogger("leadhunter.scraper")
|
||||
|
||||
_handler = RotatingFileHandler(
|
||||
"/home/h3r7/leadhunter.log",
|
||||
maxBytes=5 * 1024 * 1024, # 5 MB
|
||||
backupCount=3,
|
||||
)
|
||||
_handler.setFormatter(
|
||||
logging.Formatter("%(asctime)s %(levelname)-8s %(name)s — %(message)s")
|
||||
)
|
||||
logger.setLevel(logging.INFO)
|
||||
if not logger.handlers:
|
||||
logger.addHandler(_handler)
|
||||
logger.addHandler(logging.StreamHandler())
|
||||
|
||||
# ─── Configuration ───────────────────────────────────────────────────────────
|
||||
GOOGLE_PLACES_API_KEY = os.environ.get("GOOGLE_PLACES_API_KEY")
|
||||
|
||||
# Quota journalier Google Places Free Tier
|
||||
DAILY_QUOTA_FILE = "/home/h3r7/leadhunter_quota.json"
|
||||
DAILY_QUOTA_LIMIT = 900 # marge de sécurité vs les 950 théoriques
|
||||
|
||||
# Délai entre requêtes Places pour éviter rate-limiting
|
||||
PLACES_SLEEP_S = 0.5
|
||||
|
||||
# Bounding box MEL (Métropole Européenne de Lille)
|
||||
MEL_CENTER_LAT = 50.6292
|
||||
MEL_CENTER_LNG = 3.0573
|
||||
MEL_RADIUS_M = 20000 # 20 km autour de Lille
|
||||
|
||||
# Types de lieux ciblés
|
||||
TARGET_TYPES = ["restaurant", "cafe", "bar", "bakery", "food"]
|
||||
|
||||
# Overpass API endpoint
|
||||
OVERPASS_URL = "https://overpass-api.de/api/interpreter"
|
||||
|
||||
# Requête Overpass MEL — bounding box directe (50.4,2.8,50.8,3.3) couvrant la MEL
|
||||
# Fix HRT-72 : la résolution area["name"=...] échoue silencieusement sur l'API Overpass publique
|
||||
OVERPASS_MEL_QUERY = """
|
||||
[out:json][timeout:60];
|
||||
(
|
||||
node["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||
way["amenity"~"^(restaurant|cafe|bar|fast_food|bakery)$"][!"website"](50.4,2.8,50.8,3.3);
|
||||
);
|
||||
out center 200;
|
||||
"""
|
||||
|
||||
|
||||
# ─── Quota Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _load_quota() -> dict:
|
||||
"""Charge le compteur quotidien depuis le fichier JSON."""
|
||||
today = str(date.today())
|
||||
if os.path.exists(DAILY_QUOTA_FILE):
|
||||
try:
|
||||
with open(DAILY_QUOTA_FILE, "r") as f:
|
||||
data = json.load(f)
|
||||
if data.get("date") == today:
|
||||
return data
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible de lire le fichier quota : {e}")
|
||||
return {"date": today, "count": 0}
|
||||
|
||||
|
||||
def _save_quota(data: dict) -> None:
|
||||
"""Persiste le compteur quotidien."""
|
||||
try:
|
||||
with open(DAILY_QUOTA_FILE, "w") as f:
|
||||
json.dump(data, f)
|
||||
except Exception as e:
|
||||
logger.warning(f"Impossible d'écrire le fichier quota : {e}")
|
||||
|
||||
|
||||
def _increment_quota(n: int = 1) -> int:
|
||||
"""Incrémente le compteur et retourne le total du jour."""
|
||||
quota = _load_quota()
|
||||
quota["count"] += n
|
||||
_save_quota(quota)
|
||||
return quota["count"]
|
||||
|
||||
|
||||
def _quota_remaining() -> int:
|
||||
"""Retourne le nombre de requêtes restantes pour aujourd'hui."""
|
||||
quota = _load_quota()
|
||||
return max(0, DAILY_QUOTA_LIMIT - quota["count"])
|
||||
|
||||
|
||||
# ─── Google Places Scraper ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class GooglePlacesScraper:
|
||||
"""
|
||||
Scraping via Google Places API (Nearby Search + Place Details).
|
||||
Filtre les lieux sans site web côté API.
|
||||
"""
|
||||
|
||||
BASE_URL = "https://maps.googleapis.com/maps/api/place"
|
||||
|
||||
def __init__(self):
|
||||
if not GOOGLE_PLACES_API_KEY:
|
||||
raise EnvironmentError(
|
||||
"GOOGLE_PLACES_API_KEY non définie. "
|
||||
"Ajouter dans /home/h3r7/.env et relancer."
|
||||
)
|
||||
self.api_key = GOOGLE_PLACES_API_KEY
|
||||
|
||||
def _nearby_search(self, place_type: str, page_token: str = None) -> dict:
|
||||
"""Appel Nearby Search — 1 requête comptabilisée."""
|
||||
params = {
|
||||
"key": self.api_key,
|
||||
"location": f"{MEL_CENTER_LAT},{MEL_CENTER_LNG}",
|
||||
"radius": MEL_RADIUS_M,
|
||||
"type": place_type,
|
||||
}
|
||||
if page_token:
|
||||
params["pagetoken"] = page_token
|
||||
|
||||
_increment_quota()
|
||||
time.sleep(PLACES_SLEEP_S)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.BASE_URL}/nearbysearch/json",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"NearbySearch error (type={place_type}): {e}")
|
||||
return {}
|
||||
|
||||
def _place_details(self, place_id: str) -> dict:
|
||||
"""Place Details pour récupérer website, phone, rating, etc. — 1 requête."""
|
||||
params = {
|
||||
"key": self.api_key,
|
||||
"place_id": place_id,
|
||||
"fields": "name,formatted_address,formatted_phone_number,website,rating,user_ratings_total",
|
||||
}
|
||||
|
||||
_increment_quota()
|
||||
time.sleep(PLACES_SLEEP_S)
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
f"{self.BASE_URL}/details/json",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json().get("result", {})
|
||||
except Exception as e:
|
||||
logger.warning(f"PlaceDetails error (place_id={place_id}): {e}")
|
||||
return {}
|
||||
|
||||
def scrape(self, max_leads: int = 50) -> list[dict]:
|
||||
"""
|
||||
Scrape les restaurants/cafés/bars MEL sans site web.
|
||||
|
||||
Retourne une liste de dicts normalisés compatibles LeadHunter CRM :
|
||||
source, name, address, phone, rating, reviews_count, website, rgpd_ok
|
||||
"""
|
||||
leads = []
|
||||
seen_ids = set()
|
||||
|
||||
for place_type in TARGET_TYPES:
|
||||
if _quota_remaining() < 10:
|
||||
logger.warning(
|
||||
"Quota journalier presque épuisé — arrêt scraping Google Places."
|
||||
)
|
||||
break
|
||||
|
||||
logger.info(f"Scraping Google Places — type={place_type}")
|
||||
page_token = None
|
||||
|
||||
while True:
|
||||
if _quota_remaining() < 5:
|
||||
logger.warning("Quota insuffisant pour continuer la pagination.")
|
||||
break
|
||||
|
||||
data = self._nearby_search(place_type, page_token)
|
||||
results = data.get("results", [])
|
||||
|
||||
for place in results:
|
||||
if len(leads) >= max_leads:
|
||||
break
|
||||
|
||||
place_id = place.get("place_id", "")
|
||||
if not place_id or place_id in seen_ids:
|
||||
continue
|
||||
seen_ids.add(place_id)
|
||||
|
||||
if _quota_remaining() < 2:
|
||||
logger.warning("Quota épuisé avant details.")
|
||||
break
|
||||
|
||||
details = self._place_details(place_id)
|
||||
|
||||
# Filtre : on ne garde que les lieux SANS site web
|
||||
if details.get("website"):
|
||||
continue
|
||||
|
||||
lead = {
|
||||
"source": "google_places",
|
||||
"name": details.get("name") or place.get("name", ""),
|
||||
"address": details.get("formatted_address")
|
||||
or place.get("vicinity", ""),
|
||||
"phone": details.get("formatted_phone_number", ""),
|
||||
"rating": details.get("rating") or place.get("rating"),
|
||||
"reviews_count": details.get("user_ratings_total")
|
||||
or place.get("user_ratings_total"),
|
||||
"website": "",
|
||||
"rgpd_ok": True, # Données publiques Google Places uniquement
|
||||
}
|
||||
leads.append(lead)
|
||||
logger.info(f"Lead trouvé (Google Places) : {lead['name']}")
|
||||
|
||||
if len(leads) >= max_leads:
|
||||
break
|
||||
|
||||
page_token = data.get("next_page_token")
|
||||
if not page_token:
|
||||
break
|
||||
|
||||
# L'API Google Places nécessite un délai avant d'utiliser next_page_token
|
||||
time.sleep(2)
|
||||
|
||||
logger.info(f"Google Places : {len(leads)} leads collectés.")
|
||||
return leads
|
||||
|
||||
|
||||
# ─── Overpass / OSM Fallback ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class OverpassScraper:
|
||||
"""
|
||||
Fallback OSM via Overpass API.
|
||||
Cible les nœuds/ways dans la boundary MEL sans attribut 'website'.
|
||||
Données publiques ODbL — RGPD OK.
|
||||
"""
|
||||
|
||||
def scrape(self, max_leads: int = 100) -> list[dict]:
|
||||
"""
|
||||
Scrape via Overpass API — retourne des leads normalisés.
|
||||
"""
|
||||
logger.info("Scraping Overpass OSM — boundary MEL")
|
||||
leads = []
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
OVERPASS_URL,
|
||||
data={"data": OVERPASS_MEL_QUERY},
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded", # Fix HRT-72 Bug2
|
||||
"User-Agent": "H3R7Tech-LeadHunter/1.0 (contact@h3r7tech.fr)", # Fix HRT-72 Bug3: overpass-api.de blocks python-requests UA
|
||||
},
|
||||
timeout=90,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
logger.warning(f"Overpass API error : {e}")
|
||||
return []
|
||||
|
||||
elements = data.get("elements", [])
|
||||
logger.info(f"Overpass : {len(elements)} éléments bruts reçus.")
|
||||
|
||||
for el in elements[:max_leads]:
|
||||
tags = el.get("tags", {})
|
||||
|
||||
# Coordonnées (pour les ways, Overpass retourne 'center')
|
||||
lat = el.get("lat") or (el.get("center") or {}).get("lat")
|
||||
lon = el.get("lon") or (el.get("center") or {}).get("lon")
|
||||
|
||||
name = tags.get("name", "")
|
||||
if not name:
|
||||
continue # Ignorer les lieux sans nom
|
||||
|
||||
addr_parts = [
|
||||
tags.get("addr:housenumber", ""),
|
||||
tags.get("addr:street", ""),
|
||||
tags.get("addr:city", ""),
|
||||
tags.get("addr:postcode", ""),
|
||||
]
|
||||
address = " ".join(p for p in addr_parts if p).strip()
|
||||
if not address and lat and lon:
|
||||
address = f"{lat:.4f},{lon:.4f}"
|
||||
|
||||
lead = {
|
||||
"source": "osm",
|
||||
"name": name,
|
||||
"address": address,
|
||||
"phone": tags.get("phone", tags.get("contact:phone", "")),
|
||||
"rating": None,
|
||||
"reviews_count": None,
|
||||
"website": "",
|
||||
"rgpd_ok": True, # Données publiques ODbL
|
||||
}
|
||||
leads.append(lead)
|
||||
logger.info(f"Lead trouvé (OSM) : {lead['name']}")
|
||||
|
||||
logger.info(f"Overpass : {len(leads)} leads collectés.")
|
||||
return leads
|
||||
|
||||
|
||||
# ─── Orchestrateur ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run_scraping(
|
||||
max_leads: int = 100, use_google: bool = True, use_osm: bool = True
|
||||
) -> list[dict]:
|
||||
"""
|
||||
Lance le scraping Google Places + fallback OSM.
|
||||
|
||||
Args:
|
||||
max_leads: nombre maximum de leads à collecter au total.
|
||||
use_google: activer Google Places (nécessite GOOGLE_PLACES_API_KEY).
|
||||
use_osm: activer le fallback Overpass OSM.
|
||||
|
||||
Returns:
|
||||
Liste de leads normalisés (dédupliqués par nom + adresse).
|
||||
"""
|
||||
all_leads = []
|
||||
seen_keys = set()
|
||||
|
||||
def _dedup_key(lead: dict) -> str:
|
||||
return f"{lead['name'].lower().strip()}|{lead['address'].lower().strip()[:40]}"
|
||||
|
||||
if use_google:
|
||||
try:
|
||||
scraper = GooglePlacesScraper()
|
||||
google_leads = scraper.scrape(max_leads=max_leads)
|
||||
for lead in google_leads:
|
||||
k = _dedup_key(lead)
|
||||
if k not in seen_keys:
|
||||
seen_keys.add(k)
|
||||
all_leads.append(lead)
|
||||
except EnvironmentError as e:
|
||||
logger.warning(f"Google Places désactivé : {e}")
|
||||
use_google = False
|
||||
|
||||
remaining = max_leads - len(all_leads)
|
||||
if use_osm and remaining > 0:
|
||||
osm_leads = OverpassScraper().scrape(max_leads=remaining)
|
||||
for lead in osm_leads:
|
||||
k = _dedup_key(lead)
|
||||
if k not in seen_keys:
|
||||
seen_keys.add(k)
|
||||
all_leads.append(lead)
|
||||
|
||||
logger.info(
|
||||
f"run_scraping terminé — {len(all_leads)} leads uniques "
|
||||
f"(Google={use_google}, OSM={use_osm}). "
|
||||
f"Quota restant aujourd'hui : {_quota_remaining()}"
|
||||
)
|
||||
return all_leads
|
||||
|
||||
|
||||
# ─── CLI (debug) ─────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
assert GOOGLE_PLACES_API_KEY, (
|
||||
"GOOGLE_PLACES_API_KEY manquante — "
|
||||
"ajouter 'export GOOGLE_PLACES_API_KEY=xxx' dans /home/h3r7/.env"
|
||||
)
|
||||
leads = run_scraping(max_leads=10)
|
||||
for i, l in enumerate(leads, 1):
|
||||
print(f"{i:02d}. [{l['source']}] {l['name']} — {l['address']}")
|
||||
112
log_config.py
Normal file
112
log_config.py
Normal 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)
|
||||
208
login.html
208
login.html
@@ -1,44 +1,64 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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)}
|
||||
*, *::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>
|
||||
@@ -50,7 +70,9 @@
|
||||
<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>
|
||||
@@ -58,45 +80,103 @@
|
||||
<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>
|
||||
<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 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(m){alertBox.textContent=m;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,cond,msg){const e=document.getElementById(errId);if(cond){input.classList.add('error');e.textContent=msg;e.classList.add('show');return false}input.classList.remove('error');e.classList.remove('show');return true}
|
||||
form.addEventListener('submit',async e=>{
|
||||
e.preventDefault();hideError();
|
||||
const email=emailInput.value.trim(),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;
|
||||
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.')}
|
||||
else{localStorage.setItem('turf_token',data.token);localStorage.setItem('turf_user',JSON.stringify(data.user));location.href=new URLSearchParams(location.search).get('next')||'/dashboard'}
|
||||
}catch(_){showError('Erreur de connexion. Vérifiez votre réseau.')}
|
||||
finally{setLoading(false)}
|
||||
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);
|
||||
}
|
||||
});
|
||||
[emailInput,passInput].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'})();
|
||||
|
||||
// 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>
|
||||
</html>
|
||||
|
||||
255
metrics.py
Normal file
255
metrics.py
Normal 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
90
middleware.py
Normal file
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Middleware — rate limiting, CORS, and access logging
|
||||
Sprint 2-3: HRT-28
|
||||
"""
|
||||
|
||||
import logging
|
||||
import time
|
||||
from collections import defaultdict
|
||||
from datetime import datetime, timezone
|
||||
from functools import wraps
|
||||
from threading import Lock
|
||||
|
||||
from flask import request, jsonify, g
|
||||
|
||||
logger = logging.getLogger("turf_saas.middleware")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# In-memory rate limiter (100 req/min per IP)
|
||||
# For production: replace with Redis-backed counter
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_rate_store: dict = defaultdict(lambda: {"count": 0, "window_start": 0.0})
|
||||
_rate_lock = Lock()
|
||||
|
||||
RATE_LIMIT = 100 # max requests
|
||||
RATE_WINDOW = 60 # seconds
|
||||
|
||||
|
||||
def rate_limit_middleware(app):
|
||||
"""Register before_request rate limiting on the Flask app."""
|
||||
|
||||
@app.before_request
|
||||
def check_rate_limit():
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
|
||||
with _rate_lock:
|
||||
bucket = _rate_store[ip]
|
||||
if now - bucket["window_start"] >= RATE_WINDOW:
|
||||
bucket["count"] = 0
|
||||
bucket["window_start"] = now
|
||||
bucket["count"] += 1
|
||||
count = bucket["count"]
|
||||
remaining = max(0, RATE_LIMIT - count)
|
||||
|
||||
if count > RATE_LIMIT:
|
||||
logger.warning("Rate limit exceeded for IP %s", ip)
|
||||
resp = jsonify({"error": "Trop de requêtes. Limite: 100/min par IP."})
|
||||
resp.status_code = 429
|
||||
resp.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||
resp.headers["X-RateLimit-Remaining"] = "0"
|
||||
resp.headers["Retry-After"] = str(RATE_WINDOW)
|
||||
return resp
|
||||
|
||||
# Attach headers on all responses via after_request
|
||||
g.rl_remaining = remaining
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Access logs (timestamped)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
access_log = logging.getLogger("turf_saas.access")
|
||||
|
||||
|
||||
def access_log_middleware(app):
|
||||
"""Register after_request access logging on the Flask app."""
|
||||
|
||||
@app.after_request
|
||||
def log_access(response):
|
||||
ip = request.remote_addr or "unknown"
|
||||
ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
user_id = getattr(g, "current_user_id", "-")
|
||||
access_log.info(
|
||||
'%s %s %s "%s %s" %s %s',
|
||||
ts,
|
||||
ip,
|
||||
user_id,
|
||||
request.method,
|
||||
request.path,
|
||||
response.status_code,
|
||||
response.content_length or 0,
|
||||
)
|
||||
# Attach rate-limit headers
|
||||
remaining = getattr(g, "rl_remaining", None)
|
||||
if remaining is not None:
|
||||
response.headers["X-RateLimit-Limit"] = str(RATE_LIMIT)
|
||||
response.headers["X-RateLimit-Remaining"] = str(remaining)
|
||||
return response
|
||||
1
migrations/README
Normal file
1
migrations/README
Normal file
@@ -0,0 +1 @@
|
||||
Generic single-database configuration file
|
||||
68
migrations/env.py
Normal file
68
migrations/env.py
Normal 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()
|
||||
180
migrations/migrate_sqlite_to_postgres.py
Normal file
180
migrations/migrate_sqlite_to_postgres.py
Normal 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
26
migrations/script.py.mako
Normal 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"}
|
||||
345
migrations/versions/001_initial_schema.py
Normal file
345
migrations/versions/001_initial_schema.py
Normal 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")
|
||||
174
models/benchmark_report.json
Normal file
174
models/benchmark_report.json
Normal file
@@ -0,0 +1,174 @@
|
||||
{
|
||||
"run_date": "2026-04-25T19:09:46.629142",
|
||||
"dataset": {
|
||||
"db_path": "/home/h3r7/turf_saas/turf.db",
|
||||
"total_rows": 10899,
|
||||
"train_rows": 8719,
|
||||
"holdout_rows": 2180,
|
||||
"train_date_range": [
|
||||
"2026-03-31",
|
||||
"2026-04-19"
|
||||
],
|
||||
"holdout_date_range": [
|
||||
"2026-04-19",
|
||||
"2026-04-24"
|
||||
]
|
||||
},
|
||||
"baseline": {
|
||||
"model": "XGBoost (baseline)",
|
||||
"precision_at3": 0.5286821705426358,
|
||||
"auc": 0.7254057665061495
|
||||
},
|
||||
"individual_models": {
|
||||
"xgboost": {
|
||||
"model": "xgboost",
|
||||
"auc": 0.7856,
|
||||
"accuracy": 0.6917,
|
||||
"precision": 0.4865,
|
||||
"recall": 0.7229,
|
||||
"precision_at3": 0.5783,
|
||||
"latency_ms_per_row": 0.0112
|
||||
},
|
||||
"lightgbm": {
|
||||
"model": "lightgbm",
|
||||
"auc": 0.7833,
|
||||
"accuracy": 0.6995,
|
||||
"precision": 0.4951,
|
||||
"recall": 0.709,
|
||||
"precision_at3": 0.5736,
|
||||
"latency_ms_per_row": 0.0041
|
||||
},
|
||||
"mlp": {
|
||||
"model": "mlp",
|
||||
"auc": 0.7743,
|
||||
"accuracy": 0.7445,
|
||||
"precision": 0.5743,
|
||||
"recall": 0.5325,
|
||||
"precision_at3": 0.5643,
|
||||
"latency_ms_per_row": 0.0052
|
||||
}
|
||||
},
|
||||
"ensemble": {
|
||||
"model": "ensemble",
|
||||
"auc": 0.784,
|
||||
"accuracy": 0.7147,
|
||||
"precision": 0.5142,
|
||||
"recall": 0.6718,
|
||||
"precision_at3": 0.5814,
|
||||
"latency_ms_per_row": 0.0208
|
||||
},
|
||||
"delta_precision_at3": 0.0527,
|
||||
"deploy": true,
|
||||
"optuna": {
|
||||
"n_trials": 100,
|
||||
"xgboost_best_params": {
|
||||
"n_estimators": 141,
|
||||
"max_depth": 5,
|
||||
"learning_rate": 0.016298172447266404,
|
||||
"subsample": 0.7660470794373848,
|
||||
"colsample_bytree": 0.471124415020467,
|
||||
"min_child_weight": 14,
|
||||
"reg_alpha": 1.9364166463791586,
|
||||
"reg_lambda": 6.018030083488602,
|
||||
"gamma": 4.614943551368141
|
||||
},
|
||||
"lightgbm_best_params": {
|
||||
"n_estimators": 186,
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.012915117465216954,
|
||||
"num_leaves": 141,
|
||||
"subsample": 0.6193119116922561,
|
||||
"colsample_bytree": 0.539310022549326,
|
||||
"min_child_samples": 9,
|
||||
"reg_alpha": 0.6864583098112754,
|
||||
"reg_lambda": 0.0549259590914184
|
||||
}
|
||||
},
|
||||
"features": {
|
||||
"total": 43,
|
||||
"selected_by_shap": 31,
|
||||
"feature_list": [
|
||||
"age",
|
||||
"sexe_enc",
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_num",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"nb_partants",
|
||||
"discipline_enc",
|
||||
"specialite_enc",
|
||||
"oeilleres_enc",
|
||||
"tendance_cote_enc",
|
||||
"penetrometre_intitule_enc",
|
||||
"form_1",
|
||||
"form_2",
|
||||
"form_3",
|
||||
"form_4",
|
||||
"form_5",
|
||||
"form_weighted",
|
||||
"form_avg",
|
||||
"form_best",
|
||||
"form_worst",
|
||||
"win_ratio",
|
||||
"place_ratio",
|
||||
"implied_prob",
|
||||
"win_rate_adj",
|
||||
"place_rate_adj",
|
||||
"earnings_per_race",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"rang_cote",
|
||||
"ratio_cote_field",
|
||||
"distance_cat",
|
||||
"age_win_interact",
|
||||
"is_favorite",
|
||||
"poids",
|
||||
"prize_norm"
|
||||
],
|
||||
"shap_selected": [
|
||||
"rang_cote",
|
||||
"implied_prob",
|
||||
"cote_direct",
|
||||
"ratio_cote_field",
|
||||
"nb_partants",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"specialite_enc",
|
||||
"earnings_per_race",
|
||||
"nombre_courses",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"discipline_enc",
|
||||
"is_favorite",
|
||||
"prize_norm",
|
||||
"win_ratio",
|
||||
"place_rate_adj",
|
||||
"gains_annee_en_cours",
|
||||
"poids",
|
||||
"tx_place",
|
||||
"penetrometre_intitule_enc",
|
||||
"age_win_interact",
|
||||
"nombre_places",
|
||||
"tendance_num",
|
||||
"age",
|
||||
"form_avg",
|
||||
"form_weighted",
|
||||
"place_ratio",
|
||||
"form_3",
|
||||
"oeilleres_enc",
|
||||
"form_5"
|
||||
]
|
||||
},
|
||||
"ensemble_weights": {
|
||||
"xgboost": 0.23161801824035544,
|
||||
"lightgbm": 0.23415467282905,
|
||||
"mlp": 0.21290370528252356
|
||||
}
|
||||
}
|
||||
68
models/benchmark_report.md
Normal file
68
models/benchmark_report.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# Benchmark ML Ensemble — Turf Prédictions
|
||||
|
||||
**Date:** 2026-04-25
|
||||
**Dataset:** 10,899 partants
|
||||
**Holdout:** 2,180 lignes (2026-04-19 → 2026-04-24)
|
||||
|
||||
## Résultats
|
||||
|
||||
| Modèle | Precision@3 | AUC | Latence/prédiction |
|
||||
|--------|-------------|-----|-------------------|
|
||||
| XGBoost (baseline) | 0.5287 | 0.7254 | — |
|
||||
| xgboost | 0.5783 | 0.7856 | 0.01 ms |
|
||||
| lightgbm | 0.5736 | 0.7833 | 0.00 ms |
|
||||
| mlp | 0.5643 | 0.7743 | 0.01 ms |
|
||||
| **Ensemble** | **0.5814** | **0.7840** | **0.02 ms** |
|
||||
|
||||
## Décision de déploiement
|
||||
|
||||
- Delta Precision@3 : **+0.0527** (+5.3%)
|
||||
- Seuil requis : **+5%**
|
||||
- Résultat : **✅ DEPLOIEMENT RECOMMANDE**
|
||||
|
||||
## Optimisation Optuna
|
||||
|
||||
- Trials XGBoost : 100
|
||||
- Trials LightGBM : 100
|
||||
- Pruning : MedianPruner
|
||||
|
||||
### Meilleurs hyperparamètres XGBoost
|
||||
```json
|
||||
{
|
||||
"n_estimators": 141,
|
||||
"max_depth": 5,
|
||||
"learning_rate": 0.016298172447266404,
|
||||
"subsample": 0.7660470794373848,
|
||||
"colsample_bytree": 0.471124415020467,
|
||||
"min_child_weight": 14,
|
||||
"reg_alpha": 1.9364166463791586,
|
||||
"reg_lambda": 6.018030083488602,
|
||||
"gamma": 4.614943551368141
|
||||
}
|
||||
```
|
||||
|
||||
### Meilleurs hyperparamètres LightGBM
|
||||
```json
|
||||
{
|
||||
"n_estimators": 186,
|
||||
"max_depth": 4,
|
||||
"learning_rate": 0.012915117465216954,
|
||||
"num_leaves": 141,
|
||||
"subsample": 0.6193119116922561,
|
||||
"colsample_bytree": 0.539310022549326,
|
||||
"min_child_samples": 9,
|
||||
"reg_alpha": 0.6864583098112754,
|
||||
"reg_lambda": 0.0549259590914184
|
||||
}
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
- Total features : 43
|
||||
- Retenues par SHAP : 31
|
||||
|
||||
## Poids de l'ensemble
|
||||
|
||||
- xgboost : 0.2316
|
||||
- lightgbm : 0.2342
|
||||
- mlp : 0.2129
|
||||
@@ -15,7 +15,7 @@ import sqlite3
|
||||
import re
|
||||
import 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',
|
||||
|
||||
351
onboarding.html
351
onboarding.html
@@ -1,67 +1,97 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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;--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:12px;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:12px;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]{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{border-color:var(--green)}
|
||||
.btn{display:inline-flex;align-items:center;gap:8px;padding:12px 28px;border-radius:12px;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:12px;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}
|
||||
footer{margin-top:24px;color:var(--muted);font-size:.78rem;text-align:center}
|
||||
*, *::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>
|
||||
@@ -69,82 +99,237 @@
|
||||
<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 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 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>Envoyez <strong>/start</strong> à <strong>@TurfIABot</strong> et collez votre Chat ID ci-dessous.</p>
|
||||
<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="Chat ID (ex: 123456789)">
|
||||
<button class="btn btn-ghost" onclick="window.open('https://t.me/TurfIABot','_blank')">Ouvrir le bot</button>
|
||||
<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</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</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>
|
||||
<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 DU JOUR</div>
|
||||
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">Chargement…</div><div class="pred-prob">—</div><div style="color:var(--muted);font-size:.82rem">—</div></div>
|
||||
<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 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}
|
||||
(function(){if(!getToken()){location.href='/login';return}try{const user=JSON.parse(localStorage.getItem('turf_user')||'{}');selectedPlan=user.plan||'free';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){
|
||||
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()
|
||||
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('');
|
||||
}
|
||||
document.getElementById('step1-next').addEventListener('click',async()=>{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));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();if(chatId){await fetchJson(API+'/auth/update-preferences',{method:'POST',body:JSON.stringify({telegram_chat_id:chatId,alert_value_bets:document.getElementById('alert-vb').checked,alert_top1:document.getElementById('alert-top1').checked,alert_quinte_only:document.getElementById('alert-quinte').checked})})}goToStep(3)});
|
||||
document.getElementById('step3-finish').addEventListener('click',()=>{localStorage.removeItem('turf_onboarding');location.href='/dashboard'});
|
||||
async function loadFirstPrediction(){const data=await fetchJson(API+'/predictions/today');if(!data||!data.predictions||!data.predictions.length)return;const sorted=[...data.predictions].sort((a,b)=>b.ml_score-a.ml_score).slice(0,3);const container=document.getElementById('first-pred');container.innerHTML='<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR</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 style="color:var(--muted);font-size:.82rem">'+(h.odds?h.odds.toFixed(1):'—')+'</div></div>').join('')}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -5,30 +5,40 @@ import json
|
||||
import requests
|
||||
import subprocess
|
||||
import db
|
||||
from middleware import rate_limit_middleware, access_log_middleware
|
||||
|
||||
app = Flask(__name__)
|
||||
rate_limit_middleware(app)
|
||||
access_log_middleware(app)
|
||||
|
||||
DASHBOARD_API_URL = "http://localhost:8791"
|
||||
COMBINED_API_URL = "http://localhost:8790"
|
||||
SAAS_DIR = "/home/h3r7/turf_saas"
|
||||
|
||||
# ─── SaaS Auth & API v1 blueprints (Sprint 4-5 HRT-30) ───────────────────────
|
||||
# ─── 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")
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: SaaS blueprints not loaded: {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 landing():
|
||||
"""Marketing landing page (Sprint 4-5)."""
|
||||
"""Marketing landing page."""
|
||||
return send_from_directory(SAAS_DIR, "landing.html")
|
||||
|
||||
|
||||
@@ -60,6 +70,7 @@ def account():
|
||||
@app.route("/portal")
|
||||
@app.route("/portail")
|
||||
def portal_legacy():
|
||||
"""Legacy portal redirect."""
|
||||
return send_from_directory(SAAS_DIR, "portail.html")
|
||||
|
||||
|
||||
@@ -732,19 +743,29 @@ def pod_static(filename=""):
|
||||
@app.route("/turf/api/")
|
||||
@app.route("/turf/api/<path:api_path>")
|
||||
def api_proxy(api_path=""):
|
||||
if api_path.startswith("vitesse"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("n8n-proxy"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("backtest"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("stats"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("predictions_analysis"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("parisroi"):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("paris"):
|
||||
# Routes servies par combined_api.py (port 8790) :
|
||||
# backtest, stats, paris, parisroi, races, scores, report, ask, brave-search,
|
||||
# execute-sql, send-email, vitesse, n8n-proxy, predictions_analysis, ideas
|
||||
# Fix HRT-73 : alignement complet avec turf_scraper fix #23
|
||||
COMBINED_ROUTES = (
|
||||
"backtest",
|
||||
"stats",
|
||||
"parisroi",
|
||||
"paris",
|
||||
"predictions_analysis",
|
||||
"vitesse",
|
||||
"n8n-proxy",
|
||||
"races",
|
||||
"race/",
|
||||
"scores",
|
||||
"ask",
|
||||
"brave-search",
|
||||
"execute-sql",
|
||||
"send-email",
|
||||
"report",
|
||||
"ideas",
|
||||
)
|
||||
if any(api_path.startswith(r) for r in COMBINED_ROUTES):
|
||||
url = f"{COMBINED_API_URL}/turf/api/{api_path}"
|
||||
elif api_path.startswith("scoring"):
|
||||
url = f"{DASHBOARD_API_URL}/turf/api/{api_path}"
|
||||
@@ -759,11 +780,17 @@ def api_proxy(api_path=""):
|
||||
if fwd_method in ("POST", "PUT", "PATCH")
|
||||
else None
|
||||
)
|
||||
# Forwarder Authorization header (combined_api.py exige Basic h3r7:h3r7 pour parisroi/paris)
|
||||
fwd_headers = {"Content-Type": "application/json"}
|
||||
if request.headers.get("Authorization"):
|
||||
fwd_headers["Authorization"] = request.headers.get("Authorization")
|
||||
incoming_auth = request.headers.get("Authorization")
|
||||
if incoming_auth:
|
||||
fwd_headers["Authorization"] = incoming_auth
|
||||
resp = requests.request(
|
||||
method=fwd_method, url=url, json=fwd_json, timeout=30, headers=fwd_headers
|
||||
method=fwd_method,
|
||||
url=url,
|
||||
json=fwd_json,
|
||||
timeout=30,
|
||||
headers=fwd_headers,
|
||||
)
|
||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
|
||||
387
predict_v2.py
Normal file
387
predict_v2.py
Normal file
@@ -0,0 +1,387 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Ensemble prediction module for /api/v1/predictions.
|
||||
|
||||
Loads the trained ensemble model and provides a high-level predict_top3()
|
||||
function compatible with the existing combined_api.py interface.
|
||||
|
||||
Cache: model is loaded once at import time (or on first call).
|
||||
Invalidation: reload if models/ensemble_top3.pkl mtime changes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import pickle
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
from sklearn.preprocessing import LabelEncoder
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||
|
||||
# ── Cache ─────────────────────────────────────────────────────────────────────
|
||||
_model_cache = {
|
||||
"ensemble": None,
|
||||
"mtime": None,
|
||||
"lock": threading.Lock(),
|
||||
}
|
||||
|
||||
# ── Feature list (must match train_ensemble.py FEATURE_COLS) ─────────────────
|
||||
FEATURE_COLS = [
|
||||
"age",
|
||||
"sexe_enc",
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_num",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"nb_partants",
|
||||
"discipline_enc",
|
||||
"specialite_enc",
|
||||
"oeilleres_enc",
|
||||
"tendance_cote_enc",
|
||||
"penetrometre_intitule_enc",
|
||||
"form_1",
|
||||
"form_2",
|
||||
"form_3",
|
||||
"form_4",
|
||||
"form_5",
|
||||
"form_weighted",
|
||||
"form_avg",
|
||||
"form_best",
|
||||
"form_worst",
|
||||
"win_ratio",
|
||||
"place_ratio",
|
||||
"implied_prob",
|
||||
"win_rate_adj",
|
||||
"place_rate_adj",
|
||||
"earnings_per_race",
|
||||
"cote_diff",
|
||||
"cote_ratio",
|
||||
"rang_cote",
|
||||
"ratio_cote_field",
|
||||
"distance_cat",
|
||||
"age_win_interact",
|
||||
"is_favorite",
|
||||
"poids",
|
||||
"prize_norm",
|
||||
]
|
||||
|
||||
|
||||
# ── Encoders (built per-prediction batch for live data) ──────────────────────
|
||||
def _fit_encoder(values, default):
|
||||
le = LabelEncoder()
|
||||
unique = list(set(str(v) if v else default for v in values)) + [default]
|
||||
le.fit(unique)
|
||||
return le
|
||||
|
||||
|
||||
def _safe_transform(le: LabelEncoder, value, default: str):
|
||||
v = str(value) if value else default
|
||||
if v not in le.classes_:
|
||||
v = default
|
||||
return int(le.transform([v])[0])
|
||||
|
||||
|
||||
# ── Model loading with auto-invalidation ─────────────────────────────────────
|
||||
def load_ensemble(force: bool = False) -> Optional[object]:
|
||||
"""Load ensemble model, reload if file changed."""
|
||||
with _model_cache["lock"]:
|
||||
if not ENSEMBLE_PATH.exists():
|
||||
return None
|
||||
mtime = ENSEMBLE_PATH.stat().st_mtime
|
||||
if force or _model_cache["ensemble"] is None or mtime != _model_cache["mtime"]:
|
||||
try:
|
||||
with open(ENSEMBLE_PATH, "rb") as f:
|
||||
_model_cache["ensemble"] = pickle.load(f)
|
||||
_model_cache["mtime"] = mtime
|
||||
logger.info(f"[predict_v2] Loaded ensemble model from {ENSEMBLE_PATH}")
|
||||
except Exception as e:
|
||||
logger.error(f"[predict_v2] Failed to load ensemble: {e}")
|
||||
return None
|
||||
return _model_cache["ensemble"]
|
||||
|
||||
|
||||
def invalidate_model_cache():
|
||||
"""Force reload on next prediction call."""
|
||||
with _model_cache["lock"]:
|
||||
_model_cache["mtime"] = None
|
||||
|
||||
|
||||
# ── Feature engineering for live pmu_partants rows ───────────────────────────
|
||||
def _parse_musique(musique) -> list:
|
||||
if not musique or pd.isna(str(musique)):
|
||||
return [0, 0, 0, 0, 0]
|
||||
try:
|
||||
clean = re.sub(r"\(\d+\)", "", str(musique))
|
||||
numbers = re.findall(r"\d+", clean)
|
||||
result = [int(n) for n in numbers[:5]]
|
||||
result += [0] * (5 - len(result))
|
||||
return result[:5]
|
||||
except Exception:
|
||||
return [0, 0, 0, 0, 0]
|
||||
|
||||
|
||||
def build_feature_df(partants: list) -> pd.DataFrame:
|
||||
"""
|
||||
Convert a list of pmu_partants dicts to a feature DataFrame.
|
||||
|
||||
Expected keys (same as pmu_partants columns):
|
||||
date_programme, num_reunion, num_course, num_pmu,
|
||||
age, sexe, musique, nombre_courses, nombre_victoires, nombre_places,
|
||||
gains_annee_en_cours, handicap_poids, oeilleres, cote_direct,
|
||||
cote_reference, tendance_cote, favoris, tx_victoire, tx_place,
|
||||
forme_recente, tendance_forme, indicateur_inedit,
|
||||
distance, discipline, specialite, nb_declares_partants,
|
||||
montant_prix, penetrometre_intitule
|
||||
"""
|
||||
if not partants:
|
||||
return pd.DataFrame()
|
||||
|
||||
df = pd.DataFrame(partants)
|
||||
|
||||
# ── Categorical encoders fitted on this batch ─────────────────────────────
|
||||
le_sexe = _fit_encoder(df.get("sexe", ["U"]), "U")
|
||||
le_oeilleres = _fit_encoder(df.get("oeilleres", ["SANS"]), "SANS")
|
||||
le_discipline = _fit_encoder(df.get("discipline", ["UNKNOWN"]), "UNKNOWN")
|
||||
le_specialite = _fit_encoder(df.get("specialite", ["UNKNOWN"]), "UNKNOWN")
|
||||
le_tendance = _fit_encoder(df.get("tendance_cote", ["STABLE"]), "STABLE")
|
||||
le_penet = _fit_encoder(df.get("penetrometre_intitule", ["BON"]), "BON")
|
||||
|
||||
df["sexe_enc"] = df["sexe"].apply(lambda v: _safe_transform(le_sexe, v, "U"))
|
||||
df["oeilleres_enc"] = df["oeilleres"].apply(
|
||||
lambda v: _safe_transform(le_oeilleres, v, "SANS")
|
||||
)
|
||||
df["discipline_enc"] = df.get("discipline", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||
lambda v: _safe_transform(le_discipline, v, "UNKNOWN")
|
||||
)
|
||||
df["specialite_enc"] = df.get("specialite", pd.Series(["UNKNOWN"] * len(df))).apply(
|
||||
lambda v: _safe_transform(le_specialite, v, "UNKNOWN")
|
||||
)
|
||||
df["tendance_cote_enc"] = df.get(
|
||||
"tendance_cote", pd.Series(["STABLE"] * len(df))
|
||||
).apply(lambda v: _safe_transform(le_tendance, v, "STABLE"))
|
||||
df["penetrometre_intitule_enc"] = df.get(
|
||||
"penetrometre_intitule", pd.Series(["BON"] * len(df))
|
||||
).apply(lambda v: _safe_transform(le_penet, v, "BON"))
|
||||
|
||||
# ── Musique ────────────────────────────────────────────────────────────────
|
||||
music_parsed = df["musique"].apply(_parse_musique)
|
||||
for i in range(5):
|
||||
df[f"form_{i + 1}"] = music_parsed.apply(lambda x: x[i])
|
||||
weights = np.array([0.4, 0.25, 0.15, 0.12, 0.08])
|
||||
df["form_weighted"] = music_parsed.apply(
|
||||
lambda x: sum(w * v for w, v in zip(weights, x))
|
||||
)
|
||||
df["form_avg"] = music_parsed.apply(np.mean)
|
||||
df["form_best"] = music_parsed.apply(min)
|
||||
df["form_worst"] = music_parsed.apply(max)
|
||||
|
||||
# ── Numeric features ───────────────────────────────────────────────────────
|
||||
for col in [
|
||||
"nombre_courses",
|
||||
"nombre_victoires",
|
||||
"nombre_places",
|
||||
"tx_victoire",
|
||||
"tx_place",
|
||||
"forme_recente",
|
||||
"tendance_forme",
|
||||
"gains_annee_en_cours",
|
||||
"cote_direct",
|
||||
"cote_reference",
|
||||
"distance",
|
||||
"handicap_poids",
|
||||
"age",
|
||||
"montant_prix",
|
||||
"nb_declares_partants",
|
||||
]:
|
||||
if col not in df.columns:
|
||||
df[col] = 0.0
|
||||
df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0)
|
||||
|
||||
df["tendance_num"] = df["tendance_forme"].fillna(0)
|
||||
df["win_ratio"] = df["nombre_victoires"] / df["nombre_courses"].replace(0, 1)
|
||||
df["place_ratio"] = df["nombre_places"] / df["nombre_courses"].replace(0, 1)
|
||||
df["implied_prob"] = 1.0 / df["cote_direct"].replace(0, np.nan)
|
||||
df["win_rate_adj"] = df["tx_victoire"] * np.log1p(df["nombre_courses"])
|
||||
df["place_rate_adj"] = df["tx_place"] * np.log1p(df["nombre_courses"])
|
||||
df["earnings_per_race"] = df["gains_annee_en_cours"] / df["nombre_courses"].replace(
|
||||
0, 1
|
||||
)
|
||||
df["cote_diff"] = (df["cote_direct"] - df["cote_reference"]).fillna(0)
|
||||
df["cote_ratio"] = (
|
||||
df["cote_direct"] / df["cote_reference"].replace(0, np.nan)
|
||||
).fillna(1)
|
||||
|
||||
# ── Per-race rank features ─────────────────────────────────────────────────
|
||||
if "num_reunion" in df.columns and "num_course" in df.columns:
|
||||
grp = ["date_programme", "num_reunion", "num_course"]
|
||||
# Some fields may be missing
|
||||
for g in grp:
|
||||
if g not in df.columns:
|
||||
df[g] = 0
|
||||
df["rang_cote"] = df.groupby(grp)["cote_direct"].rank(
|
||||
method="min", na_option="bottom"
|
||||
)
|
||||
race_mean = df.groupby(grp)["cote_direct"].transform("mean")
|
||||
df["ratio_cote_field"] = df["cote_direct"] / race_mean.replace(0, np.nan)
|
||||
df["nb_partants"] = df.groupby(grp)["cote_direct"].transform("count")
|
||||
else:
|
||||
df["rang_cote"] = 1.0
|
||||
df["ratio_cote_field"] = 1.0
|
||||
df["nb_partants"] = df.get("nb_declares_partants", pd.Series([10] * len(df)))
|
||||
|
||||
df["distance_cat"] = pd.cut(
|
||||
df["distance"].fillna(1600),
|
||||
bins=[0, 1400, 1800, 2200, 2600, 10000],
|
||||
labels=[1, 2, 3, 4, 5],
|
||||
).astype(float)
|
||||
df["age_win_interact"] = df["age"] * df["tx_victoire"]
|
||||
df["is_favorite"] = (
|
||||
df.get("favoris", pd.Series([0] * len(df))).fillna(0).astype(int)
|
||||
)
|
||||
df["poids"] = df["handicap_poids"].fillna(60)
|
||||
df["prize_norm"] = np.log1p(df["montant_prix"].fillna(0))
|
||||
|
||||
return df
|
||||
|
||||
|
||||
# ── Main prediction function ───────────────────────────────────────────────────
|
||||
def predict_top3(partants: list, model=None) -> list:
|
||||
"""
|
||||
Given a list of partant dicts (from pmu_partants), return predictions.
|
||||
|
||||
Returns list of {horse_name, num_pmu, prob_top3, prob_top1_approx, ...}
|
||||
sorted by prob_top3 descending.
|
||||
|
||||
Falls back to empty list if model not available.
|
||||
"""
|
||||
t_start = time.perf_counter()
|
||||
|
||||
if model is None:
|
||||
model = load_ensemble()
|
||||
if model is None:
|
||||
logger.warning("[predict_v2] Ensemble model not available — no predictions")
|
||||
return []
|
||||
|
||||
df = build_feature_df(partants)
|
||||
if df.empty:
|
||||
return []
|
||||
|
||||
available = [c for c in FEATURE_COLS if c in df.columns]
|
||||
X = df[available].fillna(0)
|
||||
|
||||
try:
|
||||
proba = model.predict_proba(X)[:, 1]
|
||||
except Exception as e:
|
||||
logger.error(f"[predict_v2] predict_proba failed: {e}")
|
||||
return []
|
||||
|
||||
latency_ms = (time.perf_counter() - t_start) * 1000
|
||||
|
||||
results = []
|
||||
for i, (p, row) in enumerate(zip(proba, partants)):
|
||||
results.append(
|
||||
{
|
||||
"horse_name": row.get("nom", row.get("horse_name", f"H{i}")),
|
||||
"num_pmu": row.get("num_pmu", i + 1),
|
||||
"num_reunion": row.get("num_reunion"),
|
||||
"num_course": row.get("num_course"),
|
||||
"prob_top3": round(float(p) * 100, 1),
|
||||
# approx top1 from top3 score (divide by ~2.5 empirically)
|
||||
"prob_top1": round(float(p) / 2.5 * 100, 1),
|
||||
"ml_score": round(float(p) * 100, 1),
|
||||
"recommendation": "top3"
|
||||
if p >= 0.40
|
||||
else ("watch" if p >= 0.28 else "pass"),
|
||||
"is_value_bet": int(
|
||||
p >= 0.35 and float(row.get("cote_direct", 0) or 0) > 10
|
||||
),
|
||||
"model_version": getattr(model, "version", "ensemble_v1"),
|
||||
}
|
||||
)
|
||||
|
||||
results.sort(key=lambda x: x["prob_top3"], reverse=True)
|
||||
|
||||
# Mark top-3 predicted
|
||||
for i, r in enumerate(results[:3]):
|
||||
r["predicted_rank"] = i + 1
|
||||
|
||||
if results:
|
||||
logger.info(
|
||||
f"[predict_v2] {len(results)} horses predicted in {latency_ms:.1f} ms "
|
||||
f"({latency_ms / len(results):.2f} ms/horse)"
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
# ── API-compatible wrapper keeping model_version & structure ──────────────────
|
||||
def get_model_version() -> str:
|
||||
m = load_ensemble()
|
||||
if m is None:
|
||||
return "ensemble_v1_not_loaded"
|
||||
return getattr(m, "version", "ensemble_v1")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Quick self-test
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
rows = conn.execute(
|
||||
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
FROM pmu_partants p
|
||||
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||
WHERE p.date_programme=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||
AND p.num_reunion=1 AND p.num_course=1
|
||||
LIMIT 20"""
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
if not rows:
|
||||
print("No data found for self-test")
|
||||
else:
|
||||
cols = [d[0] for d in conn.description] if hasattr(conn, "description") else []
|
||||
# Fallback column list
|
||||
import sqlite3 as sq3
|
||||
|
||||
conn2 = sq3.connect("/home/h3r7/turf_saas/turf.db")
|
||||
cur = conn2.execute(
|
||||
"""SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
FROM pmu_partants p
|
||||
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||
WHERE p.date_programme=(SELECT MAX(date_programme) FROM pmu_partants)
|
||||
AND p.num_reunion=1 AND p.num_course=1
|
||||
LIMIT 20"""
|
||||
)
|
||||
cols = [d[0] for d in cur.description]
|
||||
rows2 = cur.fetchall()
|
||||
conn2.close()
|
||||
|
||||
partants = [dict(zip(cols, row)) for row in rows2]
|
||||
preds = predict_top3(partants)
|
||||
print(f"Self-test: {len(preds)} predictions")
|
||||
for p in preds[:5]:
|
||||
print(
|
||||
f" {p['horse_name']:20s} prob_top3={p['prob_top3']}% rec={p['recommendation']}"
|
||||
)
|
||||
13
pytest.ini
Normal file
13
pytest.ini
Normal file
@@ -0,0 +1,13 @@
|
||||
[pytest]
|
||||
asyncio_mode = auto
|
||||
testpaths = tests
|
||||
python_files = test_*.py
|
||||
python_classes = Test*
|
||||
python_functions = test_*
|
||||
addopts = --tb=short -v
|
||||
markers =
|
||||
e2e: Tests End-to-End Playwright
|
||||
load: Tests de charge Locust
|
||||
security: Tests de sécurité
|
||||
smoke: Tests rapides de smoke
|
||||
integration: Tests d'intégration DB et pipeline ML
|
||||
182
rebuild_ensemble.py
Normal file
182
rebuild_ensemble.py
Normal file
@@ -0,0 +1,182 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Rebuild ensemble using known best Optuna params (from completed study).
|
||||
Skips the 100-trial Optuna search and goes straight to training + pickling.
|
||||
"""
|
||||
import sys
|
||||
sys.path.insert(0, '/home/h3r7/turf_saas')
|
||||
|
||||
from train_ensemble import (
|
||||
load_data, engineer_features, temporal_split, get_features_and_target,
|
||||
evaluate_baseline, train_xgboost, train_lightgbm, train_mlp,
|
||||
shap_feature_selection, compute_ensemble_weights,
|
||||
evaluate_model, compute_precision_at3, TurfEnsemble,
|
||||
MODELS_DIR, DEPLOY_THRESHOLD, _write_markdown_report
|
||||
)
|
||||
import json, pickle, numpy as np
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
DB_PATH = '/home/h3r7/turf_saas/turf.db'
|
||||
|
||||
# Best params from the 100-trial Optuna run
|
||||
XGB_BEST = {
|
||||
'n_estimators': 141, 'max_depth': 5,
|
||||
'learning_rate': 0.016298172447266404,
|
||||
'subsample': 0.7660470794373848,
|
||||
'colsample_bytree': 0.471124415020467,
|
||||
'min_child_weight': 14,
|
||||
'reg_alpha': 1.9364166463791586,
|
||||
'reg_lambda': 6.018030083488602,
|
||||
'gamma': 4.614943551368141,
|
||||
}
|
||||
LGB_BEST = {
|
||||
'n_estimators': 186, 'max_depth': 4,
|
||||
'learning_rate': 0.012915117465216954,
|
||||
'num_leaves': 141,
|
||||
'subsample': 0.6193119116922561,
|
||||
'colsample_bytree': 0.539310022549326,
|
||||
'min_child_samples': 9,
|
||||
'reg_alpha': 0.6864583098112754,
|
||||
'reg_lambda': 0.0549259590914184,
|
||||
}
|
||||
|
||||
print("=" * 65)
|
||||
print("TURF ENSEMBLE REBUILD (using pre-computed Optuna params)")
|
||||
print("=" * 65)
|
||||
|
||||
print("\n[1/7] Loading data...")
|
||||
df = load_data(DB_PATH)
|
||||
df = engineer_features(df)
|
||||
|
||||
print("\n[2/7] Temporal split...")
|
||||
train_df, holdout_df = temporal_split(df)
|
||||
X_train, y_train, feat_cols = get_features_and_target(train_df)
|
||||
X_holdout, y_holdout, _ = get_features_and_target(holdout_df)
|
||||
|
||||
n = len(X_train); n_val = int(n * 0.15)
|
||||
X_tr = X_train.iloc[:n-n_val]; y_tr = y_train.iloc[:n-n_val]
|
||||
X_val = X_train.iloc[n-n_val:]; y_val = y_train.iloc[n-n_val:]
|
||||
|
||||
print("\n[3/7] Evaluating baseline XGBoost...")
|
||||
baseline = evaluate_baseline(holdout_df, '/home/h3r7/turf_saas/xgboost_models.pkl')
|
||||
print(f" Baseline P@3={baseline['precision_at3']:.4f} AUC={baseline['auc']:.4f}")
|
||||
|
||||
print("\n[4/7] Training models with best params...")
|
||||
print(" XGBoost...")
|
||||
xgb_model = train_xgboost(X_tr, y_tr, XGB_BEST)
|
||||
print(" LightGBM...")
|
||||
lgb_model = train_lightgbm(X_tr, y_tr, LGB_BEST)
|
||||
print(" MLP...")
|
||||
mlp_model = train_mlp(X_tr.values, y_tr)
|
||||
|
||||
print("\n[5/7] SHAP analysis...")
|
||||
selected_features, shap_df = shap_feature_selection(xgb_model, X_tr)
|
||||
|
||||
print("\n[6/7] Computing ensemble weights...")
|
||||
class WrappedMLP:
|
||||
def __init__(self, pipeline, cols):
|
||||
self.pipeline = pipeline
|
||||
self.feature_cols = cols
|
||||
def predict_proba(self, X):
|
||||
import pandas as pd
|
||||
available = [c for c in self.feature_cols if c in X.columns]
|
||||
return self.pipeline.predict_proba(X[available].values)
|
||||
|
||||
class WrappedTree:
|
||||
def __init__(self, model, cols):
|
||||
self.model = model
|
||||
self.feature_cols = cols
|
||||
def predict_proba(self, X):
|
||||
available = [c for c in self.feature_cols if c in X.columns]
|
||||
return self.model.predict_proba(X[available])
|
||||
|
||||
wrapped_xgb = WrappedTree(xgb_model, feat_cols)
|
||||
wrapped_lgb = WrappedTree(lgb_model, feat_cols)
|
||||
wrapped_mlp = WrappedMLP(mlp_model, feat_cols)
|
||||
model_dict = {'xgboost': wrapped_xgb, 'lightgbm': wrapped_lgb, 'mlp': wrapped_mlp}
|
||||
|
||||
weights = compute_ensemble_weights(model_dict, X_val, y_val, feat_cols)
|
||||
print(" Weights:", weights)
|
||||
|
||||
print("\n[7/7] Evaluating + saving ensemble...")
|
||||
ensemble = TurfEnsemble(xgb_model, lgb_model, mlp_model, weights, feat_cols)
|
||||
|
||||
results = {}
|
||||
for name, wrapped in model_dict.items():
|
||||
res = evaluate_model(wrapped, X_holdout, y_holdout, holdout_df, name)
|
||||
results[name] = res
|
||||
print(f" {name:12s} P@3={res['precision_at3']:.4f} AUC={res['auc']:.4f}")
|
||||
|
||||
ens_res = evaluate_model(ensemble, X_holdout, y_holdout, holdout_df, "ensemble")
|
||||
results["ensemble"] = ens_res
|
||||
print(f" {'ensemble':12s} P@3={ens_res['precision_at3']:.4f} AUC={ens_res['auc']:.4f}")
|
||||
|
||||
delta = ens_res['precision_at3'] - baseline['precision_at3']
|
||||
deploy = delta >= DEPLOY_THRESHOLD
|
||||
print(f"\n Delta: {delta:+.4f} ({delta*100:+.1f}%) Deploy={'YES' if deploy else 'NO'}")
|
||||
|
||||
# Save ensemble
|
||||
ensemble_path = MODELS_DIR / "ensemble_top3.pkl"
|
||||
with open(ensemble_path, "wb") as f:
|
||||
pickle.dump(ensemble, f)
|
||||
print(f"\n ✅ ensemble_top3.pkl saved ({ensemble_path.stat().st_size//1024} KB)")
|
||||
|
||||
# Save individual models
|
||||
for name, model in [("xgboost_optimized", xgb_model), ("lightgbm", lgb_model)]:
|
||||
path = MODELS_DIR / f"{name}_top3.pkl"
|
||||
with open(path, "wb") as f:
|
||||
pickle.dump({"model": model, "feature_cols": feat_cols}, f)
|
||||
print(f" ✅ {name}_top3.pkl saved")
|
||||
|
||||
mlp_path = MODELS_DIR / "mlp_top3.pkl"
|
||||
with open(mlp_path, "wb") as f:
|
||||
pickle.dump({"pipeline": mlp_model, "feature_cols": feat_cols}, f)
|
||||
print(f" ✅ mlp_top3.pkl saved")
|
||||
|
||||
# Benchmark report
|
||||
report = {
|
||||
"run_date": datetime.now().isoformat(),
|
||||
"dataset": {
|
||||
"db_path": DB_PATH,
|
||||
"total_rows": len(df),
|
||||
"train_rows": len(X_train),
|
||||
"holdout_rows": len(X_holdout),
|
||||
"train_date_range": [str(train_df["date_programme"].min()), str(train_df["date_programme"].max())],
|
||||
"holdout_date_range": [str(holdout_df["date_programme"].min()), str(holdout_df["date_programme"].max())],
|
||||
},
|
||||
"baseline": baseline,
|
||||
"individual_models": {k: v for k, v in results.items() if k != "ensemble"},
|
||||
"ensemble": ens_res,
|
||||
"delta_precision_at3": round(delta, 4),
|
||||
"deploy": deploy,
|
||||
"optuna": {
|
||||
"n_trials": 100,
|
||||
"xgboost_best_params": XGB_BEST,
|
||||
"lightgbm_best_params": LGB_BEST,
|
||||
},
|
||||
"features": {
|
||||
"total": len(feat_cols),
|
||||
"selected_by_shap": len(selected_features),
|
||||
"feature_list": feat_cols,
|
||||
"shap_selected": selected_features,
|
||||
},
|
||||
"ensemble_weights": weights,
|
||||
}
|
||||
|
||||
report_path = MODELS_DIR / "benchmark_report.json"
|
||||
with open(report_path, "w") as f:
|
||||
json.dump(report, f, indent=2)
|
||||
print(f" ✅ benchmark_report.json saved")
|
||||
|
||||
md_path = MODELS_DIR / "benchmark_report.md"
|
||||
_write_markdown_report(report, md_path)
|
||||
print(f" ✅ benchmark_report.md saved")
|
||||
|
||||
print("\n" + "=" * 65)
|
||||
print("DONE")
|
||||
print(f" Baseline P@3: {baseline['precision_at3']:.4f}")
|
||||
print(f" Ensemble P@3: {ens_res['precision_at3']:.4f}")
|
||||
print(f" Delta: {delta:+.4f} ({delta*100:+.1f}%)")
|
||||
print(f" Deploy: {'✅ YES' if deploy else '❌ NO'}")
|
||||
print("=" * 65)
|
||||
338
register.html
338
register.html
@@ -1,55 +1,75 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<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}
|
||||
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:10px;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:10px;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}
|
||||
.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:10px;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-card{background:var(--dark2);border:2px 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}}
|
||||
*, *::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>
|
||||
@@ -61,69 +81,191 @@
|
||||
<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" placeholder="Jean" 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" placeholder="Dupont" required><div class="field-error" id="lastname-error">Requis.</div></div>
|
||||
<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>
|
||||
<div class="form-group"><label for="email">Email</label><input type="email" id="email" placeholder="vous@exemple.fr" 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" placeholder="8 caractères minimum" 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.</div></div>
|
||||
<div class="form-group"><label for="plan">Plan de départ</label><select id="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">CGU</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 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</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></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>Alertes Telegram</li><li>Value bets</li></ul></div>
|
||||
<div id="plan-card-pro" class="plan-card" style="display:none"><div class="plan-card-name">Pro</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 Premium</li><li>Export CSV</li><li>API REST</li></ul></div>
|
||||
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire pour le plan Free</p>
|
||||
<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');
|
||||
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(m){alertBox.textContent=m;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,cond,msg){const e=document.getElementById(errId);if(cond){input.classList.add('error');e.textContent=msg;e.classList.add('show');return false}input.classList.remove('error');e.classList.remove('show');return true}
|
||||
const passInput=document.getElementById('password');
|
||||
const bar=document.getElementById('strength-bar');
|
||||
passInput.addEventListener('input',()=>{const v=passInput.value;let s=0;if(v.length>=8)s++;if(/[A-Z]/.test(v))s++;if(/[0-9]/.test(v))s++;if(/[^A-Za-z0-9]/.test(v))s++;const c=['#f85149','#e3b341','#58a6ff','#00c853'];bar.style.width=(s*25)+'%';bar.style.background=c[s-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||!email.includes('@'),'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)}
|
||||
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';
|
||||
});
|
||||
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'})();
|
||||
|
||||
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>
|
||||
</html>
|
||||
|
||||
33
requirements.txt
Normal file
33
requirements.txt
Normal 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
247
saas_api.py
Normal file
@@ -0,0 +1,247 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Turf SaaS API v1 — Auth JWT + Multi-tenant
|
||||
Sprint 2-3: HRT-28
|
||||
|
||||
Run:
|
||||
FLASK_ENV=development ./venv/bin/python saas_api.py
|
||||
|
||||
Ports (isolated from production):
|
||||
Portal: 8793
|
||||
SaaS API: 8792 ← this file
|
||||
Dashboard: 8791
|
||||
Combined API: 8790
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import logging.handlers
|
||||
import sys
|
||||
|
||||
from flask import Flask, jsonify, g, request
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager, get_jwt
|
||||
|
||||
from auth_db import init_auth_tables
|
||||
from auth import (
|
||||
auth_bp,
|
||||
jwt_required_middleware,
|
||||
plan_required,
|
||||
free_daily_limit_check,
|
||||
_get_user_by_id,
|
||||
)
|
||||
from middleware import rate_limit_middleware, access_log_middleware
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Logging setup
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
LOG_DIR = os.path.join(os.path.dirname(__file__), "logs")
|
||||
os.makedirs(LOG_DIR, exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout),
|
||||
logging.handlers.RotatingFileHandler(
|
||||
os.path.join(LOG_DIR, "saas_api.log"),
|
||||
maxBytes=5 * 1024 * 1024,
|
||||
backupCount=3,
|
||||
),
|
||||
],
|
||||
)
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# App factory
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def create_app(test_config=None):
|
||||
app = Flask(__name__)
|
||||
|
||||
# JWT config
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "CHANGE_ME_IN_PRODUCTION_" + os.urandom(24).hex()
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = 900 # 15 minutes
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = 2592000 # 30 days
|
||||
|
||||
if test_config:
|
||||
app.config.update(test_config)
|
||||
|
||||
# CORS — SaaS domain + localhost for dev
|
||||
CORS(
|
||||
app,
|
||||
origins=os.environ.get(
|
||||
"CORS_ORIGINS",
|
||||
"http://localhost:8793,http://127.0.0.1:8793,https://turf-ia.h3r7.tech",
|
||||
).split(","),
|
||||
methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"],
|
||||
allow_headers=["Content-Type", "Authorization"],
|
||||
supports_credentials=True,
|
||||
)
|
||||
|
||||
# JWT
|
||||
jwt = JWTManager(app)
|
||||
|
||||
# ── JWT error handlers ────────────────────────────────────
|
||||
@jwt.expired_token_loader
|
||||
def expired_token(_jwt_header, _jwt_payload):
|
||||
return jsonify({"error": "Token expiré"}), 401
|
||||
|
||||
@jwt.invalid_token_loader
|
||||
def invalid_token(reason):
|
||||
return jsonify({"error": "Token invalide", "detail": reason}), 422
|
||||
|
||||
@jwt.unauthorized_loader
|
||||
def unauthorized(reason):
|
||||
return jsonify({"error": "Token manquant ou invalide", "detail": reason}), 401
|
||||
|
||||
# ── Register middleware ───────────────────────────────────
|
||||
rate_limit_middleware(app)
|
||||
access_log_middleware(app)
|
||||
|
||||
# ── Blueprints ────────────────────────────────────────────
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# ── Predictions routes (multi-tenant plan check) ──────────
|
||||
|
||||
@app.route("/api/v1/predictions", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def predictions():
|
||||
"""
|
||||
GET /api/v1/predictions
|
||||
- free: Top 3 uniquement (déjà filtrées par le moteur ML)
|
||||
- premium: toutes courses + alertes Telegram
|
||||
- pro: API complète + export CSV disponible
|
||||
"""
|
||||
user = g.current_user
|
||||
plan = user["plan"]
|
||||
|
||||
# Forward to combined_api for actual predictions
|
||||
import requests as req
|
||||
|
||||
try:
|
||||
params = dict(request.args)
|
||||
resp = req.get(
|
||||
"http://localhost:8790/api/predictions",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
data = resp.json()
|
||||
except Exception as e:
|
||||
return jsonify(
|
||||
{"error": "Service prédictions indisponible", "detail": str(e)}
|
||||
), 503
|
||||
|
||||
# Plan filtering
|
||||
if plan == "free":
|
||||
# Top 3 only
|
||||
if isinstance(data, list):
|
||||
data = [
|
||||
{k: v for k, v in p.items() if k not in ("score_detaille",)}
|
||||
for p in data[:3]
|
||||
]
|
||||
return jsonify({"plan": plan, "predictions": data, "limit": "Top 3"}), 200
|
||||
|
||||
elif plan == "premium":
|
||||
# All courses, but no CSV export
|
||||
return jsonify(
|
||||
{"plan": plan, "predictions": data, "telegram_alerts": True}
|
||||
), 200
|
||||
|
||||
else: # pro
|
||||
return jsonify(
|
||||
{
|
||||
"plan": plan,
|
||||
"predictions": data,
|
||||
"telegram_alerts": True,
|
||||
"csv_export_url": "/api/v1/predictions/export",
|
||||
}
|
||||
), 200
|
||||
|
||||
@app.route("/api/v1/predictions/export", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def predictions_export():
|
||||
"""CSV export — pro plan only."""
|
||||
import requests as req
|
||||
import io
|
||||
|
||||
try:
|
||||
resp = req.get(
|
||||
"http://localhost:8790/api/predictions/export",
|
||||
params=dict(request.args),
|
||||
timeout=15,
|
||||
)
|
||||
from flask import Response
|
||||
|
||||
return Response(
|
||||
resp.content,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": "attachment; filename=predictions.csv"},
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Export indisponible", "detail": str(e)}), 503
|
||||
|
||||
@app.route("/api/v1/subscription/upgrade", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def subscription_info():
|
||||
"""Return available plans and current user plan."""
|
||||
user = g.current_user
|
||||
return jsonify(
|
||||
{
|
||||
"current_plan": user["plan"],
|
||||
"plans": {
|
||||
"free": {
|
||||
"price": "0€/mois",
|
||||
"features": ["Top 3 prédictions", "1 course/jour"],
|
||||
},
|
||||
"premium": {
|
||||
"price": "9.99€/mois",
|
||||
"features": [
|
||||
"Toutes les courses",
|
||||
"Alertes Telegram",
|
||||
"Historique 30j",
|
||||
],
|
||||
},
|
||||
"pro": {
|
||||
"price": "29.99€/mois",
|
||||
"features": [
|
||||
"API complète",
|
||||
"Export CSV",
|
||||
"Alertes Telegram",
|
||||
"Historique illimité",
|
||||
"Support prioritaire",
|
||||
],
|
||||
},
|
||||
},
|
||||
"upgrade_contact": "contact@h3r7.tech",
|
||||
}
|
||||
), 200
|
||||
|
||||
# ── Health check ──────────────────────────────────────────
|
||||
|
||||
@app.route("/api/v1/health", methods=["GET"])
|
||||
def health():
|
||||
return jsonify(
|
||||
{"status": "ok", "service": "turf-saas-api", "version": "2.3.0"}
|
||||
), 200
|
||||
|
||||
# Init DB tables on startup
|
||||
with app.app_context():
|
||||
init_auth_tables()
|
||||
|
||||
return app
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Entrypoint
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = create_app()
|
||||
port = int(os.environ.get("SAAS_API_PORT", 8792))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
220
saas_api_v1.py
220
saas_api_v1.py
@@ -5,10 +5,8 @@ Stats, prédictions, résumés pour le dashboard SaaS.
|
||||
Sprint 4-5 — HRT-30
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, Response
|
||||
from flask import Blueprint, request, jsonify
|
||||
import sqlite3
|
||||
import csv
|
||||
import io
|
||||
import os
|
||||
from datetime import datetime
|
||||
from saas_auth import require_auth
|
||||
@@ -24,59 +22,90 @@ def get_db():
|
||||
return conn
|
||||
|
||||
|
||||
def plan_allows(user_plan, required):
|
||||
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 = conn.execute(
|
||||
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
|
||||
(today,)
|
||||
).fetchone()[0] or 0
|
||||
value_bets_today = conn.execute(
|
||||
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
|
||||
(today,)
|
||||
).fetchone()[0] or 0
|
||||
# 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
|
||||
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,)
|
||||
(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
|
||||
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
|
||||
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("""
|
||||
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,
|
||||
@@ -84,59 +113,170 @@ def predictions_today():
|
||||
FROM ml_predictions_cache
|
||||
WHERE date=?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""", (today,)).fetchall()
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
predictions = [dict(r) for r in rows]
|
||||
if plan == "free" and 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]
|
||||
for p in predictions:
|
||||
p["is_value_bet"] = 0
|
||||
return jsonify({"date": today, "plan": plan, "count": len(predictions), "predictions": predictions}), 200
|
||||
|
||||
# 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
|
||||
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()
|
||||
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
|
||||
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,)
|
||||
(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"}
|
||||
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}')
|
||||
|
||||
350
saas_auth.py
350
saas_auth.py
@@ -5,22 +5,157 @@ Gestion des utilisateurs, JWT, plans, préférences.
|
||||
Sprint 4-5 — HRT-30
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
import os
|
||||
import time
|
||||
import json
|
||||
from functools import wraps
|
||||
from datetime import datetime
|
||||
from collections import defaultdict
|
||||
from threading import Lock
|
||||
|
||||
# ─── Rate limiting login ───────────────────────────────────────────────────────
|
||||
_login_attempts: dict = defaultdict(
|
||||
lambda: {"count": 0, "window_start": 0.0, "blocked_until": 0.0}
|
||||
)
|
||||
_login_lock = Lock()
|
||||
|
||||
LOGIN_RATE_MAX = 5 # max tentatives par fenêtre
|
||||
LOGIN_RATE_WINDOW = 300 # 5 minutes (en secondes)
|
||||
LOGIN_BLOCK_DURATION = 900 # 15 min de blocage après dépassement
|
||||
|
||||
# ─── Blacklist mots de passe faibles ─────────────────────────────────────────
|
||||
# HRT-63 — Validation mots de passe faibles
|
||||
WEAK_PASSWORDS = {
|
||||
"password",
|
||||
"password1",
|
||||
"password123",
|
||||
"passw0rd",
|
||||
"12345678",
|
||||
"123456789",
|
||||
"1234567890",
|
||||
"123456",
|
||||
"12345",
|
||||
"1234",
|
||||
"qwerty",
|
||||
"qwerty123",
|
||||
"qwertyuiop",
|
||||
"azerty",
|
||||
"azertyuiop",
|
||||
"letmein",
|
||||
"letmein1",
|
||||
"iloveyou",
|
||||
"iloveyou1",
|
||||
"admin",
|
||||
"admin123",
|
||||
"admin1234",
|
||||
"administrator",
|
||||
"welcome",
|
||||
"welcome1",
|
||||
"welcome123",
|
||||
"monkey",
|
||||
"monkey1",
|
||||
"dragon",
|
||||
"dragon1",
|
||||
"master",
|
||||
"master1",
|
||||
"football",
|
||||
"soccer",
|
||||
"baseball",
|
||||
"basketball",
|
||||
"superman",
|
||||
"batman",
|
||||
"starwars",
|
||||
"starwars1",
|
||||
"princess",
|
||||
"princess1",
|
||||
"sunshine",
|
||||
"sunshine1",
|
||||
"shadow",
|
||||
"shadow1",
|
||||
"michael",
|
||||
"michael1",
|
||||
"jessica",
|
||||
"jessica1",
|
||||
"abc123",
|
||||
"abc1234",
|
||||
"abcd1234",
|
||||
"abcdefgh",
|
||||
"login",
|
||||
"login123",
|
||||
"pass",
|
||||
"pass1234",
|
||||
"test",
|
||||
"test1234",
|
||||
"test123456",
|
||||
"hello",
|
||||
"hello123",
|
||||
"hello1234",
|
||||
"changeme",
|
||||
"changeme1",
|
||||
"secret",
|
||||
"secret1",
|
||||
"secret123",
|
||||
"trustno1",
|
||||
"zaq1zaq1",
|
||||
"qazwsx",
|
||||
"qazwsxedc",
|
||||
"111111",
|
||||
"1111111",
|
||||
"11111111",
|
||||
"000000",
|
||||
"00000000",
|
||||
"123123",
|
||||
"1231234",
|
||||
"321321",
|
||||
"p@ssword",
|
||||
"p@ssw0rd",
|
||||
"pa$$word",
|
||||
"turf",
|
||||
"turf123",
|
||||
"cheval",
|
||||
"cheval123",
|
||||
"pmu",
|
||||
"pmu123",
|
||||
}
|
||||
|
||||
|
||||
def validate_password_strength(password: str):
|
||||
"""
|
||||
Valide la complexité d'un mot de passe.
|
||||
Retourne None si OK, sinon un message d'erreur (str).
|
||||
Règles :
|
||||
- 8 caractères minimum
|
||||
- absent de la blacklist WEAK_PASSWORDS
|
||||
- au moins 1 chiffre
|
||||
- au moins 1 lettre
|
||||
"""
|
||||
if len(password) < 8:
|
||||
return "Mot de passe trop court (8 caractères minimum)."
|
||||
if password.lower() in WEAK_PASSWORDS:
|
||||
return "Mot de passe trop commun. Choisissez un mot de passe plus sécurisé."
|
||||
if not any(c.isdigit() for c in password):
|
||||
return "Le mot de passe doit contenir au moins 1 chiffre."
|
||||
if not any(c.isalpha() for c in password):
|
||||
return "Le mot de passe doit contenir au moins 1 lettre."
|
||||
return None
|
||||
|
||||
|
||||
# ─── Config ───────────────────────────────────────────────────────────────────
|
||||
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))
|
||||
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
|
||||
@@ -28,6 +163,7 @@ def get_db():
|
||||
|
||||
|
||||
def init_users_table():
|
||||
"""Ensure users table exists."""
|
||||
conn = get_db()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS saas_users (
|
||||
@@ -61,17 +197,22 @@ except Exception as e:
|
||||
print(f"[auth_bp] DB init warning: {e}")
|
||||
|
||||
|
||||
def generate_token(user_id):
|
||||
# ─── 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.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):
|
||||
def validate_token(token: str):
|
||||
"""Returns user row dict or None."""
|
||||
if not token:
|
||||
return None
|
||||
conn = get_db()
|
||||
@@ -79,59 +220,107 @@ def validate_token(token):
|
||||
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)
|
||||
(token, now),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def hash_password(password):
|
||||
def hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def validate_api_key(raw_key: str):
|
||||
"""
|
||||
Validate a personal API token (X-API-Key header).
|
||||
Returns user dict or None. Updates last_used_at on success.
|
||||
HRT-80
|
||||
"""
|
||||
if not raw_key:
|
||||
return None
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||
"JOIN saas_users u ON t.user_id = u.id "
|
||||
"WHERE t.token_hash = ? AND t.revoked = 0",
|
||||
(key_hash,),
|
||||
).fetchone()
|
||||
if row:
|
||||
conn.execute(
|
||||
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||
"WHERE token_hash = ?",
|
||||
(key_hash,),
|
||||
)
|
||||
conn.commit()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logging.getLogger("turf_saas.auth").warning("validate_api_key error: %s", e)
|
||||
return None
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
def require_auth(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
# 1. Try Bearer session token (existing flow — unchanged)
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth[7:].strip() if auth.startswith("Bearer ") else None
|
||||
user = validate_token(token)
|
||||
token = (
|
||||
auth.removeprefix("Bearer ").strip() if auth.startswith("Bearer ") else None
|
||||
)
|
||||
user = validate_token(token) if token else None
|
||||
|
||||
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||
if not user:
|
||||
api_key = request.headers.get("X-API-Key", "").strip()
|
||||
if api_key:
|
||||
user = validate_api_key(api_key)
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
request.current_user = user
|
||||
return f(*args, **kwargs)
|
||||
|
||||
return decorated
|
||||
|
||||
|
||||
def user_to_dict(user):
|
||||
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)),
|
||||
"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 ""
|
||||
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")
|
||||
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
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Mot de passe trop court (8 caractères minimum)."}), 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"
|
||||
|
||||
@@ -141,13 +330,14 @@ def register():
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, firstname, lastname, pw_hash, plan)
|
||||
(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
|
||||
@@ -155,20 +345,55 @@ def register():
|
||||
|
||||
@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()
|
||||
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
|
||||
|
||||
# ── Rate limit par IP ────────────────────────────────────────
|
||||
ip = request.remote_addr or "unknown"
|
||||
now = time.time()
|
||||
|
||||
with _login_lock:
|
||||
bucket = _login_attempts[ip]
|
||||
# Lever le blocage si la durée est écoulée
|
||||
if now >= bucket["blocked_until"]:
|
||||
if now - bucket["window_start"] >= LOGIN_RATE_WINDOW:
|
||||
bucket["count"] = 0
|
||||
bucket["window_start"] = now
|
||||
bucket["count"] += 1
|
||||
count = bucket["count"]
|
||||
if count > LOGIN_RATE_MAX:
|
||||
bucket["blocked_until"] = now + LOGIN_BLOCK_DURATION
|
||||
retry_after = LOGIN_BLOCK_DURATION
|
||||
blocked = True
|
||||
else:
|
||||
retry_after = int(LOGIN_RATE_WINDOW - (now - bucket["window_start"]))
|
||||
blocked = False
|
||||
else:
|
||||
blocked = True
|
||||
retry_after = int(bucket["blocked_until"] - now)
|
||||
|
||||
if blocked:
|
||||
resp = jsonify({"error": "Trop de tentatives. Réessayez plus tard."})
|
||||
resp.status_code = 429
|
||||
resp.headers["Retry-After"] = str(retry_after)
|
||||
return resp
|
||||
# ─────────────────────────────────────────────────────────────
|
||||
|
||||
pw_hash = hash_password(password)
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
"SELECT * FROM saas_users WHERE email=? AND password_hash=?",
|
||||
(email, pw_hash)
|
||||
"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
|
||||
|
||||
@@ -176,29 +401,37 @@ def login():
|
||||
@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:
|
||||
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.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
@@ -210,20 +443,29 @@ def update_profile():
|
||||
@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"]
|
||||
uid = request.current_user["id"]
|
||||
cur_pwd = data.get("current_password") or ""
|
||||
new_pwd = data.get("new_password") or ""
|
||||
if len(new_pwd) < 8:
|
||||
return jsonify({"error": "Nouveau mot de passe trop court."}), 400
|
||||
|
||||
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()
|
||||
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.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
|
||||
@@ -232,14 +474,17 @@ def change_password():
|
||||
@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.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
|
||||
@@ -248,28 +493,38 @@ def update_plan():
|
||||
@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 "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.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[7:].strip() if auth.startswith("Bearer ") else ""
|
||||
token = auth.removeprefix("Bearer ").strip()
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,))
|
||||
conn.commit()
|
||||
@@ -280,6 +535,7 @@ def logout():
|
||||
@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,))
|
||||
|
||||
@@ -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):
|
||||
|
||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
Fonctionnement :
|
||||
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||
"""
|
||||
Envoie un message Telegram à un chat_id donné.
|
||||
|
||||
Returns True si succès, False sinon.
|
||||
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||
return False
|
||||
|
||||
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.warning(
|
||||
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||
chat_id,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||
"""
|
||||
Construit le message Markdown de l'alerte pré-course.
|
||||
|
||||
Args:
|
||||
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||
|
||||
Returns: texte Markdown formaté
|
||||
"""
|
||||
hippo = race_data.get("hippo", "?")
|
||||
num_course = race_data.get("num_course", "?")
|
||||
heure = race_data.get("heure", "?")
|
||||
type_course = race_data.get("type_course", "")
|
||||
|
||||
lines = [
|
||||
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||
f"⏰ Départ prévu : *{heure}*",
|
||||
]
|
||||
if type_course:
|
||||
lines.append(f"📋 Type : {type_course}")
|
||||
lines.append("")
|
||||
|
||||
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||
|
||||
if top3:
|
||||
lines.append("📊 *Top-3 ML :*")
|
||||
for i, p in enumerate(top3, 1):
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
prob = p.get("prob_top3", 0)
|
||||
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||
lines.append("")
|
||||
|
||||
if value_bets:
|
||||
lines.append("💡 *Value bets :*")
|
||||
for p in value_bets[:3]:
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
score = p.get("ml_score", 0)
|
||||
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main send function ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||
"""
|
||||
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||
|
||||
Args:
|
||||
minutes_before: non utilisé directement (la planification est gérée
|
||||
par le scheduler), présent pour documentation.
|
||||
|
||||
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning(
|
||||
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||
)
|
||||
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
conn = _get_db()
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Récupère les courses du jour
|
||||
try:
|
||||
courses_rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
hippo, num_course, heure_depart, type_course
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ?
|
||||
AND heure_depart IS NOT NULL
|
||||
ORDER BY heure_depart ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not courses_rows:
|
||||
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||
try:
|
||||
users = conn.execute(
|
||||
"""
|
||||
SELECT id, telegram_chat_id,
|
||||
alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE plan IN ('premium', 'pro')
|
||||
AND is_active = 1
|
||||
AND telegram_chat_id IS NOT NULL
|
||||
AND telegram_chat_id != ''
|
||||
""",
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning(
|
||||
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||
exc,
|
||||
)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not users:
|
||||
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
for course_row in courses_rows:
|
||||
hippo = course_row["hippo"] or "?"
|
||||
num_course = course_row["num_course"] or "?"
|
||||
heure_ts = course_row["heure_depart"]
|
||||
type_course = course_row["type_course"] or ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||
heure_str = dt.strftime("%H:%M")
|
||||
except Exception:
|
||||
heure_str = str(heure_ts)
|
||||
|
||||
race_data = {
|
||||
"hippo": hippo,
|
||||
"num_course": num_course,
|
||||
"heure": heure_str,
|
||||
"type_course": type_course,
|
||||
}
|
||||
|
||||
# Récupère les prédictions ML pour cette course
|
||||
predictions = []
|
||||
try:
|
||||
pred_rows = conn.execute(
|
||||
"""
|
||||
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND hippo = ?
|
||||
AND num_course = ?
|
||||
ORDER BY prob_top3 DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(today, hippo, num_course),
|
||||
).fetchall()
|
||||
predictions = [dict(r) for r in pred_rows]
|
||||
except sqlite3.OperationalError:
|
||||
pass # table absente, on envoie quand même avec données minimales
|
||||
|
||||
is_quinte = (
|
||||
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||
)
|
||||
|
||||
for user in users:
|
||||
chat_id = user["telegram_chat_id"]
|
||||
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||
alert_top1 = bool(user["alert_top1"])
|
||||
alert_value_bets = bool(user["alert_value_bets"])
|
||||
|
||||
# Filtre quinte_only
|
||||
if alert_quinte_only and not is_quinte:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Construit le message selon préférences
|
||||
filtered_preds = []
|
||||
if predictions:
|
||||
for p in predictions:
|
||||
include = False
|
||||
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||
include = True
|
||||
if alert_value_bets and p.get("is_value_bet"):
|
||||
include = True
|
||||
if include:
|
||||
filtered_preds.append(p)
|
||||
|
||||
text = build_race_alert(race_data, filtered_preds)
|
||||
ok = send_telegram_message(chat_id, text)
|
||||
if ok:
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(
|
||||
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats["sent"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
)
|
||||
return stats
|
||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
448
tests/beta_monitor.py
Normal file
448
tests/beta_monitor.py
Normal file
@@ -0,0 +1,448 @@
|
||||
"""
|
||||
Beta Monitoring — SaaS Turf Prédictions IA
|
||||
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||
Ticket: HRT-34
|
||||
|
||||
Ce module :
|
||||
- Collecte les feedbacks beta via l'API in-app
|
||||
- Envoie des alertes Telegram en cas d'erreur détectée pendant la beta
|
||||
- Génère le rapport beta final (bugs, UX, NPS)
|
||||
|
||||
Usage :
|
||||
# Démarrer le monitoring beta
|
||||
python tests/beta_monitor.py --watch --interval 60
|
||||
|
||||
# Générer le rapport beta final
|
||||
python tests/beta_monitor.py --report
|
||||
|
||||
# Test d'envoi Telegram
|
||||
python tests/beta_monitor.py --test-telegram
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import json
|
||||
import time
|
||||
import sqlite3
|
||||
import requests
|
||||
import argparse
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
TELEGRAM_TOKEN = os.environ.get(
|
||||
"TELEGRAM_TOKEN", "8649773134:AAFqzZVtSHfPPFDadcte1B-1h23nZ8DmdYE"
|
||||
)
|
||||
TELEGRAM_CHAT_ID = os.environ.get("TELEGRAM_CHAT_ID", "") # À configurer
|
||||
|
||||
BETA_DB_PATH = os.environ.get("BETA_DB_PATH", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
REPORTS_DIR = Path("tests/reports")
|
||||
REPORTS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Seuils d'alerte
|
||||
ERROR_RATE_THRESHOLD = 0.01 # 1% d'erreurs → alerte
|
||||
LATENCY_P95_THRESHOLD_MS = 500 # p95 > 500ms → alerte
|
||||
BETA_MIN_USERS = 10 # Minimum d'utilisateurs beta requis
|
||||
NPS_TARGET = 7.0 # NPS cible (sur 10)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Alertes Telegram
|
||||
# ============================================================
|
||||
|
||||
|
||||
def send_telegram(message: str, parse_mode: str = "Markdown") -> bool:
|
||||
"""Envoie un message Telegram d'alerte."""
|
||||
if not TELEGRAM_TOKEN or not TELEGRAM_CHAT_ID:
|
||||
print(f"⚠️ Telegram non configuré. Message: {message[:100]}")
|
||||
return False
|
||||
|
||||
try:
|
||||
resp = requests.post(
|
||||
f"https://api.telegram.org/bot{TELEGRAM_TOKEN}/sendMessage",
|
||||
json={
|
||||
"chat_id": TELEGRAM_CHAT_ID,
|
||||
"text": message,
|
||||
"parse_mode": parse_mode,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
print(f"✅ Alerte Telegram envoyée")
|
||||
return True
|
||||
else:
|
||||
print(f"❌ Telegram erreur: {resp.status_code} — {resp.text}")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"❌ Telegram exception: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def alert_error(endpoint: str, status_code: int, message: str):
|
||||
"""Alerte Telegram sur erreur critique."""
|
||||
text = (
|
||||
f"🚨 *ALERTE BETA — SaaS Turf IA*\n\n"
|
||||
f"Erreur détectée sur `{endpoint}`\n"
|
||||
f"Status: `{status_code}`\n"
|
||||
f"Message: {message[:200]}\n"
|
||||
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||
f"_Ticket: HRT-34_"
|
||||
)
|
||||
send_telegram(text)
|
||||
|
||||
|
||||
def alert_performance(p95_ms: float, error_rate: float):
|
||||
"""Alerte Telegram sur dégradation de performance."""
|
||||
text = (
|
||||
f"⚠️ *ALERTE PERFORMANCE — SaaS Turf IA*\n\n"
|
||||
f"p95 latence: `{p95_ms:.0f}ms` (seuil: {LATENCY_P95_THRESHOLD_MS}ms)\n"
|
||||
f"Error rate: `{error_rate * 100:.2f}%` (seuil: {ERROR_RATE_THRESHOLD * 100:.1f}%)\n"
|
||||
f"Heure: {datetime.now().strftime('%H:%M:%S')}\n\n"
|
||||
f"_Ticket: HRT-34_"
|
||||
)
|
||||
send_telegram(text)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Collecte de métriques
|
||||
# ============================================================
|
||||
|
||||
|
||||
class BetaMonitor:
|
||||
"""Moniteur actif pendant la beta fermée."""
|
||||
|
||||
ENDPOINTS_TO_CHECK = [
|
||||
"/api",
|
||||
"/api/races",
|
||||
"/api/scoring",
|
||||
"/dashboard",
|
||||
"/",
|
||||
]
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.errors: list[dict] = []
|
||||
self.latencies: list[float] = []
|
||||
self.check_count = 0
|
||||
|
||||
def check_endpoint(self, path: str) -> dict:
|
||||
"""Vérifie un endpoint et retourne le résultat."""
|
||||
start = time.time()
|
||||
try:
|
||||
resp = requests.get(f"{self.base_url}{path}", timeout=10)
|
||||
latency_ms = (time.time() - start) * 1000
|
||||
return {
|
||||
"path": path,
|
||||
"status": resp.status_code,
|
||||
"latency_ms": latency_ms,
|
||||
"ok": resp.status_code < 500,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except requests.exceptions.ConnectionError as e:
|
||||
return {
|
||||
"path": path,
|
||||
"status": 0,
|
||||
"latency_ms": 0,
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
"path": path,
|
||||
"status": 0,
|
||||
"latency_ms": 0,
|
||||
"ok": False,
|
||||
"error": str(e),
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
}
|
||||
|
||||
def run_checks(self) -> dict:
|
||||
"""Exécute tous les checks et retourne un résumé."""
|
||||
results = [self.check_endpoint(p) for p in self.ENDPOINTS_TO_CHECK]
|
||||
self.check_count += 1
|
||||
|
||||
failures = [r for r in results if not r["ok"]]
|
||||
latencies = [r["latency_ms"] for r in results if r["latency_ms"] > 0]
|
||||
|
||||
p95 = (
|
||||
sorted(latencies)[int(len(latencies) * 0.95)]
|
||||
if len(latencies) >= 2
|
||||
else (latencies[0] if latencies else 0)
|
||||
)
|
||||
error_rate = len(failures) / len(results) if results else 0
|
||||
|
||||
# Stocker pour rapport
|
||||
self.latencies.extend(latencies)
|
||||
self.errors.extend(failures)
|
||||
|
||||
return {
|
||||
"check_number": self.check_count,
|
||||
"timestamp": datetime.now().isoformat(),
|
||||
"total_checks": len(results),
|
||||
"failures": len(failures),
|
||||
"error_rate": error_rate,
|
||||
"p95_ms": p95,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
def watch(self, interval_seconds: int = 60):
|
||||
"""Surveillance continue avec alertes Telegram."""
|
||||
print(f"🔍 Beta monitoring démarré — {self.base_url}")
|
||||
print(f" Intervalle: {interval_seconds}s")
|
||||
print(f" Endpoints: {len(self.ENDPOINTS_TO_CHECK)}")
|
||||
print(f" Ctrl+C pour arrêter\n")
|
||||
|
||||
consecutive_errors = 0
|
||||
|
||||
try:
|
||||
while True:
|
||||
summary = self.run_checks()
|
||||
timestamp = datetime.now().strftime("%H:%M:%S")
|
||||
|
||||
status_icon = "✅" if summary["error_rate"] == 0 else "❌"
|
||||
print(
|
||||
f"[{timestamp}] {status_icon} "
|
||||
f"Check #{summary['check_number']} — "
|
||||
f"p95={summary['p95_ms']:.0f}ms, "
|
||||
f"errors={summary['failures']}/{summary['total_checks']}"
|
||||
)
|
||||
|
||||
# Alertes
|
||||
if summary["error_rate"] > ERROR_RATE_THRESHOLD:
|
||||
consecutive_errors += 1
|
||||
if consecutive_errors >= 2: # 2 checks consécutifs en erreur
|
||||
for failure in summary["results"]:
|
||||
if not failure["ok"]:
|
||||
alert_error(
|
||||
failure["path"],
|
||||
failure.get("status", 0),
|
||||
failure.get("error", "Non-2xx response"),
|
||||
)
|
||||
else:
|
||||
consecutive_errors = 0
|
||||
|
||||
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS:
|
||||
print(f"⚠️ Latence p95 élevée: {summary['p95_ms']:.0f}ms")
|
||||
if summary["p95_ms"] > LATENCY_P95_THRESHOLD_MS * 2:
|
||||
alert_performance(summary["p95_ms"], summary["error_rate"])
|
||||
|
||||
# Sauvegarder les résultats
|
||||
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||
with open(log_file, "a") as f:
|
||||
f.write(json.dumps(summary) + "\n")
|
||||
|
||||
time.sleep(interval_seconds)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print(f"\n⏹️ Monitoring arrêté après {self.check_count} checks")
|
||||
self.generate_report()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Rapport beta final
|
||||
# ============================================================
|
||||
|
||||
|
||||
class BetaReport:
|
||||
"""Générateur de rapport beta fermée."""
|
||||
|
||||
def __init__(self, base_url: str = BASE_URL):
|
||||
self.base_url = base_url
|
||||
self.timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
|
||||
def collect_feedback_from_db(self) -> list[dict]:
|
||||
"""Collecte les feedbacks depuis la BDD (table beta_feedback si elle existe)."""
|
||||
try:
|
||||
conn = sqlite3.connect(BETA_DB_PATH)
|
||||
c = conn.cursor()
|
||||
c.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='beta_feedback'"
|
||||
)
|
||||
if not c.fetchone():
|
||||
conn.close()
|
||||
return []
|
||||
c.execute("SELECT * FROM beta_feedback ORDER BY created_at DESC")
|
||||
rows = c.fetchall()
|
||||
conn.close()
|
||||
return [dict(zip([col[0] for col in c.description], row)) for row in rows]
|
||||
except Exception as e:
|
||||
print(f"⚠️ Impossible de lire beta_feedback: {e}")
|
||||
return []
|
||||
|
||||
def collect_monitor_logs(self) -> list[dict]:
|
||||
"""Lit les logs du monitoring beta."""
|
||||
log_file = REPORTS_DIR / "beta_monitor_log.jsonl"
|
||||
if not log_file.exists():
|
||||
return []
|
||||
entries = []
|
||||
with open(log_file) as f:
|
||||
for line in f:
|
||||
try:
|
||||
entries.append(json.loads(line))
|
||||
except Exception:
|
||||
pass
|
||||
return entries
|
||||
|
||||
def generate(self) -> str:
|
||||
"""Génère le rapport complet et le sauvegarde."""
|
||||
feedbacks = self.collect_feedback_from_db()
|
||||
monitor_logs = self.collect_monitor_logs()
|
||||
|
||||
# Calculer NPS depuis les feedbacks
|
||||
nps_scores = [
|
||||
f.get("nps_score") for f in feedbacks if f.get("nps_score") is not None
|
||||
]
|
||||
avg_nps = sum(nps_scores) / len(nps_scores) if nps_scores else None
|
||||
|
||||
# Statistiques monitoring
|
||||
if monitor_logs:
|
||||
all_latencies = []
|
||||
total_errors = 0
|
||||
total_checks = 0
|
||||
for entry in monitor_logs:
|
||||
all_latencies.extend(
|
||||
[
|
||||
r["latency_ms"]
|
||||
for r in entry.get("results", [])
|
||||
if r.get("latency_ms", 0) > 0
|
||||
]
|
||||
)
|
||||
total_errors += entry.get("failures", 0)
|
||||
total_checks += entry.get("total_checks", 0)
|
||||
avg_latency = (
|
||||
sum(all_latencies) / len(all_latencies) if all_latencies else 0
|
||||
)
|
||||
overall_error_rate = total_errors / total_checks if total_checks > 0 else 0
|
||||
else:
|
||||
avg_latency = 0
|
||||
overall_error_rate = 0
|
||||
total_checks = 0
|
||||
|
||||
# Construire le rapport
|
||||
report = []
|
||||
report.append("=" * 60)
|
||||
report.append("RAPPORT BETA FERMÉE — SaaS Turf Prédictions IA")
|
||||
report.append(f"Généré le : {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
|
||||
report.append(f"Ticket : HRT-34")
|
||||
report.append("=" * 60)
|
||||
report.append("")
|
||||
report.append("## 1. PARTICIPANTS BETA")
|
||||
report.append(f" Feedbacks reçus : {len(feedbacks)}")
|
||||
report.append(
|
||||
f" NPS moyen : {avg_nps:.1f}/10"
|
||||
if avg_nps
|
||||
else " NPS moyen : (en attente feedbacks)"
|
||||
)
|
||||
report.append(f" Cible NPS : ≥ {NPS_TARGET}/10")
|
||||
nps_ok = avg_nps is not None and avg_nps >= NPS_TARGET
|
||||
report.append(
|
||||
f" Statut NPS : {'✅ OBJECTIF ATTEINT' if nps_ok else '⏳ En attente' if avg_nps is None else '❌ OBJECTIF NON ATTEINT'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 2. BUGS SIGNALÉS")
|
||||
bugs = [f for f in feedbacks if f.get("type") == "bug"]
|
||||
critical_bugs = [b for b in bugs if b.get("severity") in ("critical", "high")]
|
||||
report.append(f" Total bugs : {len(bugs)}")
|
||||
report.append(f" Critiques/High : {len(critical_bugs)}")
|
||||
report.append(
|
||||
f" Statut : {'✅ 0 bug critique' if len(critical_bugs) == 0 else f'❌ {len(critical_bugs)} bug(s) critique(s)'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 3. PERFORMANCE RÉELLE (monitoring)")
|
||||
report.append(f" Checks effectués: {total_checks}")
|
||||
report.append(f" Latence moyenne : {avg_latency:.1f}ms")
|
||||
report.append(f" Error rate : {overall_error_rate * 100:.2f}%")
|
||||
report.append(f" Seuil latence : {LATENCY_P95_THRESHOLD_MS}ms")
|
||||
perf_ok = (
|
||||
avg_latency < LATENCY_P95_THRESHOLD_MS
|
||||
and overall_error_rate < ERROR_RATE_THRESHOLD
|
||||
)
|
||||
report.append(
|
||||
f" Statut : {'✅ OBJECTIF ATTEINT' if perf_ok else '⏳ Données insuffisantes' if total_checks == 0 else '❌ OBJECTIF NON ATTEINT'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append("## 4. FEEDBACKS UX")
|
||||
ux_feedbacks = [f for f in feedbacks if f.get("type") == "ux"]
|
||||
report.append(f" Retours UX : {len(ux_feedbacks)}")
|
||||
if ux_feedbacks:
|
||||
for fb in ux_feedbacks[:5]: # Top 5
|
||||
report.append(f" - {fb.get('comment', '')[:100]}")
|
||||
report.append("")
|
||||
report.append("## 5. VERDICT BETA FERMÉE")
|
||||
users_ok = len(feedbacks) >= 5 # Au moins 5 feedbacks = 5 users satisfaits
|
||||
verdict = all([users_ok, nps_ok, len(critical_bugs) == 0])
|
||||
report.append(
|
||||
f" Participants suffisants (≥5) : {'✅' if users_ok else '❌'}"
|
||||
)
|
||||
report.append(f" NPS ≥ 7/10 : {'✅' if nps_ok else '❌'}")
|
||||
report.append(
|
||||
f" 0 bug critique : {'✅' if len(critical_bugs) == 0 else '❌'}"
|
||||
)
|
||||
report.append("")
|
||||
report.append(
|
||||
f" VERDICT GLOBAL : {'✅ GO — Beta réussie' if verdict else '❌ NO-GO — Conditions non remplies'}"
|
||||
)
|
||||
report.append("=" * 60)
|
||||
|
||||
report_text = "\n".join(report)
|
||||
|
||||
# Sauvegarder
|
||||
report_file = REPORTS_DIR / f"beta_report_{self.timestamp}.txt"
|
||||
with open(report_file, "w") as f:
|
||||
f.write(report_text)
|
||||
|
||||
print(report_text)
|
||||
print(f"\nRapport sauvegardé : {report_file}")
|
||||
|
||||
return report_text
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CLI
|
||||
# ============================================================
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Beta Monitor — SaaS Turf IA")
|
||||
parser.add_argument("--watch", action="store_true", help="Surveillance continue")
|
||||
parser.add_argument(
|
||||
"--interval", type=int, default=60, help="Intervalle en secondes (défaut: 60)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--report", action="store_true", help="Générer le rapport beta final"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--test-telegram", action="store_true", help="Tester l'envoi Telegram"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--url", default=BASE_URL, help=f"URL de l'app (défaut: {BASE_URL})"
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.test_telegram:
|
||||
print("Test d'envoi Telegram...")
|
||||
ok = send_telegram(
|
||||
"✅ *Test alerte Beta* — SaaS Turf IA\n_Ceci est un test du système d'alertes QA_\nTicket: HRT-34"
|
||||
)
|
||||
sys.exit(0 if ok else 1)
|
||||
|
||||
if args.report:
|
||||
reporter = BetaReport(args.url)
|
||||
reporter.generate()
|
||||
sys.exit(0)
|
||||
|
||||
if args.watch:
|
||||
monitor = BetaMonitor(args.url)
|
||||
monitor.watch(interval_seconds=args.interval)
|
||||
sys.exit(0)
|
||||
|
||||
parser.print_help()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
124
tests/conftest.py
Normal file
124
tests/conftest.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""
|
||||
conftest.py — Configuration pytest globale
|
||||
SaaS Turf Prédictions IA — Sprint 8 QA
|
||||
Ticket: HRT-34
|
||||
"""
|
||||
|
||||
import os
|
||||
import asyncio
|
||||
import pytest
|
||||
from pathlib import Path
|
||||
from datetime import datetime
|
||||
|
||||
# ============================================================
|
||||
# Répertoires de sortie
|
||||
# ============================================================
|
||||
|
||||
REPORTS_DIR = Path("tests/reports")
|
||||
SCREENSHOTS_DIR = Path("tests/screenshots")
|
||||
|
||||
for d in [REPORTS_DIR, SCREENSHOTS_DIR]:
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Variables d'environnement
|
||||
# ============================================================
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixtures globales
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def base_url():
|
||||
return BASE_URL
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Event loop partagé pour les tests async de la session."""
|
||||
policy = asyncio.get_event_loop_policy()
|
||||
loop = policy.new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def reports_dir():
|
||||
return REPORTS_DIR
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def screenshots_dir():
|
||||
return SCREENSHOTS_DIR
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Hook : screenshot automatique sur échec
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.hookimpl(tryfirst=True, hookwrapper=True)
|
||||
def pytest_runtest_makereport(item, call):
|
||||
"""Capture screenshot automatiquement sur tout test E2E en échec."""
|
||||
outcome = yield
|
||||
report = outcome.get_result()
|
||||
|
||||
if report.when == "call" and report.failed:
|
||||
# Récupérer la page Playwright si disponible dans les fixtures
|
||||
page = None
|
||||
for fixture_name in ("page", "context_page"):
|
||||
if fixture_name in item.funcargs:
|
||||
val = item.funcargs[fixture_name]
|
||||
if isinstance(val, tuple):
|
||||
page = val[0] # (page, browser_name)
|
||||
else:
|
||||
page = val
|
||||
break
|
||||
|
||||
if page is not None:
|
||||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
test_name = item.name.replace("/", "_").replace(":", "_")
|
||||
screenshot_path = SCREENSHOTS_DIR / f"FAIL_{test_name}_{timestamp}.png"
|
||||
try:
|
||||
# Playwright page.screenshot est synchrone dans les fixtures sync
|
||||
# Pour les fixtures async, on force la capture
|
||||
import asyncio as _asyncio
|
||||
|
||||
if _asyncio.iscoroutinefunction(page.screenshot):
|
||||
loop = _asyncio.get_event_loop()
|
||||
loop.run_until_complete(page.screenshot(path=str(screenshot_path)))
|
||||
else:
|
||||
page.screenshot(path=str(screenshot_path))
|
||||
report.sections.append(
|
||||
("Screenshot", f"Sauvegardé : {screenshot_path}")
|
||||
)
|
||||
except Exception as e:
|
||||
report.sections.append(
|
||||
("Screenshot Error", f"Impossible de capturer : {e}")
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Marqueurs personnalisés
|
||||
# ============================================================
|
||||
|
||||
|
||||
def pytest_configure(config):
|
||||
config.addinivalue_line("markers", "e2e: Tests End-to-End Playwright")
|
||||
config.addinivalue_line("markers", "load: Tests de charge Locust")
|
||||
config.addinivalue_line("markers", "security: Tests de sécurité")
|
||||
config.addinivalue_line(
|
||||
"markers", "smoke: Tests rapides de smoke (sans infra complète)"
|
||||
)
|
||||
config.addinivalue_line("markers", "beta: Tests spécifiques beta fermée")
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_billing: Nécessite HRT-31 (Billing Stripe)"
|
||||
)
|
||||
config.addinivalue_line(
|
||||
"markers", "requires_infra: Nécessite HRT-33 (infra staging)"
|
||||
)
|
||||
@@ -141,7 +141,7 @@ class TestJWTAuthentication:
|
||||
"invalid_signature_here"
|
||||
)
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {expired_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -153,7 +153,7 @@ class TestJWTAuthentication:
|
||||
"""Un token JWT malformé doit être rejeté."""
|
||||
for bad_token in ["not.a.jwt", "Bearer", "null", "undefined", ""]:
|
||||
resp = requests.get(
|
||||
f"{BASE_URL}/api/races",
|
||||
f"{BASE_URL}/api/v1/predictions/today",
|
||||
headers={"Authorization": f"Bearer {bad_token}"},
|
||||
timeout=5,
|
||||
)
|
||||
@@ -163,7 +163,7 @@ class TestJWTAuthentication:
|
||||
|
||||
def test_jwt_sans_token(self):
|
||||
"""Sans token, les routes protégées doivent retourner 401."""
|
||||
resp = requests.get(f"{BASE_URL}/api/export/csv", timeout=5)
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/export/csv", timeout=5)
|
||||
assert resp.status_code in (401, 403), (
|
||||
f"Route protégée accessible sans token: status={resp.status_code}"
|
||||
)
|
||||
@@ -303,6 +303,138 @@ class TestPlanAuthorisation:
|
||||
)
|
||||
|
||||
|
||||
# === Tests validation mots de passe faibles (HRT-63) ===
|
||||
|
||||
|
||||
class TestWeakPasswordRejection:
|
||||
"""Tests rejet mots de passe faibles : blacklist + complexité (HRT-63)."""
|
||||
|
||||
REGISTER_URL = (
|
||||
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/register"
|
||||
)
|
||||
|
||||
WEAK_PASSWORDS = [
|
||||
"password",
|
||||
"12345678",
|
||||
"qwerty123",
|
||||
"letmein1",
|
||||
"admin123",
|
||||
"welcome1",
|
||||
"iloveyou",
|
||||
"abc1234",
|
||||
"sunshine",
|
||||
"111111111",
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize("weak_pwd", WEAK_PASSWORDS)
|
||||
def test_weak_password_rejected(self, weak_pwd):
|
||||
"""Les mots de passe faibles/blacklistés doivent retourner 400."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_weak_{int(_time.time() * 1000)}_{weak_pwd[:4]}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": weak_pwd, "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe faible accepté: pwd={weak_pwd!r}, status={resp.status_code}"
|
||||
)
|
||||
body = resp.json()
|
||||
assert "error" in body, f"Pas de champ 'error' dans la réponse: {body}"
|
||||
|
||||
def test_strong_password_accepted(self):
|
||||
"""Un mot de passe fort doit permettre l'inscription (retourne 201)."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_strong_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "Tr0ub4d@ur!", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 201, (
|
||||
f"Mot de passe fort rejeté: status={resp.status_code}, body={resp.text}"
|
||||
)
|
||||
data = resp.json()
|
||||
assert "token" in data, f"Pas de token dans la réponse: {data}"
|
||||
|
||||
def test_no_digit_rejected(self):
|
||||
"""Un mot de passe sans chiffre doit être rejeté."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_nodigit_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "NoDigitPassword", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe sans chiffre accepté: status={resp.status_code}"
|
||||
)
|
||||
|
||||
def test_no_letter_rejected(self):
|
||||
"""Un mot de passe sans lettre doit être rejeté."""
|
||||
import time as _time
|
||||
|
||||
unique_email = f"test_noletter_{int(_time.time() * 1000)}@h3r7.tech"
|
||||
resp = requests.post(
|
||||
self.REGISTER_URL,
|
||||
json={"email": unique_email, "password": "12345678901", "plan": "free"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 400, (
|
||||
f"Mot de passe sans lettre accepté: status={resp.status_code}"
|
||||
)
|
||||
# === Tests rate limiting login ===
|
||||
|
||||
|
||||
class TestLoginRateLimit:
|
||||
"""Tests rate limiting sur /api/v1/auth/login."""
|
||||
|
||||
TARGET_URL = (
|
||||
os.environ.get("APP_URL", "http://localhost:8792") + "/api/v1/auth/login"
|
||||
)
|
||||
|
||||
def test_login_brute_force_blocked_after_5_attempts(self):
|
||||
"""Après 5 tentatives, le 6ème appel doit retourner 429."""
|
||||
# Utiliser un email unique pour isoler le test
|
||||
email = f"ratelimit_test_{int(time.time())}@h3r7.tech"
|
||||
for i in range(5):
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code in (400, 401), (
|
||||
f"Tentative {i + 1}: status inattendu {resp.status_code}"
|
||||
)
|
||||
# La 6ème tentative doit être bloquée
|
||||
resp = requests.post(
|
||||
self.TARGET_URL,
|
||||
json={"email": email, "password": "wrong_password"},
|
||||
timeout=5,
|
||||
)
|
||||
assert resp.status_code == 429, (
|
||||
f"Rate limit non appliqué après 5 tentatives: got {resp.status_code}"
|
||||
)
|
||||
assert "Retry-After" in resp.headers, "Header Retry-After manquant sur 429"
|
||||
|
||||
def test_login_429_has_retry_after_header(self):
|
||||
"""La réponse 429 doit inclure Retry-After."""
|
||||
email = f"ratelimit_test2_{int(time.time())}@h3r7.tech"
|
||||
for _ in range(6):
|
||||
requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
resp = requests.post(
|
||||
self.TARGET_URL, json={"email": email, "password": "x"}, timeout=5
|
||||
)
|
||||
if resp.status_code == 429:
|
||||
assert "Retry-After" in resp.headers
|
||||
assert int(resp.headers["Retry-After"]) > 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import subprocess
|
||||
|
||||
|
||||
473
tests/test_api_v1.py
Normal file
473
tests/test_api_v1.py
Normal file
@@ -0,0 +1,473 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Integration tests for API v1 — HRT-29
|
||||
Sprint 3-4: Refacto API /v1/
|
||||
|
||||
Run with:
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/test_api_v1.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import pytest
|
||||
|
||||
# Ensure local modules are importable
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Use a temp file DB for tests (in-memory fails with multiple connections)
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-key"
|
||||
|
||||
from app_v1 import create_app
|
||||
from auth_db import init_auth_tables
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
application.config["JWT_SECRET_KEY"] = "test-secret-key"
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_tokens(client):
|
||||
"""Register a user and return tokens for each plan."""
|
||||
tokens = {}
|
||||
plans = {
|
||||
"free": ("free@test.com", "password123"),
|
||||
"premium": ("premium@test.com", "password123"),
|
||||
"pro": ("pro@test.com", "password123"),
|
||||
}
|
||||
|
||||
# Register users
|
||||
for plan, (email, pw) in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": pw},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||
|
||||
# Manually set plans in DB using direct sqlite (bypass app context issues)
|
||||
import sqlite3
|
||||
|
||||
db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
for plan, (email, _) in plans.items():
|
||||
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Login and collect tokens
|
||||
for plan, (email, pw) in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": pw},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||
data = r.get_json()
|
||||
tokens[plan] = data["access_token"]
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Health
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_public(self, client):
|
||||
"""GET /api/v1/health — no auth required"""
|
||||
r = client.get("/api/v1/health")
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["version"] == "1.0"
|
||||
assert "timestamp" in data
|
||||
|
||||
def test_health_returns_json(self, client):
|
||||
r = client.get("/api/v1/health")
|
||||
assert r.content_type.startswith("application/json")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuth:
|
||||
def test_register_new_user(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "new_test@example.com", "password": "strongpass123"},
|
||||
)
|
||||
assert r.status_code in (201, 409)
|
||||
|
||||
def test_register_short_password(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "bad@example.com", "password": "123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_register_invalid_email(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "notemail", "password": "password123"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_login_valid(self, client, auth_tokens):
|
||||
assert "free" in auth_tokens
|
||||
|
||||
def test_login_wrong_password(self, client):
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "free@test.com", "password": "wrongpassword"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_protected_without_token(self, client):
|
||||
r = client.get("/api/v1/courses/today")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Courses
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCourses:
|
||||
def test_today_requires_auth(self, client):
|
||||
r = client.get("/api/v1/courses/today")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_today_with_auth(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/courses/today",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "courses" in data
|
||||
assert "pagination" in data
|
||||
assert "date" in data
|
||||
|
||||
def test_today_pagination(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/courses/today?limit=5&offset=0",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["pagination"]["limit"] == 5
|
||||
assert data["pagination"]["offset"] == 0
|
||||
|
||||
def test_today_filter_all(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/courses/today?filter=all",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_course_predictions_requires_auth(self, client):
|
||||
r = client.get("/api/v1/courses/1-1/predictions")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_course_predictions_invalid_id(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/courses/invalid/predictions",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_course_predictions_not_found(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/courses/99-99/predictions",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
# 404 expected since DB is empty; 429 if free daily limit already reached in this session
|
||||
assert r.status_code in (404, 200, 429) # 200 if gracefully returns empty
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Predictions
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPredictions:
|
||||
def test_top3_requires_auth(self, client):
|
||||
r = client.get("/api/v1/predictions/top3")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_top3_free_allowed(self, client, auth_tokens):
|
||||
# Reset daily usage for free user before testing rate-limited endpoint
|
||||
import sqlite3
|
||||
|
||||
db_path = os.environ.get("TURF_SAAS_DB", "/tmp/test_turf.db")
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"UPDATE users SET daily_usage=0, last_usage_date=NULL WHERE email='free@test.com'"
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
r = client.get(
|
||||
"/api/v1/predictions/top3",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "top3" in data
|
||||
|
||||
def test_all_requires_premium(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/predictions/all",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_all_premium_allowed(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/predictions/all",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "predictions" in data
|
||||
assert "pagination" in data
|
||||
|
||||
def test_all_pro_allowed(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/predictions/all",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Value Bets
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValueBets:
|
||||
def test_requires_auth(self, client):
|
||||
r = client.get("/api/v1/valuebets")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_free_forbidden(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/valuebets",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_premium_allowed(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/valuebets",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "valuebets" in data
|
||||
assert "pagination" in data
|
||||
|
||||
def test_min_odds_filter(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/valuebets?min_odds=3.0",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["min_odds"] == 3.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Backtest
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBacktest:
|
||||
def test_requires_auth(self, client):
|
||||
r = client.get("/api/v1/backtest")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_premium_forbidden(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/backtest",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_pro_allowed(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/backtest",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "summary" in data
|
||||
assert "period" in data
|
||||
|
||||
def test_invalid_date_format(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/backtest?start=31-12-2025",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Export
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExport:
|
||||
def test_requires_auth(self, client):
|
||||
r = client.get("/api/v1/export/csv")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_free_forbidden(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/export/csv",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_premium_forbidden(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/export/csv",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_pro_allowed_predictions(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/export/csv?type=predictions",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
# 200 (CSV) or 400 if table doesn't exist in test DB
|
||||
assert r.status_code in (200, 400)
|
||||
if r.status_code == 200:
|
||||
assert "text/csv" in r.content_type
|
||||
|
||||
def test_invalid_type(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/export/csv?type=invalid",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Metrics
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMetrics:
|
||||
def test_requires_auth(self, client):
|
||||
r = client.get("/api/v1/metrics")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_free_forbidden(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/metrics",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
|
||||
def test_premium_allowed(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/metrics",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "bet_metrics" in data
|
||||
assert "ml_metrics" in data
|
||||
assert "period" in data
|
||||
|
||||
def test_days_parameter(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/metrics?days=7",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["period"]["days"] == 7
|
||||
|
||||
def test_invalid_days(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/metrics?days=abc",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Global error handlers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestErrorHandlers:
|
||||
def test_404_returns_json(self, client):
|
||||
r = client.get("/api/v1/this-does-not-exist")
|
||||
assert r.status_code == 404
|
||||
data = r.get_json()
|
||||
assert data["code"] == 404
|
||||
|
||||
def test_uniform_error_shape(self, client):
|
||||
"""All error responses must have {status, message, code}."""
|
||||
r = client.get("/api/v1/this-does-not-exist")
|
||||
data = r.get_json()
|
||||
assert "status" in data
|
||||
assert "message" in data
|
||||
assert "code" in data
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Swagger docs
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDocs:
|
||||
def test_docs_accessible(self, client):
|
||||
r = client.get("/api/v1/docs")
|
||||
# flasgger returns a redirect or the UI page
|
||||
assert r.status_code in (200, 301, 302)
|
||||
|
||||
def test_apispec_json(self, client):
|
||||
r = client.get("/api/v1/apispec.json")
|
||||
assert r.status_code == 200
|
||||
spec = r.get_json()
|
||||
assert spec["swagger"] == "2.0"
|
||||
assert "paths" in spec
|
||||
404
tests/test_auth.py
Normal file
404
tests/test_auth.py
Normal file
@@ -0,0 +1,404 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Pytest tests — Auth JWT + Multi-tenant
|
||||
Sprint 2-3: HRT-28
|
||||
Coverage target: >= 80%
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_auth.py -v --tb=short
|
||||
./venv/bin/pytest tests/test_auth.py -v --cov=auth --cov=auth_db --cov=middleware --cov=saas_api --cov-report=term-missing
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import json
|
||||
import pytest
|
||||
|
||||
# Point to a temp SQLite DB for tests
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-key-for-pytest"
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from saas_api import create_app # noqa: E402
|
||||
|
||||
TEST_CONFIG = {
|
||||
"TESTING": True,
|
||||
"JWT_SECRET_KEY": "test-secret-key-for-pytest",
|
||||
"JWT_ACCESS_TOKEN_EXPIRES": 900,
|
||||
"JWT_REFRESH_TOKEN_EXPIRES": 2592000,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app(TEST_CONFIG)
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Health
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHealth:
|
||||
def test_health_ok(self, client):
|
||||
resp = client.get("/api/v1/health")
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["service"] == "turf-saas-api"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Registration
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRegister:
|
||||
def test_register_success(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "user_test@example.com", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert "user_id" in data
|
||||
|
||||
def test_register_duplicate(self, client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@example.com", "password": "password123"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "dup@example.com", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_register_invalid_email(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "notanemail", "password": "password123"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_register_short_password(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "shortpw@example.com", "password": "abc"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_register_missing_fields(self, client):
|
||||
resp = client.post("/api/v1/auth/register", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Login
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogin:
|
||||
@pytest.fixture(autouse=True)
|
||||
def create_user(self, client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "login@example.com", "password": "loginpass1"},
|
||||
)
|
||||
|
||||
def test_login_success(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "login@example.com", "password": "loginpass1"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
assert data["plan"] == "free"
|
||||
|
||||
def test_login_wrong_password(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "login@example.com", "password": "wrongpass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_unknown_email(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "ghost@example.com", "password": "anypass"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_login_missing_fields(self, client):
|
||||
resp = client.post("/api/v1/auth/login", json={"email": "login@example.com"})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Token refresh
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRefresh:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "refresh@example.com", "password": "refreshpass1"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "refresh@example.com", "password": "refreshpass1"},
|
||||
)
|
||||
tokens = resp.get_json()
|
||||
self.refresh_token = tokens["refresh_token"]
|
||||
|
||||
def test_refresh_success(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": self.refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "access_token" in data
|
||||
assert "refresh_token" in data
|
||||
# New refresh token should differ from old
|
||||
assert data["refresh_token"] != self.refresh_token
|
||||
|
||||
def test_refresh_token_rotation(self, client):
|
||||
"""Old refresh token must be invalid after rotation."""
|
||||
client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": self.refresh_token},
|
||||
)
|
||||
resp2 = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": self.refresh_token},
|
||||
)
|
||||
assert resp2.status_code == 401
|
||||
|
||||
def test_refresh_invalid_token(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": "completely.invalid.token"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_refresh_missing_token(self, client):
|
||||
resp = client.post("/api/v1/auth/refresh", json={})
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Logout
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLogout:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "logout@example.com", "password": "logoutpass1"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "logout@example.com", "password": "logoutpass1"},
|
||||
)
|
||||
tokens = resp.get_json()
|
||||
self.refresh_token = tokens["refresh_token"]
|
||||
self.access_token = tokens["access_token"]
|
||||
|
||||
def test_logout_success(self, client):
|
||||
resp = client.post(
|
||||
"/api/v1/auth/logout",
|
||||
json={"refresh_token": self.refresh_token},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
|
||||
def test_refresh_after_logout_fails(self, client):
|
||||
client.post("/api/v1/auth/logout", json={"refresh_token": self.refresh_token})
|
||||
resp = client.post(
|
||||
"/api/v1/auth/refresh",
|
||||
json={"refresh_token": self.refresh_token},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_logout_no_token(self, client):
|
||||
resp = client.post("/api/v1/auth/logout", json={})
|
||||
assert resp.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JWT middleware — protected routes
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestJWTMiddleware:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, client):
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "protected@example.com", "password": "protect123"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "protected@example.com", "password": "protect123"},
|
||||
)
|
||||
self.access_token = resp.get_json()["access_token"]
|
||||
|
||||
def test_subscription_info_requires_auth(self, client):
|
||||
resp = client.get("/api/v1/subscription/upgrade")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_subscription_info_with_token(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/subscription/upgrade",
|
||||
headers={"Authorization": f"Bearer {self.access_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "current_plan" in data
|
||||
assert data["current_plan"] == "free"
|
||||
|
||||
def test_invalid_token_rejected(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/subscription/upgrade",
|
||||
headers={"Authorization": "Bearer invalid.token.here"},
|
||||
)
|
||||
assert resp.status_code in (401, 422)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plan checks
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPlanMiddleware:
|
||||
@pytest.fixture(autouse=True)
|
||||
def setup(self, client, app):
|
||||
# Register free user
|
||||
client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": "free_plan@example.com", "password": "freepass1"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": "free_plan@example.com", "password": "freepass1"},
|
||||
)
|
||||
self.free_token = resp.get_json()["access_token"]
|
||||
|
||||
# Upgrade user to pro directly in DB for testing
|
||||
import sqlite3
|
||||
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||
("pro_plan@example.com", "$2b$12$placeholder", "pro"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Login pro user using JWT created manually via app context
|
||||
with app.app_context():
|
||||
from flask_jwt_extended import create_access_token
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
row = conn.execute(
|
||||
"SELECT id FROM users WHERE email='pro_plan@example.com'"
|
||||
).fetchone()
|
||||
conn.close()
|
||||
self.pro_token = create_access_token(
|
||||
identity=str(row[0]),
|
||||
additional_claims={"plan": "pro", "email": "pro_plan@example.com"},
|
||||
)
|
||||
|
||||
def test_export_blocked_for_free(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/predictions/export",
|
||||
headers={"Authorization": f"Bearer {self.free_token}"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert "Plan insuffisant" in data["error"]
|
||||
|
||||
def test_export_allowed_for_pro(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/predictions/export",
|
||||
headers={"Authorization": f"Bearer {self.pro_token}"},
|
||||
)
|
||||
# 503 is expected because no backend is running; 403 would be wrong
|
||||
assert resp.status_code != 403
|
||||
|
||||
def test_upgrade_info_shows_plans(self, client):
|
||||
resp = client.get(
|
||||
"/api/v1/subscription/upgrade",
|
||||
headers={"Authorization": f"Bearer {self.free_token}"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "free" in data["plans"]
|
||||
assert "premium" in data["plans"]
|
||||
assert "pro" in data["plans"]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Rate limiting
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestRateLimiting:
|
||||
def test_rate_limit_headers_present(self, client):
|
||||
resp = client.get("/api/v1/health")
|
||||
assert "X-RateLimit-Limit" in resp.headers
|
||||
assert resp.headers["X-RateLimit-Limit"] == "100"
|
||||
|
||||
def test_rate_limit_remaining_decreases(self, client):
|
||||
r1 = client.get("/api/v1/health")
|
||||
r2 = client.get("/api/v1/health")
|
||||
rem1 = int(r1.headers.get("X-RateLimit-Remaining", 100))
|
||||
rem2 = int(r2.headers.get("X-RateLimit-Remaining", 100))
|
||||
assert rem2 <= rem1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DB module
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAuthDB:
|
||||
def test_tables_exist(self):
|
||||
import sqlite3
|
||||
|
||||
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||
tables = {
|
||||
r[0]
|
||||
for r in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
).fetchall()
|
||||
}
|
||||
assert "users" in tables
|
||||
assert "subscriptions" in tables
|
||||
assert "refresh_tokens" in tables
|
||||
conn.close()
|
||||
|
||||
def test_get_db_returns_connection(self):
|
||||
from auth_db import get_db
|
||||
|
||||
db = get_db()
|
||||
assert db is not None
|
||||
db.close()
|
||||
407
tests/test_history.py
Normal file
407
tests/test_history.py
Normal file
@@ -0,0 +1,407 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for GET /api/v1/history — HRT-81
|
||||
Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
|
||||
Run with:
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python -m pytest tests/test_history.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import sqlite3
|
||||
import tempfile
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
# Use an isolated temp DB for these tests
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
|
||||
from app_v1 import create_app
|
||||
from auth_db import init_auth_tables
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
TODAY = datetime.now().date()
|
||||
|
||||
|
||||
def days_ago(n: int) -> str:
|
||||
return (TODAY - timedelta(days=n)).isoformat()
|
||||
|
||||
|
||||
def auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Fixtures
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config["TESTING"] = True
|
||||
application.config["JWT_SECRET_KEY"] = "test-history-secret-key"
|
||||
return application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def seeded_db():
|
||||
"""
|
||||
Seed the test DB:
|
||||
- Create ml_predictions_cache with rows spanning 120 days back
|
||||
- Create users for free/premium/pro plans
|
||||
"""
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
|
||||
# Create ml_predictions_cache table if absent
|
||||
conn.execute("""
|
||||
CREATE TABLE IF NOT EXISTS ml_predictions_cache (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
date TEXT NOT NULL,
|
||||
horse_name TEXT,
|
||||
prob_top1 REAL,
|
||||
prob_top3 REAL,
|
||||
ml_score REAL,
|
||||
race_label TEXT,
|
||||
hippodrome TEXT,
|
||||
heure TEXT,
|
||||
is_value_bet INTEGER DEFAULT 0
|
||||
)
|
||||
""")
|
||||
|
||||
# Seed rows at: 1, 6, 7, 8, 30, 89, 90, 91, 100 days ago
|
||||
offsets = [1, 6, 7, 8, 30, 89, 90, 91, 100]
|
||||
for offset in offsets:
|
||||
d = days_ago(offset)
|
||||
conn.execute(
|
||||
"""INSERT INTO ml_predictions_cache
|
||||
(date, horse_name, prob_top1, prob_top3, ml_score, race_label, hippodrome, heure, is_value_bet)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)""",
|
||||
(d, f"Cheval_{offset}j", 0.5, 0.8, 0.75, f"R1C1", "PARIS", "14:00", 0),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return db_path
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def auth_tokens(client, seeded_db):
|
||||
"""Register/login users for each plan and return their JWT tokens."""
|
||||
plans = {
|
||||
"free": "hist_free@test.com",
|
||||
"premium": "hist_premium@test.com",
|
||||
"pro": "hist_pro@test.com",
|
||||
}
|
||||
password = "password123"
|
||||
|
||||
for plan, email in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": password},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code in (201, 409), f"register failed for {plan}: {r.data}"
|
||||
|
||||
# Set plan via direct DB
|
||||
db_path = os.environ["TURF_SAAS_DB"]
|
||||
conn = sqlite3.connect(db_path)
|
||||
for plan, email in plans.items():
|
||||
conn.execute("UPDATE users SET plan = ? WHERE email = ?", (plan, email))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
tokens = {}
|
||||
for plan, email in plans.items():
|
||||
r = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": password},
|
||||
content_type="application/json",
|
||||
)
|
||||
assert r.status_code == 200, f"login failed for {plan}: {r.data}"
|
||||
tokens[plan] = r.get_json()["access_token"]
|
||||
|
||||
return tokens
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth guard
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryAuth:
|
||||
def test_requires_auth(self, client):
|
||||
"""Unauthenticated request must return 401."""
|
||||
r = client.get("/api/v1/history")
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_invalid_token_returns_401(self, client):
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers={"Authorization": "Bearer this.is.not.valid"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Free plan — 7-day window
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryFreePlan:
|
||||
def test_free_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: start = today-6 (within 7-day window) must return 200."""
|
||||
start = days_ago(6)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "free"
|
||||
assert data["history_limit_days"] == 7
|
||||
|
||||
def test_free_blocked_beyond_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: start = today-8 must return 403 (beyond 7-day window)."""
|
||||
start = days_ago(8)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert data["code"] == 403
|
||||
assert (
|
||||
"upgrade" in data.get("message", "").lower()
|
||||
or "plan" in data.get("message", "").lower()
|
||||
)
|
||||
|
||||
def test_free_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||
"""Free user: no dates specified — should use defaults and return 200."""
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert "history" in data
|
||||
assert "pagination" in data
|
||||
|
||||
def test_free_upgrade_hint_in_403(self, client, auth_tokens, seeded_db):
|
||||
"""403 response must contain required_plans and upgrade_url."""
|
||||
start = days_ago(30)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}",
|
||||
headers=auth_header(auth_tokens["free"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert "required_plans" in data
|
||||
assert "upgrade_url" in data
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Premium plan — 90-day window
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryPremiumPlan:
|
||||
def test_premium_can_access_within_90_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user: start = today-89 must return 200."""
|
||||
start = days_ago(89)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "premium"
|
||||
assert data["history_limit_days"] == 90
|
||||
|
||||
def test_premium_blocked_beyond_90_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user: start = today-91 must return 403."""
|
||||
start = days_ago(91)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 403
|
||||
data = r.get_json()
|
||||
assert data["code"] == 403
|
||||
assert "required_plans" in data
|
||||
# Premium upgrade hint should suggest pro
|
||||
assert "pro" in data.get("required_plans", [])
|
||||
|
||||
def test_premium_can_access_last_7_days(self, client, auth_tokens, seeded_db):
|
||||
"""Premium user can always access the free window too."""
|
||||
start = days_ago(6)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}",
|
||||
headers=auth_header(auth_tokens["premium"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pro plan — unlimited
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryProPlan:
|
||||
def test_pro_can_access_old_data(self, client, auth_tokens, seeded_db):
|
||||
"""Pro user: start = today-100 must return 200 (unlimited)."""
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={TODAY.isoformat()}",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert data["status"] == "ok"
|
||||
assert data["plan"] == "pro"
|
||||
assert data["history_limit_days"] is None # unlimited
|
||||
|
||||
def test_pro_default_request_returns_200(self, client, auth_tokens, seeded_db):
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
def test_pro_can_see_all_seeded_rows(self, client, auth_tokens, seeded_db):
|
||||
"""Pro fetching entire seeded range (100 days) should get all inserted rows."""
|
||||
start = days_ago(100)
|
||||
end = TODAY.isoformat()
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&end={end}&limit=500",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
# All 9 seeded rows should be present
|
||||
assert data["pagination"]["total"] == 9
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Input validation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestHistoryValidation:
|
||||
def test_invalid_start_format(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/history?start=31-12-2025",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
data = r.get_json()
|
||||
assert data["code"] == 400
|
||||
assert "start" in data["message"].lower()
|
||||
|
||||
def test_invalid_end_format(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
"/api/v1/history?end=2025/12/31",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
data = r.get_json()
|
||||
assert "end" in data["message"].lower()
|
||||
|
||||
def test_start_after_end_returns_400(self, client, auth_tokens):
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={TODAY.isoformat()}&end={days_ago(5)}",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_pagination_limit_respected(self, client, auth_tokens, seeded_db):
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert len(data["history"]) <= 3
|
||||
assert data["pagination"]["limit"] == 3
|
||||
|
||||
def test_pagination_has_more(self, client, auth_tokens, seeded_db):
|
||||
"""has_more should be True when more rows exist beyond current page."""
|
||||
start = days_ago(100)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=3&offset=0",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
# 9 total rows seeded, limit=3 → has_more=True
|
||||
assert data["pagination"]["has_more"] is True
|
||||
|
||||
def test_response_shape(self, client, auth_tokens, seeded_db):
|
||||
"""Verify the full response envelope shape."""
|
||||
r = client.get(
|
||||
"/api/v1/history",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
assert "status" in data
|
||||
assert "plan" in data
|
||||
assert "history_limit_days" in data
|
||||
assert "start" in data
|
||||
assert "end" in data
|
||||
assert "history" in data
|
||||
assert "pagination" in data
|
||||
pagination = data["pagination"]
|
||||
assert "total" in pagination
|
||||
assert "limit" in pagination
|
||||
assert "offset" in pagination
|
||||
assert "has_more" in pagination
|
||||
|
||||
def test_history_row_fields(self, client, auth_tokens, seeded_db):
|
||||
"""Each history row must contain the expected ML fields."""
|
||||
start = days_ago(10)
|
||||
r = client.get(
|
||||
f"/api/v1/history?start={start}&limit=5",
|
||||
headers=auth_header(auth_tokens["pro"]),
|
||||
)
|
||||
assert r.status_code == 200
|
||||
data = r.get_json()
|
||||
if data["history"]:
|
||||
row = data["history"][0]
|
||||
expected_fields = {
|
||||
"id",
|
||||
"date",
|
||||
"horse_name",
|
||||
"prob_top1",
|
||||
"prob_top3",
|
||||
"ml_score",
|
||||
"race_label",
|
||||
"hippodrome",
|
||||
"heure",
|
||||
"is_value_bet",
|
||||
}
|
||||
assert expected_fields.issubset(set(row.keys()))
|
||||
300
tests/test_ml_cache_integrity.py
Normal file
300
tests/test_ml_cache_integrity.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""
|
||||
test_ml_cache_integrity.py — Test d'intégration : zéro NULL dans ml_predictions_cache
|
||||
SaaS Turf Prédictions IA
|
||||
Ticket: HRT-43 (suite au fix HRT-41 — métadonnées manquantes dans le cache ML)
|
||||
|
||||
Ces tests vérifient que la table ml_predictions_cache ne contient aucune ligne
|
||||
avec des métadonnées NULL (hippodrome, race_label, heure) pour la date courante,
|
||||
après le job ML de 19h30.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration
|
||||
pytest tests/test_ml_cache_integrity.py -v -m integration --date 2026-04-26
|
||||
|
||||
Variables d'environnement:
|
||||
TURF_DB_PATH : chemin vers turf.db (défaut: /home/h3r7/turf_scraper/turf.db)
|
||||
TEST_DATE : date cible au format YYYY-MM-DD (défaut: date du jour)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import pytest
|
||||
from datetime import date, datetime
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Configuration
|
||||
# ============================================================
|
||||
|
||||
DEFAULT_DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = os.environ.get("TURF_DB_PATH", DEFAULT_DB_PATH)
|
||||
|
||||
|
||||
def _get_test_date() -> str:
|
||||
"""Retourne la date cible pour les tests (env TEST_DATE ou date du jour)."""
|
||||
env_date = os.environ.get("TEST_DATE", "")
|
||||
if env_date:
|
||||
try:
|
||||
datetime.strptime(env_date, "%Y-%m-%d")
|
||||
return env_date
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"TEST_DATE invalide : '{env_date}'. Format attendu : YYYY-MM-DD"
|
||||
)
|
||||
return date.today().isoformat()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Fixture : connexion DB en lecture seule
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db_connection():
|
||||
"""
|
||||
Connexion SQLite en mode lecture seule (uri=True + ?mode=ro).
|
||||
Garantit qu'aucune modification accidentelle de la DB de prod n'est possible.
|
||||
"""
|
||||
db_path = Path(DB_PATH)
|
||||
if not db_path.exists():
|
||||
pytest.skip(
|
||||
f"Base de données introuvable : {DB_PATH}. "
|
||||
"Définir TURF_DB_PATH ou vérifier le chemin."
|
||||
)
|
||||
|
||||
uri = f"file:{db_path.as_posix()}?mode=ro"
|
||||
conn = sqlite3.connect(uri, uri=True)
|
||||
conn.row_factory = sqlite3.Row
|
||||
yield conn
|
||||
conn.close()
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def target_date():
|
||||
"""Date cible pour les tests (date du jour ou TEST_DATE)."""
|
||||
return _get_test_date()
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Tests d'intégration
|
||||
# ============================================================
|
||||
|
||||
|
||||
@pytest.mark.integration
|
||||
class TestMlCacheNullIntegrity:
|
||||
"""
|
||||
Vérifie qu'après le job ML de 19h30, la table ml_predictions_cache
|
||||
ne contient aucune métadonnée NULL pour la date courante.
|
||||
|
||||
Régression testée : HRT-41 (Fix #17 — métadonnées manquantes dans le cache ML)
|
||||
"""
|
||||
|
||||
def test_table_exists(self, db_connection):
|
||||
"""Vérifie que la table ml_predictions_cache existe dans la DB."""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT name FROM sqlite_master "
|
||||
"WHERE type='table' AND name='ml_predictions_cache'"
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
assert row is not None, (
|
||||
"La table ml_predictions_cache est introuvable dans la base de données. "
|
||||
"Vérifier que le job ML a bien créé la table."
|
||||
)
|
||||
|
||||
def test_rows_exist_for_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que des prédictions existent pour la date cible.
|
||||
|
||||
Ce test passe en skip si aucune ligne n'existe (ex: avant le job 19h30).
|
||||
Il échoue uniquement si le job a manifestement tourné mais a laissé 0 lignes.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
count = cursor.fetchone()["cnt"]
|
||||
|
||||
if count == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Ce test doit être exécuté après le job ML de 19h30."
|
||||
)
|
||||
|
||||
def test_zero_null_hippodrome_today(self, db_connection, target_date):
|
||||
"""
|
||||
CRITÈRE D'ACCEPTATION PRINCIPAL (HRT-43) :
|
||||
Vérifie que COUNT(*) WHERE date = today AND hippodrome IS NULL = 0.
|
||||
|
||||
Régression directe du bug HRT-41 : le champ hippodrome était NULL
|
||||
pour toutes les prédictions du cache ML.
|
||||
"""
|
||||
# Vérifier si des données existent avant de tester les NULLs
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND hippodrome IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"RÉGRESSION HRT-41 DÉTECTÉE : {null_count} ligne(s) avec hippodrome IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Le patch de métadonnées n'a pas été appliqué correctement."
|
||||
)
|
||||
|
||||
def test_zero_null_race_label_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND race_label IS NULL = 0.
|
||||
|
||||
Complément du test hippodrome : vérifie que le libellé de course
|
||||
est bien renseigné pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND race_label IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec race_label IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_zero_null_heure_today(self, db_connection, target_date):
|
||||
"""
|
||||
Vérifie que COUNT(*) WHERE date = today AND heure IS NULL = 0.
|
||||
|
||||
Vérifie que l'heure de course est bien renseignée pour toutes les prédictions.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache "
|
||||
"WHERE date = ? AND heure IS NULL",
|
||||
(target_date,),
|
||||
)
|
||||
null_count = cursor.fetchone()["cnt"]
|
||||
|
||||
assert null_count == 0, (
|
||||
f"ANOMALIE : {null_count} ligne(s) avec heure IS NULL "
|
||||
f"dans ml_predictions_cache pour le {target_date}. "
|
||||
"Vérifier le pipeline de patch de métadonnées."
|
||||
)
|
||||
|
||||
def test_full_metadata_coverage_today(self, db_connection, target_date):
|
||||
"""
|
||||
Test de couverture globale : aucune des trois colonnes critiques
|
||||
(hippodrome, race_label, heure) n'est NULL pour une même ligne.
|
||||
|
||||
Retourne les 5 premières lignes problématiques pour faciliter le débogage.
|
||||
"""
|
||||
cursor_total = db_connection.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(target_date,),
|
||||
)
|
||||
total = cursor_total.fetchone()["cnt"]
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
cursor = db_connection.execute(
|
||||
"SELECT id, num_reunion, num_course, horse_name, hippodrome, race_label, heure "
|
||||
"FROM ml_predictions_cache "
|
||||
"WHERE date = ? "
|
||||
"AND (hippodrome IS NULL OR race_label IS NULL OR heure IS NULL) "
|
||||
"LIMIT 5",
|
||||
(target_date,),
|
||||
)
|
||||
bad_rows = cursor.fetchall()
|
||||
|
||||
assert len(bad_rows) == 0, (
|
||||
f"ANOMALIE : {len(bad_rows)} ligne(s) avec au moins une métadonnée NULL "
|
||||
f"(hippodrome, race_label ou heure) pour le {target_date}.\n"
|
||||
"Exemples de lignes affectées :\n"
|
||||
+ "\n".join(
|
||||
f" - id={r['id']} R{r['num_reunion']}C{r['num_course']} "
|
||||
f"{r['horse_name']} | hippodrome={r['hippodrome']!r} "
|
||||
f"race_label={r['race_label']!r} heure={r['heure']!r}"
|
||||
for r in bad_rows
|
||||
)
|
||||
)
|
||||
|
||||
def test_metadata_completeness_summary(self, db_connection, target_date):
|
||||
"""
|
||||
Résumé diagnostique : affiche les statistiques de complétude des métadonnées
|
||||
pour la date cible. Toujours en mode informatif (pas de assertion stricte),
|
||||
utile pour le monitoring et les logs CI.
|
||||
"""
|
||||
cursor = db_connection.execute(
|
||||
"""
|
||||
SELECT
|
||||
COUNT(*) as total,
|
||||
SUM(CASE WHEN hippodrome IS NULL THEN 1 ELSE 0 END) as null_hippodrome,
|
||||
SUM(CASE WHEN race_label IS NULL THEN 1 ELSE 0 END) as null_race_label,
|
||||
SUM(CASE WHEN heure IS NULL THEN 1 ELSE 0 END) as null_heure,
|
||||
COUNT(DISTINCT hippodrome) as distinct_hippodromes,
|
||||
COUNT(DISTINCT race_label) as distinct_race_labels
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
""",
|
||||
(target_date,),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
total = row["total"]
|
||||
|
||||
if total == 0:
|
||||
pytest.skip(
|
||||
f"Aucune prédiction en cache pour le {target_date}. "
|
||||
"Lancer ce test après le job ML de 19h30."
|
||||
)
|
||||
|
||||
# Afficher les statistiques (visibles avec pytest -v -s)
|
||||
print(f"\n=== Statistiques ml_predictions_cache pour le {target_date} ===")
|
||||
print(f" Total lignes : {total}")
|
||||
print(f" NULL hippodrome : {row['null_hippodrome']}")
|
||||
print(f" NULL race_label : {row['null_race_label']}")
|
||||
print(f" NULL heure : {row['null_heure']}")
|
||||
print(f" Hippodromes distincts: {row['distinct_hippodromes']}")
|
||||
print(f" Race labels distincts: {row['distinct_race_labels']}")
|
||||
|
||||
# L'assertion ici reste stricte pour hippodrome (bug HRT-41 critique)
|
||||
assert row["null_hippodrome"] == 0, (
|
||||
f"RÉGRESSION HRT-41 : {row['null_hippodrome']}/{total} lignes "
|
||||
f"avec hippodrome IS NULL pour le {target_date}."
|
||||
)
|
||||
333
tests/test_ml_ensemble.py
Normal file
333
tests/test_ml_ensemble.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Tests ML Ensemble — HRT-32 Sprint 6-7
|
||||
Tests de régression, benchmark et latence pour le nouveau modèle ensemble.
|
||||
|
||||
Usage:
|
||||
pytest tests/test_ml_ensemble.py -v
|
||||
pytest tests/test_ml_ensemble.py -v -m regression
|
||||
pytest tests/test_ml_ensemble.py -v -m latency
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import pickle
|
||||
import sqlite3
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
import requests
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8790")
|
||||
DB_PATH = os.environ.get("DB_PATH", "/home/h3r7/turf_saas/turf.db")
|
||||
MODELS_DIR = Path("/home/h3r7/turf_saas/models")
|
||||
ENSEMBLE_PATH = MODELS_DIR / "ensemble_top3.pkl"
|
||||
BENCHMARK_PATH = MODELS_DIR / "benchmark_report.json"
|
||||
|
||||
|
||||
# ─── Fixtures ────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def ensemble_model():
|
||||
"""Load ensemble model (skip tests if not yet trained)."""
|
||||
if not ENSEMBLE_PATH.exists():
|
||||
pytest.skip(
|
||||
f"Ensemble model not found at {ENSEMBLE_PATH}. Run train_ensemble.py first."
|
||||
)
|
||||
with open(ENSEMBLE_PATH, "rb") as f:
|
||||
return pickle.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def benchmark_report():
|
||||
"""Load benchmark report (skip if not generated)."""
|
||||
if not BENCHMARK_PATH.exists():
|
||||
pytest.skip(f"Benchmark report not found at {BENCHMARK_PATH}.")
|
||||
with open(BENCHMARK_PATH) as f:
|
||||
return json.load(f)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def holdout_data():
|
||||
"""Load holdout slice (last 20% temporal) for regression tests."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
df = pd.read_sql_query(
|
||||
"""
|
||||
SELECT p.*, c.distance, c.discipline, c.specialite,
|
||||
c.nb_declares_partants, c.montant_prix, c.penetrometre_intitule
|
||||
FROM pmu_partants p
|
||||
LEFT JOIN pmu_courses c ON p.date_programme=c.date_programme
|
||||
AND p.num_reunion=c.num_reunion AND p.num_course=c.num_course
|
||||
WHERE p.ordre_arrivee > 0
|
||||
ORDER BY p.date_programme, p.num_reunion, p.num_course, p.num_pmu
|
||||
""",
|
||||
conn,
|
||||
)
|
||||
conn.close()
|
||||
n = len(df)
|
||||
cutoff = int(n * 0.80)
|
||||
return df.iloc[cutoff:].copy()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def predict_v2():
|
||||
"""Import predict_v2 module."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"predict_v2", "/home/h3r7/turf_saas/predict_v2.py"
|
||||
)
|
||||
mod = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(mod)
|
||||
return mod
|
||||
|
||||
|
||||
# ─── Model Existence Tests ────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestModelFiles:
|
||||
"""Verify all expected model files exist."""
|
||||
|
||||
def test_ensemble_model_exists(self):
|
||||
assert ENSEMBLE_PATH.exists(), f"Ensemble model missing: {ENSEMBLE_PATH}"
|
||||
|
||||
def test_benchmark_report_exists(self):
|
||||
assert BENCHMARK_PATH.exists(), f"Benchmark report missing: {BENCHMARK_PATH}"
|
||||
|
||||
def test_models_dir_contains_expected_files(self):
|
||||
expected = ["ensemble_top3.pkl", "benchmark_report.json", "benchmark_report.md"]
|
||||
for fname in expected:
|
||||
assert (MODELS_DIR / fname).exists(), f"Missing: {MODELS_DIR / fname}"
|
||||
|
||||
|
||||
# ─── Benchmark Tests ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBenchmark:
|
||||
"""Validate benchmark metrics from the training report."""
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_ensemble_beats_baseline_or_meets_threshold(self, benchmark_report):
|
||||
"""Ensemble Precision@3 must be >= baseline XGBoost."""
|
||||
baseline = benchmark_report["baseline"]["precision_at3"]
|
||||
ensemble = benchmark_report["ensemble"]["precision_at3"]
|
||||
assert ensemble >= baseline, (
|
||||
f"Ensemble Precision@3 {ensemble:.4f} < baseline {baseline:.4f}"
|
||||
)
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_ensemble_auc_above_random(self, benchmark_report):
|
||||
"""Ensemble AUC must be > 0.60 (significantly above random 0.50)."""
|
||||
auc = benchmark_report["ensemble"]["auc"]
|
||||
assert auc > 0.60, f"Ensemble AUC {auc:.4f} <= 0.60"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_optuna_ran_minimum_trials(self, benchmark_report):
|
||||
"""Optuna must have run at least 100 trials per model."""
|
||||
n_trials = benchmark_report["optuna"]["n_trials"]
|
||||
assert n_trials >= 100, f"Only {n_trials} Optuna trials (minimum 100 required)"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_no_precision_regression(self, benchmark_report):
|
||||
"""Ensemble Precision@3 must not be below naive random baseline (~30%)."""
|
||||
ensemble_p3 = benchmark_report["ensemble"]["precision_at3"]
|
||||
assert ensemble_p3 >= 0.30, (
|
||||
f"Precision@3 {ensemble_p3:.4f} is below random baseline (~0.30)"
|
||||
)
|
||||
|
||||
def test_benchmark_has_all_required_models(self, benchmark_report):
|
||||
"""Benchmark must include results for all 3 models."""
|
||||
required = {"xgboost", "lightgbm", "mlp"}
|
||||
found = set(benchmark_report.get("individual_models", {}).keys())
|
||||
missing = required - found
|
||||
assert not missing, f"Missing model benchmarks: {missing}"
|
||||
|
||||
|
||||
# ─── Regression Tests ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPrecisionRegression:
|
||||
"""Holdout regression: ensure precision doesn't degrade."""
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_precision_at3_on_holdout(self, ensemble_model, holdout_data):
|
||||
"""Precision@3 on holdout must be above naive baseline."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
df = holdout_data.copy()
|
||||
df["top3"] = (df["ordre_arrivee"] <= 3).astype(int)
|
||||
|
||||
partants = df.to_dict("records")
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||
|
||||
# Per-race Precision@3
|
||||
tmp = df[["date_programme", "num_reunion", "num_course"]].copy()
|
||||
tmp["proba"] = proba
|
||||
tmp["actual"] = df["top3"].values
|
||||
|
||||
precisions = []
|
||||
for _, group in tmp.groupby(["date_programme", "num_reunion", "num_course"]):
|
||||
if len(group) >= 3:
|
||||
top3_pred = group.nlargest(3, "proba")
|
||||
precisions.append(top3_pred["actual"].sum() / 3.0)
|
||||
|
||||
p_at3 = float(np.mean(precisions)) if precisions else 0.0
|
||||
print(f"\n Holdout Precision@3: {p_at3:.4f} over {len(precisions)} races")
|
||||
|
||||
# Must beat random baseline (30%)
|
||||
assert p_at3 >= 0.30, f"Holdout Precision@3 {p_at3:.4f} < 0.30"
|
||||
|
||||
@pytest.mark.regression
|
||||
def test_no_all_zero_predictions(self, ensemble_model, holdout_data):
|
||||
"""Ensemble must not predict 0 probability for all horses."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
partants = holdout_data.head(50).to_dict("records")
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
proba = ensemble_model.predict_proba(X)[:, 1]
|
||||
assert proba.max() > 0.01, "All predictions are near 0 — model appears broken"
|
||||
assert proba.std() > 0.01, (
|
||||
"All predictions have identical probability — no discrimination"
|
||||
)
|
||||
|
||||
|
||||
# ─── Latency Tests ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPredictionLatency:
|
||||
"""Prediction latency must be < 200ms per race."""
|
||||
|
||||
@pytest.mark.latency
|
||||
def test_single_race_latency(self, ensemble_model, holdout_data):
|
||||
"""Prediction for a single race (<=20 horses) must be < 200ms."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
# Take one race
|
||||
first_race = (
|
||||
holdout_data.groupby(["date_programme", "num_reunion", "num_course"])
|
||||
.first()
|
||||
.reset_index()
|
||||
.iloc[0]
|
||||
)
|
||||
mask = (
|
||||
(holdout_data["date_programme"] == first_race["date_programme"])
|
||||
& (holdout_data["num_reunion"] == first_race["num_reunion"])
|
||||
& (holdout_data["num_course"] == first_race["num_course"])
|
||||
)
|
||||
race_df = holdout_data[mask]
|
||||
partants = race_df.to_dict("records")
|
||||
|
||||
# Warm-up
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
ensemble_model.predict_proba(X)
|
||||
|
||||
# Timed run
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(10):
|
||||
ensemble_model.predict_proba(X)
|
||||
elapsed_ms = (time.perf_counter() - t0) / 10 * 1000
|
||||
|
||||
print(f"\n Single-race latency: {elapsed_ms:.2f} ms ({len(partants)} horses)")
|
||||
assert elapsed_ms < 200, (
|
||||
f"Prediction latency {elapsed_ms:.1f} ms exceeds 200 ms limit"
|
||||
)
|
||||
|
||||
@pytest.mark.latency
|
||||
def test_full_day_latency(self, ensemble_model, holdout_data):
|
||||
"""Prediction for a full day (all races) must complete < 5 seconds."""
|
||||
from predict_v2 import build_feature_df, FEATURE_COLS
|
||||
|
||||
# Take one day
|
||||
day = holdout_data["date_programme"].iloc[0]
|
||||
day_df = holdout_data[holdout_data["date_programme"] == day]
|
||||
partants = day_df.to_dict("records")
|
||||
|
||||
feature_df = build_feature_df(partants)
|
||||
available = [c for c in FEATURE_COLS if c in feature_df.columns]
|
||||
X = feature_df[available].fillna(0)
|
||||
|
||||
t0 = time.perf_counter()
|
||||
proba = ensemble_model.predict_proba(X)
|
||||
elapsed_ms = (time.perf_counter() - t0) * 1000
|
||||
|
||||
print(
|
||||
f"\n Full day latency: {elapsed_ms:.2f} ms ({len(partants)} horses, {day})"
|
||||
)
|
||||
assert elapsed_ms < 5000, (
|
||||
f"Full-day prediction {elapsed_ms:.0f} ms exceeds 5s limit"
|
||||
)
|
||||
|
||||
|
||||
# ─── API Endpoint Tests ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestV1PredictionsAPI:
|
||||
"""Tests for the new /api/v1/predictions endpoint."""
|
||||
|
||||
def _api_available(self):
|
||||
try:
|
||||
requests.get(f"{BASE_URL}/api/v1/model/status", timeout=3)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@pytest.mark.api
|
||||
def test_model_status_endpoint(self):
|
||||
"""GET /api/v1/model/status returns valid JSON."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/model/status", timeout=10)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "ensemble_available" in data
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_no_500(self):
|
||||
"""GET /api/v1/predictions must not return 5xx."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
assert resp.status_code < 500, (
|
||||
f"Server error: {resp.status_code}\n{resp.text[:200]}"
|
||||
)
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_returns_json(self):
|
||||
"""GET /api/v1/predictions returns valid JSON with expected keys."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
if resp.status_code == 503:
|
||||
pytest.skip("Ensemble model not yet deployed")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert "model_version" in data, "Missing model_version in response"
|
||||
assert "races" in data or "predictions" in data, (
|
||||
"Missing races/predictions in response"
|
||||
)
|
||||
|
||||
@pytest.mark.api
|
||||
def test_v1_predictions_latency(self):
|
||||
"""GET /api/v1/predictions must respond in < 3 seconds."""
|
||||
if not self._api_available():
|
||||
pytest.skip("API server not running")
|
||||
resp = requests.get(f"{BASE_URL}/api/v1/predictions", timeout=30)
|
||||
if resp.status_code == 503:
|
||||
pytest.skip("Ensemble model not yet deployed")
|
||||
# Check API-reported latency
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
latency = data.get("latency_ms", 0)
|
||||
assert latency < 3000, f"API latency {latency:.0f} ms > 3000 ms"
|
||||
205
tests/test_smoke.py
Normal file
205
tests/test_smoke.py
Normal file
@@ -0,0 +1,205 @@
|
||||
"""
|
||||
Tests de smoke — SaaS Turf Prédictions IA
|
||||
Sprint 8 — QA, Beta Fermee, Go/No-Go
|
||||
Ticket: HRT-34
|
||||
|
||||
Vérifications rapides sur l'état de l'application :
|
||||
- Routes de base accessibles
|
||||
- API répond en JSON valide
|
||||
- Base de données accessible
|
||||
- Pas d'erreurs 5xx sur les routes principales
|
||||
|
||||
Ces tests peuvent tourner SANS infra complète (pas besoin de HRT-31/33).
|
||||
Exécuter sur l'app actuelle en staging ou localhost.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
import os
|
||||
import json
|
||||
|
||||
BASE_URL = os.environ.get("APP_URL", "http://localhost:8792")
|
||||
|
||||
# Routes qui doivent retourner 200 (publiques)
|
||||
PUBLIC_ROUTES_200 = [
|
||||
"/",
|
||||
"/dashboard",
|
||||
]
|
||||
|
||||
# Routes API qui doivent retourner 200 ou 401 (jamais 500)
|
||||
API_ROUTES_NO_500 = [
|
||||
"/api",
|
||||
"/api/races",
|
||||
"/api/scoring",
|
||||
"/api/weather",
|
||||
"/api/odds_history",
|
||||
]
|
||||
|
||||
|
||||
class TestSmoke:
|
||||
"""Tests de smoke : l'app répond correctement aux requêtes de base."""
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.parametrize("route", PUBLIC_ROUTES_200)
|
||||
def test_route_publique_accessible(self, route):
|
||||
"""Les routes publiques doivent retourner 200."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||
assert resp.status_code in (200, 304), (
|
||||
f"Route publique inaccessible: {route} → {resp.status_code}"
|
||||
)
|
||||
assert len(resp.content) > 0, f"Réponse vide sur {route}"
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(
|
||||
f"App non accessible sur {BASE_URL} — vérifier que le serveur est démarré"
|
||||
)
|
||||
|
||||
@pytest.mark.smoke
|
||||
@pytest.mark.parametrize("route", API_ROUTES_NO_500)
|
||||
def test_api_pas_derreur_serveur(self, route):
|
||||
"""Les routes API ne doivent jamais retourner 5xx."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}{route}", timeout=10)
|
||||
assert resp.status_code < 500, (
|
||||
f"Erreur serveur sur {route}: {resp.status_code}\n{resp.text[:200]}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_api_today_retourne_json(self):
|
||||
"""L'endpoint principal /api doit retourner du JSON valide."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert data is not None, "Réponse JSON nulle"
|
||||
assert isinstance(data, (list, dict)), (
|
||||
f"Type de réponse inattendu: {type(data)}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
except json.JSONDecodeError as e:
|
||||
pytest.fail(f"/api ne retourne pas du JSON valide: {e}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_contenu_html_portail_valide(self):
|
||||
"""Le portail doit contenir un titre et du contenu significatif."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
content = resp.text
|
||||
assert "<html" in content.lower() or "<!doctype" in content.lower(), (
|
||||
"La page d'accueil ne retourne pas du HTML"
|
||||
)
|
||||
assert len(content) > 500, (
|
||||
f"Page d'accueil trop courte ({len(content)} chars)"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_headers_securite_presents(self):
|
||||
"""Les headers de sécurité de base doivent être présents."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/", timeout=10)
|
||||
if resp.status_code != 200:
|
||||
return
|
||||
|
||||
# En production (derrière Nginx), ces headers doivent être présents
|
||||
# En dev direct Flask, ils peuvent être absents — on note seulement
|
||||
security_headers = {
|
||||
"X-Content-Type-Options": "nosniff",
|
||||
"X-Frame-Options": None, # SAMEORIGIN ou DENY
|
||||
"X-XSS-Protection": None,
|
||||
}
|
||||
|
||||
missing = []
|
||||
for header, expected_value in security_headers.items():
|
||||
if header not in resp.headers:
|
||||
missing.append(header)
|
||||
|
||||
if missing:
|
||||
# Warning seulement — bloquant uniquement en prod derrière Nginx
|
||||
pytest.warns(UserWarning, match=r".*") if False else None
|
||||
print(f"⚠️ Headers sécurité manquants (requis en prod): {missing}")
|
||||
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_api_races_format_reponse(self):
|
||||
"""L'endpoint /api/races doit retourner une liste structurée."""
|
||||
try:
|
||||
resp = requests.get(f"{BASE_URL}/api/races", timeout=10)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
assert isinstance(data, (list, dict)), (
|
||||
f"Format inattendu pour /api/races: {type(data)}"
|
||||
)
|
||||
if isinstance(data, list) and len(data) > 0:
|
||||
first = data[0]
|
||||
# Vérifier la présence de champs clés
|
||||
expected_fields = ["date", "course", "hippodrome"]
|
||||
present = [
|
||||
f
|
||||
for f in expected_fields
|
||||
if f in first
|
||||
or any(k in first for k in [f, f.upper(), f.replace("_", "")])
|
||||
]
|
||||
assert len(present) > 0, (
|
||||
f"Champs attendus absents de /api/races. Champs présents: {list(first.keys())}"
|
||||
)
|
||||
except requests.exceptions.ConnectionError:
|
||||
pytest.skip(f"App non accessible sur {BASE_URL}")
|
||||
except json.JSONDecodeError:
|
||||
pytest.fail("/api/races ne retourne pas du JSON valide")
|
||||
|
||||
|
||||
class TestSmokeDatabase:
|
||||
"""Tests smoke sur la base de données."""
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_base_donnees_accessible(self):
|
||||
"""La base de données SQLite doit être accessible et contenir des données."""
|
||||
import sqlite3
|
||||
|
||||
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
if not __import__("os").path.exists(db_path):
|
||||
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
|
||||
# Vérifier que les tables essentielles existent
|
||||
c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
tables = {row[0] for row in c.fetchall()}
|
||||
conn.close()
|
||||
|
||||
expected_tables = ["predictions", "results"]
|
||||
for table in expected_tables:
|
||||
assert table in tables, (
|
||||
f"Table manquante dans la BDD: {table}. Tables présentes: {tables}"
|
||||
)
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_donnees_predictions_disponibles(self):
|
||||
"""Des prédictions doivent être présentes dans la BDD."""
|
||||
import sqlite3
|
||||
|
||||
db_path = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
if not __import__("os").path.exists(db_path):
|
||||
pytest.skip(f"Base de données non trouvée: {db_path}")
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
c = conn.cursor()
|
||||
c.execute("SELECT COUNT(*) FROM predictions")
|
||||
count = c.fetchone()[0]
|
||||
conn.close()
|
||||
|
||||
# Au moins quelques données pour que le SaaS soit utile
|
||||
assert count >= 0, "Table predictions accessible"
|
||||
if count == 0:
|
||||
print("⚠️ Aucune prédiction en base — le scraper doit être lancé")
|
||||
383
tests/test_user_tokens.py
Normal file
383
tests/test_user_tokens.py
Normal file
@@ -0,0 +1,383 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
tests/test_user_tokens.py — Personal API Token + Webhook alertes
|
||||
HRT-80: Tests unitaires et d'intégration
|
||||
|
||||
Couvre:
|
||||
- POST /api/v1/user/api-token (create)
|
||||
- DELETE /api/v1/user/api-token (revoke)
|
||||
- POST /api/v1/user/webhook (create/upsert)
|
||||
- DELETE /api/v1/user/webhook (delete)
|
||||
- Authentification via X-API-Key
|
||||
- dispatch_webhook() fire-and-forget
|
||||
- Plan enforcement Pro uniquement
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_user_tokens.py -v --tb=short
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import sqlite3
|
||||
import sys
|
||||
import tempfile
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Test DB isolation ────────────────────────────────────────────────────────
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
os.environ["JWT_SECRET_KEY"] = "test-secret-hrt80"
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
from app_v1 import create_app # noqa: E402
|
||||
|
||||
TEST_CONFIG = {
|
||||
"TESTING": True,
|
||||
"JWT_SECRET_KEY": "test-secret-hrt80",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
application = create_app()
|
||||
application.config.update(TEST_CONFIG)
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_user(client, email, plan="pro"):
|
||||
"""Register user (plan=free) then update plan in DB."""
|
||||
resp = client.post(
|
||||
"/api/v1/auth/register",
|
||||
json={"email": email, "password": "Secure123"},
|
||||
)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
user_id = resp.get_json()["user_id"]
|
||||
|
||||
# Update plan directly in DB (no plan-update endpoint in JWT auth)
|
||||
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||
conn.execute("UPDATE users SET plan = ? WHERE id = ?", (plan, user_id))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
# Login to get access token
|
||||
login_resp = client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"email": email, "password": "Secure123"},
|
||||
)
|
||||
assert login_resp.status_code == 200, login_resp.get_json()
|
||||
access_token = login_resp.get_json()["access_token"]
|
||||
return access_token, user_id
|
||||
|
||||
|
||||
def _auth_header(token):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ─── Tests: API Token (Pro) ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiToken:
|
||||
def test_create_api_token_pro(self, client):
|
||||
"""POST /api/v1/user/api-token — Pro user gets 201 + token starting with trf_"""
|
||||
token, _ = _create_user(client, "pro_token@test.com", plan="pro")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
data = resp.get_json()
|
||||
assert data["token"].startswith("trf_")
|
||||
assert data["prefix"] == data["token"][:12]
|
||||
assert "warning" in data
|
||||
assert "created_at" in data
|
||||
|
||||
def test_create_api_token_stores_hash_not_raw(self, client):
|
||||
"""Second POST returns 409 — only hashed token stored"""
|
||||
token, _ = _create_user(client, "pro_token2@test.com", plan="pro")
|
||||
# First create
|
||||
r1 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r1.status_code == 201
|
||||
raw_token = r1.get_json()["token"]
|
||||
# Second create should conflict
|
||||
r2 = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r2.status_code == 409
|
||||
data = r2.get_json()
|
||||
assert "existing_prefix" in data
|
||||
# Verify raw token is NOT stored in DB (only hash)
|
||||
conn = sqlite3.connect(os.environ["TURF_SAAS_DB"])
|
||||
row = conn.execute(
|
||||
"SELECT token_hash FROM user_api_tokens WHERE token_prefix = ?",
|
||||
(raw_token[:12],),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
assert row is not None
|
||||
assert row[0] != raw_token # hash != raw
|
||||
assert len(row[0]) == 64 # SHA256 hex
|
||||
|
||||
def test_create_api_token_free_user(self, client):
|
||||
"""Free user gets 403"""
|
||||
token, _ = _create_user(client, "free_token@test.com", plan="free")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_api_token_premium_user(self, client):
|
||||
"""Premium user gets 403 (Pro only feature)"""
|
||||
token, _ = _create_user(client, "premium_token@test.com", plan="premium")
|
||||
resp = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_api_token_no_auth(self, client):
|
||||
"""No auth → 401"""
|
||||
resp = client.post("/api/v1/user/api-token")
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_revoke_api_token(self, client):
|
||||
"""DELETE /api/v1/user/api-token — Pro user revokes active token"""
|
||||
token, _ = _create_user(client, "pro_revoke@test.com", plan="pro")
|
||||
# Create first
|
||||
client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Revoke
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["revoked"] is True
|
||||
assert data["count"] >= 1
|
||||
|
||||
def test_revoke_no_active_token(self, client):
|
||||
"""DELETE with no active token → 404"""
|
||||
token, _ = _create_user(client, "pro_notoken@test.com", plan="pro")
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revoke_non_pro(self, client):
|
||||
"""DELETE for free user → 403"""
|
||||
token, _ = _create_user(client, "free_revoke@test.com", plan="free")
|
||||
resp = client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ─── Tests: X-API-Key Authentication ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestApiKeyAuth:
|
||||
def test_api_key_auth_on_protected_route(self, client):
|
||||
"""Valid X-API-Key authenticates on protected route"""
|
||||
token, _ = _create_user(client, "apikey_auth@test.com", plan="pro")
|
||||
# Create API token
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r.status_code == 201
|
||||
raw_key = r.get_json()["token"]
|
||||
# Use X-API-Key to access a protected route (try create again → 409 means authenticated)
|
||||
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
# 409 means we were authenticated; 401 means auth failed
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_api_key_invalid(self, client):
|
||||
"""Invalid X-API-Key → 401"""
|
||||
resp = client.post(
|
||||
"/api/v1/user/api-token",
|
||||
headers={"X-API-Key": "trf_invalidkeyXXXXXXXXXXXXXXXXXX"},
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_api_key_revoked(self, client):
|
||||
"""Revoked X-API-Key → 401"""
|
||||
token, _ = _create_user(client, "revoked_apikey@test.com", plan="pro")
|
||||
# Create token
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
assert r.status_code == 201
|
||||
raw_key = r.get_json()["token"]
|
||||
# Revoke it
|
||||
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Try using revoked key
|
||||
resp = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert resp.status_code == 401
|
||||
|
||||
def test_revoke_then_cannot_auth(self, client):
|
||||
"""Full flow: create → use → revoke → X-API-Key rejected"""
|
||||
token, _ = _create_user(client, "flow_test@test.com", plan="pro")
|
||||
# Create
|
||||
r = client.post("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
raw_key = r.get_json()["token"]
|
||||
# Validate it works (409 because key exists)
|
||||
r2 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert r2.status_code == 409
|
||||
# Revoke
|
||||
client.delete("/api/v1/user/api-token", headers=_auth_header(token))
|
||||
# Try again with revoked key
|
||||
r3 = client.post("/api/v1/user/api-token", headers={"X-API-Key": raw_key})
|
||||
assert r3.status_code == 401
|
||||
|
||||
|
||||
# ─── Tests: Webhook ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestWebhook:
|
||||
def test_create_webhook_pro(self, client):
|
||||
"""POST /api/v1/user/webhook — Pro user with provided secret → 201"""
|
||||
token, _ = _create_user(client, "webhook_pro@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook", "secret": "mysecret123"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["webhook_url"] == "https://example.com/hook"
|
||||
assert data["secret"] == "mysecret123"
|
||||
|
||||
def test_create_webhook_auto_secret(self, client):
|
||||
"""POST without secret → auto-generated secret"""
|
||||
token, _ = _create_user(client, "webhook_auto@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://auto.example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert len(data["secret"]) == 64 # token_hex(32) = 64 hex chars
|
||||
|
||||
def test_create_webhook_non_pro_free(self, client):
|
||||
"""Free user → 403"""
|
||||
token, _ = _create_user(client, "webhook_free@test.com", plan="free")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_webhook_non_pro_premium(self, client):
|
||||
"""Premium user → 403"""
|
||||
token, _ = _create_user(client, "webhook_premium@test.com", plan="premium")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_create_webhook_url_not_https(self, client):
|
||||
"""HTTP URL → 400"""
|
||||
token, _ = _create_user(client, "webhook_http@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "http://example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
assert "https" in resp.get_json()["error"].lower()
|
||||
|
||||
def test_create_webhook_missing_url(self, client):
|
||||
"""Missing URL → 400"""
|
||||
token, _ = _create_user(client, "webhook_nourl@test.com", plan="pro")
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_webhook_upsert(self, client):
|
||||
"""Second POST updates URL (upsert behavior)"""
|
||||
token, _ = _create_user(client, "webhook_upsert@test.com", plan="pro")
|
||||
client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://first.example.com/hook"},
|
||||
)
|
||||
resp = client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://second.example.com/hook"},
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
assert resp.get_json()["webhook_url"] == "https://second.example.com/hook"
|
||||
|
||||
def test_delete_webhook(self, client):
|
||||
"""DELETE /api/v1/user/webhook → 200"""
|
||||
token, _ = _create_user(client, "webhook_delete@test.com", plan="pro")
|
||||
client.post(
|
||||
"/api/v1/user/webhook",
|
||||
headers=_auth_header(token),
|
||||
json={"url": "https://delete.example.com/hook"},
|
||||
)
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 200
|
||||
assert resp.get_json()["deleted"] is True
|
||||
|
||||
def test_delete_webhook_not_configured(self, client):
|
||||
"""DELETE without webhook configured → 404"""
|
||||
token, _ = _create_user(client, "webhook_notset@test.com", plan="pro")
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_webhook_non_pro(self, client):
|
||||
"""Free user DELETE → 403"""
|
||||
token, _ = _create_user(client, "webhook_freedelete@test.com", plan="free")
|
||||
resp = client.delete("/api/v1/user/webhook", headers=_auth_header(token))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
# ─── Tests: dispatch_webhook ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDispatchWebhook:
|
||||
def test_dispatch_no_webhook_configured(self):
|
||||
"""dispatch_webhook silently returns when no webhook is configured"""
|
||||
with patch("api_v1.utils_webhook.get_db") as mock_get_db:
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = None
|
||||
mock_get_db.return_value = mock_conn
|
||||
|
||||
from api_v1.utils_webhook import dispatch_webhook
|
||||
|
||||
# Should not raise, should return silently
|
||||
dispatch_webhook("nonexistent_user", "new_prediction", {"data": "test"})
|
||||
|
||||
def test_dispatch_sends_hmac_header(self):
|
||||
"""dispatch_webhook sends correct HMAC-SHA256 signature header"""
|
||||
test_secret = "testsecret"
|
||||
test_url = "https://hook.example.com/receive"
|
||||
test_payload = {"race_id": "R123", "top1": "Cheval Blanc"}
|
||||
|
||||
with (
|
||||
patch("api_v1.utils_webhook.get_db") as mock_get_db,
|
||||
patch("api_v1.utils_webhook.requests.post") as mock_post,
|
||||
):
|
||||
mock_row = MagicMock()
|
||||
mock_row.__getitem__ = lambda self, key: (
|
||||
test_url if key == "url" else test_secret
|
||||
)
|
||||
mock_conn = MagicMock()
|
||||
mock_conn.execute.return_value.fetchone.return_value = mock_row
|
||||
mock_get_db.return_value = mock_conn
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_post.return_value = mock_response
|
||||
|
||||
from api_v1.utils_webhook import dispatch_webhook, EVENT_NEW_PREDICTION
|
||||
|
||||
dispatch_webhook("user123", EVENT_NEW_PREDICTION, test_payload)
|
||||
|
||||
assert mock_post.called
|
||||
call_kwargs = mock_post.call_args
|
||||
headers_sent = call_kwargs.kwargs.get("headers") or call_kwargs[1].get(
|
||||
"headers"
|
||||
)
|
||||
assert "X-Turf-Signature" in headers_sent
|
||||
assert headers_sent["X-Turf-Signature"].startswith("sha256=")
|
||||
assert headers_sent["X-Turf-Event"] == EVENT_NEW_PREDICTION
|
||||
1007
train_ensemble.py
Normal file
1007
train_ensemble.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -193,6 +193,65 @@ def schedule_dynamic_scoring():
|
||||
logger.info("ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas de scoring dynamique")
|
||||
|
||||
|
||||
def run_telegram_alerts():
|
||||
"""Envoie les alertes Telegram pré-course aux utilisateurs Premium/Pro"""
|
||||
logger.info("📨 [SCHEDULER] Envoi alertes Telegram pré-course...")
|
||||
try:
|
||||
os.chdir("/home/h3r7/turf_saas")
|
||||
import telegram_alerts
|
||||
|
||||
stats = telegram_alerts.send_pre_race_alerts(minutes_before=30)
|
||||
logger.info(
|
||||
"✅ [SCHEDULER] Alertes Telegram: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats.get("sent", 0),
|
||||
stats.get("skipped", 0),
|
||||
stats.get("errors", 0),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error(f"❌ [SCHEDULER] Erreur alertes Telegram: {e}")
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
|
||||
|
||||
def schedule_dynamic_telegram_alerts():
|
||||
"""Planifie les alertes Telegram 30min avant la course (même pattern que schedule_dynamic_scoring)"""
|
||||
race_time = get_todays_race_time()
|
||||
|
||||
if race_time:
|
||||
try:
|
||||
# Convertir timestamp ms en datetime
|
||||
dt = datetime.fromtimestamp(race_time / 1000)
|
||||
race_hour = dt.hour
|
||||
race_min = dt.minute
|
||||
|
||||
logger.info(
|
||||
f"📅 [SCHEDULER] Alertes Telegram — course à {race_hour:02d}:{race_min:02d}"
|
||||
)
|
||||
|
||||
# Alertes 30min avant la course
|
||||
pre_min = race_min - 30
|
||||
pre_hour = race_hour
|
||||
if pre_min < 0:
|
||||
pre_min += 60
|
||||
pre_hour -= 1
|
||||
|
||||
alert_time = f"{pre_hour:02d}:{pre_min:02d}"
|
||||
schedule.every().day.at(alert_time).do(run_telegram_alerts).tag(
|
||||
"telegram", "dynamic"
|
||||
)
|
||||
logger.info(
|
||||
f"📅 [SCHEDULER] Alertes Telegram planifiées à {alert_time} (30min avant la course)"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Impossible de planifier les alertes Telegram: {e}")
|
||||
else:
|
||||
logger.info(
|
||||
"ℹ️ [SCHEDULER] Pas de course aujourd'hui, pas d'alertes Telegram dynamiques"
|
||||
)
|
||||
|
||||
|
||||
def schedule_dynamic_results():
|
||||
"""Planifie le scraping des résultats à H+1 (1h après la course)"""
|
||||
race_time = get_todays_race_time()
|
||||
@@ -245,6 +304,9 @@ def main():
|
||||
# Scoring dynamique (15min avant course)
|
||||
schedule_dynamic_scoring()
|
||||
|
||||
# Alertes Telegram dynamiques (30min avant course)
|
||||
schedule_dynamic_telegram_alerts()
|
||||
|
||||
# Résultats dynamiques (H+1)
|
||||
schedule_dynamic_results()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user