Compare commits
43 Commits
feature/ml
...
3079c2c6c6
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3079c2c6c6 | ||
|
|
52c0c95f22 | ||
|
|
0492f06bfd | ||
| 91134e2f3f | |||
|
|
663e0bb149 | ||
| 5c6b407f47 | |||
|
|
f300e44c74 | ||
|
|
946bdc65b6 | ||
|
|
bc5ee3fa1a | ||
|
|
701660ce83 | ||
| b7ed82418f | |||
|
|
8604dc78b1 | ||
|
|
30464fb40c | ||
|
|
31db3a8260 | ||
|
|
278245cd7c | ||
|
|
ec024d8236 | ||
|
|
225295030b | ||
|
|
86e85aa1c6 | ||
| 5aa6013c52 | |||
|
|
4b4323f707 | ||
|
|
356bdf5bec | ||
|
|
f9a45e6deb | ||
|
|
cfc0f038f9 | ||
|
|
c999285895 | ||
|
|
e517741c97 | ||
| 837a0845ec | |||
|
|
4bf458f1b8 | ||
|
|
099286b078 | ||
|
|
d39c7d3319 | ||
|
|
8c5fdf1e9c | ||
|
|
7f5573f076 | ||
|
|
82d6bdafba | ||
|
|
36d93697bc | ||
| 2f57719b21 | |||
| bffc06c9b1 | |||
| f1ef2648b1 | |||
|
|
ce0ee150ec | ||
|
|
41a9e36166 | ||
|
|
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
|
||||
```
|
||||
281
DOCUMENTATION.md
281
DOCUMENTATION.md
@@ -155,3 +155,284 @@ python app.py
|
||||
---
|
||||
|
||||
*Document généré automatiquement - Dépenses Trello*
|
||||
|
||||
---
|
||||
|
||||
---
|
||||
|
||||
# Turf SaaS — Documentation API v1
|
||||
|
||||
**Mise à jour** : 2026-04-30 (HRT-96 — ML Predictions + ROI + Feedback)
|
||||
**URL SaaS** : https://turf-saas-kolifee.duckdns.org
|
||||
**Port local** : 8792
|
||||
**Base de données** : `/home/h3r7/turf_saas/turf_saas.db`
|
||||
|
||||
---
|
||||
|
||||
## Stack Technique Turf SaaS
|
||||
|
||||
| Composant | Technologie |
|
||||
|---|---|
|
||||
| Backend | Python Flask + Blueprints |
|
||||
| Auth | JWT (access + refresh tokens) |
|
||||
| Base de données | SQLite (`turf_saas.db`) |
|
||||
| ML | XGBoost v1 (prédictions courses PMU) |
|
||||
| Frontend | HTML5 + Chart.js |
|
||||
| Hébergement | VPS Linux — https://turf-saas-kolifee.duckdns.org |
|
||||
|
||||
---
|
||||
|
||||
## Plans d'accès
|
||||
|
||||
| Plan | Accès |
|
||||
|---|---|
|
||||
| `free` | health, auth, courses/today, predictions/top3 (1/jour) |
|
||||
| `premium` | + predictions/all, valuebets, metrics, roi (complet), feedback/stats |
|
||||
| `pro` | + backtest, export/csv, historique illimité, orgs |
|
||||
|
||||
---
|
||||
|
||||
## Endpoints API v1
|
||||
|
||||
### Authentification
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/auth/register` | Non | Créer un compte (plan=free) |
|
||||
| POST | `/api/v1/auth/login` | Non | Login — retourne access_token + refresh_token |
|
||||
| POST | `/api/v1/auth/refresh` | Non | Renouveler l'access token |
|
||||
| POST | `/api/v1/auth/logout` | Oui | Révoquer le refresh token |
|
||||
|
||||
### Système
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/health` | Non | Healthcheck public |
|
||||
| GET | `/api/v1/docs` | Non | Swagger UI (Flasgger) |
|
||||
|
||||
### Courses
|
||||
|
||||
| Méthode | 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 |
|
||||
|
||||
`{id}` format : `{num_reunion}-{num_course}` ex: `1-3`
|
||||
Query params `courses/today` : `filter=[all|quinte|trot|plat]`, `limit`, `offset`
|
||||
|
||||
### Prédictions ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/predictions/top3` | free+ | Top 3 chevaux du jour |
|
||||
| GET | `/api/v1/predictions/all` | premium+ | Toutes les prédictions XGBoost |
|
||||
|
||||
Query params : `date=YYYY-MM-DD`, `limit`, `offset`
|
||||
|
||||
Source des données : table `ml_predictions_cache` (modèle `xgboost_v1`)
|
||||
|
||||
### Value Bets
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/valuebets` | premium+ | Value bets du jour (`is_value_bet=1`) |
|
||||
|
||||
Query params : `date`, `min_odds` (défaut 2.0), `limit`, `offset`
|
||||
|
||||
### Métriques ML
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/metrics` | premium+ | Métriques perf ML (precision, ROI, top-3 rate) |
|
||||
|
||||
Query params : `days` (int, défaut 30, max 365)
|
||||
|
||||
### ROI par Modèle/Stratégie (HRT-92)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/roi/by-model` | premium+ | ROI calculé par stratégie ML XGBoost |
|
||||
|
||||
**Query params** :
|
||||
- `strategy` : filtrer par stratégie (`xgboost_sg`, `xgboost_value`, `xgboost_sp`, `xgboost_2sur4`)
|
||||
- `days` : période en jours (défaut 30, max 365)
|
||||
|
||||
**Réponse** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Jointures** : `paris` ← `pmu_partants` (résultats) ← `pmu_rapports` (dividendes)
|
||||
|
||||
**Accès plan** : Free = 1 stratégie max, Premium/Pro = complet + historique illimité
|
||||
|
||||
### ML Feedback Loop (HRT-93)
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/ml/feedback/run` | Admin | Déclencher ml_feedback_saas.py manuellement |
|
||||
| GET | `/api/v1/ml/feedback/stats` | premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
**POST `/api/v1/ml/feedback/run`** — Corps optionnel :
|
||||
```json
|
||||
{"date": "2026-04-29"}
|
||||
```
|
||||
ou
|
||||
```json
|
||||
{"backfill": "2026-04-20"}
|
||||
```
|
||||
|
||||
**GET `/api/v1/ml/feedback/stats`** — Réponse :
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
**Stratégies XGBoost** :
|
||||
| Stratégie | Type pari | Condition | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | simple_gagnant | top1 ML, ml_score >= 70 | 1€ |
|
||||
| `xgboost_value` | simple_gagnant | is_value_bet = 1 | 1€ |
|
||||
| `xgboost_sp` | simple_place | top1 ML, ml_score >= 50 | 1€ |
|
||||
| `xgboost_2sur4` | deux_sur_quatre | top4 ML, 6 combos | 6€ |
|
||||
|
||||
### Backtest
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/backtest` | pro | Résultats historiques des paris |
|
||||
|
||||
Query params : `start`, `end` (YYYY-MM-DD), `limit`, `offset`
|
||||
|
||||
### Export
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/export/csv` | pro | Export CSV |
|
||||
|
||||
Query params : `type=[predictions|bets]`, `date`, `start`, `end`
|
||||
|
||||
### Historique
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/history` | free+ | Historique prédictions ML |
|
||||
|
||||
Limites : Free = 7j, Premium = 90j, Pro = illimité
|
||||
|
||||
### Organisations
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/org/` | pro | Détails de l'organisation |
|
||||
| POST | `/api/v1/org/` | pro | Créer une organisation |
|
||||
| POST | `/api/v1/org/invite` | pro | Inviter un membre (max 5) |
|
||||
| DELETE | `/api/v1/org/members/{id}` | pro | Retirer un membre |
|
||||
|
||||
### Utilisateur & Tokens
|
||||
|
||||
| Méthode | Path | Plan | Description |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/user/profile` | free+ | Profil utilisateur |
|
||||
| PUT | `/api/v1/user/alerts` | premium+ | Config alertes Telegram |
|
||||
| GET | `/api/v1/user/api-token` | pro | Token API personnel |
|
||||
| POST | `/api/v1/user/api-token` | pro | Générer/régénérer token API |
|
||||
| GET | `/api/v1/user/webhook` | pro | Config webhook |
|
||||
| PUT | `/api/v1/user/webhook` | pro | Modifier webhook |
|
||||
|
||||
### Billing (Stripe)
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/billing/checkout` | Oui | Créer session Stripe Checkout |
|
||||
| POST | `/api/v1/billing/portal` | Oui | Portail Stripe (gestion abonnement) |
|
||||
| GET | `/api/v1/billing/status` | Oui | Statut abonnement actuel |
|
||||
| POST | `/api/v1/billing/webhook` | Non | Webhook Stripe (events) |
|
||||
|
||||
---
|
||||
|
||||
## Format de réponse uniforme
|
||||
|
||||
**Erreurs** :
|
||||
```json
|
||||
{
|
||||
"status": "error",
|
||||
"message": "Description de l'erreur",
|
||||
"code": 400
|
||||
}
|
||||
```
|
||||
|
||||
**Listes paginées** :
|
||||
```json
|
||||
{
|
||||
"pagination": {
|
||||
"total": 150,
|
||||
"limit": 20,
|
||||
"offset": 0,
|
||||
"has_more": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Architecture ML — Résumé
|
||||
|
||||
```
|
||||
ml_predictions_cache (XGBoost v1)
|
||||
→ ml_feedback_saas.py
|
||||
→ table paris (source_reco = xgboost_*)
|
||||
→ /api/v1/roi/by-model (ROI calculé)
|
||||
→ /api/v1/ml/feedback/stats (stats)
|
||||
→ dashboard_saas.html (Section Performance & ROI)
|
||||
```
|
||||
|
||||
Voir documentation complète : `POD/Intelligence/ML_Predictions_SaaS.md`
|
||||
|
||||
---
|
||||
|
||||
## Démarrage
|
||||
|
||||
```bash
|
||||
cd /home/h3r7/turf_saas
|
||||
source venv/bin/activate
|
||||
python app_v1.py
|
||||
# ou via gunicorn
|
||||
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/ -v
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*Turf SaaS — H3R7Tech — Mise à jour 2026-04-30 (HRT-96)*
|
||||
|
||||
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"]
|
||||
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
339
POD/Intelligence/ML_Predictions_SaaS.md
Normal file
@@ -0,0 +1,339 @@
|
||||
# Note Intelligence — Système ML Prédictions dans turf_saas
|
||||
|
||||
**Date de création** : 2026-04-30
|
||||
**Auteur** : IngenieurDev (H3R7Tech)
|
||||
**Ticket de référence** : HRT-96 (sprint ML SaaS — HRT-90)
|
||||
**Scope** : `/home/h3r7/turf_saas/` — AUCUNE modification de `/home/h3r7/turf_scraper/`
|
||||
|
||||
---
|
||||
|
||||
## 1. Contexte & Décision architecturale
|
||||
|
||||
### 1.1 Deux systèmes, deux DB
|
||||
|
||||
H3R7Tech exploite deux dépôts séparés :
|
||||
|
||||
| Dépôt | Rôle | Base de données |
|
||||
|---|---|---|
|
||||
| `/home/h3r7/turf_scraper/` | Scraping PMU + entraînement XGBoost | `turf.db` |
|
||||
| `/home/h3r7/turf_saas/` | SaaS utilisateurs + API v1 + dashboard | `turf_saas.db` |
|
||||
|
||||
### 1.2 Décision de duplication (vs modification turf_scraper)
|
||||
|
||||
**Choix : dupliquer les tables et scripts ML dans turf_saas.db, sans toucher à turf_scraper.**
|
||||
|
||||
Justification :
|
||||
- `turf_scraper` est la source de vérité du scraping PMU et des modèles XGBoost — toute modification risque de casser la chaîne de collecte de données.
|
||||
- `turf_saas` doit fonctionner de manière autonome, avec ses propres utilisateurs, subscriptions et données.
|
||||
- La table `ml_predictions_cache` est *pré-peuplée* dans `turf_saas.db` par un processus de synchronisation (scheduler ou copie périodique depuis `turf.db`).
|
||||
- Le feedback loop (`ml_feedback_saas.py`) écrit dans `paris` de `turf_saas.db` uniquement.
|
||||
|
||||
---
|
||||
|
||||
## 2. Architecture du système ML dans turf_saas
|
||||
|
||||
### 2.1 Vue d'ensemble du flow
|
||||
|
||||
```
|
||||
[turf_scraper/turf.db]
|
||||
└── ml_predictions_cache (XGBoost v1)
|
||||
│
|
||||
│ [sync périodique / scheduler]
|
||||
▼
|
||||
[turf_saas/turf_saas.db]
|
||||
├── ml_predictions_cache ← prédictions XGBoost importées
|
||||
├── pmu_partants ← données courses PMU
|
||||
├── pmu_rapports ← dividendes réels PMU
|
||||
├── paris ← paris virtuels ML (ml_feedback_saas.py)
|
||||
│
|
||||
└── API v1 ──┬── GET /api/v1/predictions/* (lecture ml_predictions_cache)
|
||||
├── GET /api/v1/roi/by-model (jointure paris + rapports)
|
||||
├── POST /api/v1/ml/feedback/run (déclenche ml_feedback_saas)
|
||||
└── GET /api/v1/ml/feedback/stats (stats par stratégie)
|
||||
│
|
||||
▼
|
||||
[dashboard_saas.html]
|
||||
Section "Performance & ROI"
|
||||
Chart.js — ROI par modèle / évolution
|
||||
```
|
||||
|
||||
### 2.2 Table `ml_predictions_cache` (turf_saas.db)
|
||||
|
||||
Table centrale du système ML. Contient les prédictions XGBoost pour chaque cheval/course.
|
||||
|
||||
| Colonne | Type | Description |
|
||||
|---|---|---|
|
||||
| `date` | TEXT | Date de la course (YYYY-MM-DD) |
|
||||
| `num_reunion` | INTEGER | Numéro de réunion |
|
||||
| `num_course` | INTEGER | Numéro de course |
|
||||
| `horse_name` | TEXT | Nom du cheval |
|
||||
| `horse_number` | INTEGER | Numéro du cheval |
|
||||
| `odds` | REAL | Cote au moment de la prédiction |
|
||||
| `prob_top1` | REAL | Probabilité XGBoost de finir 1er |
|
||||
| `prob_top3` | REAL | Probabilité XGBoost de finir top 3 |
|
||||
| `ml_score` | REAL | Score ML composite (0–100) |
|
||||
| `recommendation` | TEXT | `top1` / `top3` / `value_bet` |
|
||||
| `is_value_bet` | INTEGER | 1 si value bet détecté |
|
||||
| `is_outlier` | INTEGER | 1 si outlier de cote |
|
||||
| `race_label` | TEXT | Ex: `R1C3` |
|
||||
| `model_version` | TEXT | Version du modèle (ex: `xgboost_v1`) |
|
||||
| `risque_label` | TEXT | Niveau de risque (`low`/`neutral`/`high`) |
|
||||
| `risque_score` | INTEGER | Score risque (0–100) |
|
||||
|
||||
**Contrainte d'unicité** : `(date, num_reunion, num_course, horse_name)` — garantit l'idempotence des imports.
|
||||
|
||||
**Volume actuel** : ~1 000 entrées (2 dates de courses).
|
||||
|
||||
---
|
||||
|
||||
## 3. Feedback Loop ML — `ml_feedback_saas.py`
|
||||
|
||||
### 3.1 Rôle
|
||||
|
||||
Script Python autonome qui :
|
||||
1. Lit les prédictions XGBoost dans `ml_predictions_cache` de `turf_saas.db`
|
||||
2. Génère des paris virtuels selon 4 stratégies XGBoost
|
||||
3. Insère les paris dans la table `paris` de `turf_saas.db`
|
||||
4. Est **idempotent** : ne duplique pas les paris existants
|
||||
|
||||
### 3.2 Stratégies supportées
|
||||
|
||||
| Stratégie | Type pari | Condition sélection | Mise |
|
||||
|---|---|---|---|
|
||||
| `xgboost_sg` | `simple_gagnant` | top 1 ML par course, `ml_score >= 70` | 1€ |
|
||||
| `xgboost_value` | `simple_gagnant` | `is_value_bet = 1` | 1€ |
|
||||
| `xgboost_sp` | `simple_place` | top 1 ML par course, `ml_score >= 50` | 1€ |
|
||||
| `xgboost_2sur4` | `deux_sur_quatre` | top 4 ML par course, 6 combos générés | 6€ (1€/combo) |
|
||||
|
||||
### 3.3 Schéma d'idempotence
|
||||
|
||||
```python
|
||||
# Vérifie avant insertion
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ?
|
||||
AND source_reco = ? # ex: 'xgboost_sg'
|
||||
AND type_pari = ?
|
||||
AND numero1 = ?
|
||||
AND race_label = ?
|
||||
```
|
||||
|
||||
Si le pari existe déjà → skip (aucune duplication).
|
||||
|
||||
### 3.4 Table `paris` — colonnes clés pour le ML
|
||||
|
||||
| Colonne | Valeur ML |
|
||||
|---|---|
|
||||
| `source_reco` | `xgboost_sg` / `xgboost_value` / `xgboost_sp` / `xgboost_2sur4` |
|
||||
| `model_source` | `xgboost_v1` (héritée de ml_predictions_cache) |
|
||||
| `type_pari` | `simple_gagnant` / `simple_place` / `deux_sur_quatre` |
|
||||
| `statut` | `EN_ATTENTE` → `GAGNE` / `PERDU` (mise à jour par update_paris_results.py) |
|
||||
| `gain` | Dividende réel × mise (depuis pmu_rapports) |
|
||||
|
||||
### 3.5 Usage CLI
|
||||
|
||||
```bash
|
||||
# Traitement du jour
|
||||
python3 ml_feedback_saas.py
|
||||
|
||||
# Date spécifique
|
||||
python3 ml_feedback_saas.py --date 2026-04-29
|
||||
|
||||
# Backfill
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-20
|
||||
```
|
||||
|
||||
**Différence avec `turf_scraper/ml_feedback.py`** :
|
||||
- `DB_PATH` = `/home/h3r7/turf_saas/turf_saas.db` (PAS `/home/h3r7/turf_scraper/turf.db`)
|
||||
- Logs dans `/home/h3r7/turf_saas/logs/`
|
||||
- AUCUNE référence à `turf_scraper`
|
||||
|
||||
---
|
||||
|
||||
## 4. API ROI — `/api/v1/roi/*`
|
||||
|
||||
### 4.1 Route principale
|
||||
|
||||
**`GET /api/v1/roi/by-model`** — Calcul du ROI par modèle/stratégie
|
||||
|
||||
Jointures SQL :
|
||||
|
||||
```sql
|
||||
-- paris ←→ pmu_partants (via race_label + date + numero)
|
||||
-- paris ←→ pmu_rapports (dividendes réels)
|
||||
|
||||
SELECT
|
||||
p.source_reco AS model_source,
|
||||
COUNT(p.id) AS nb_paris,
|
||||
SUM(p.mise) AS mise_totale,
|
||||
SUM(p.gain) AS gain_total,
|
||||
(SUM(p.gain) - SUM(p.mise)) / SUM(p.mise) * 100 AS roi_pct,
|
||||
COUNT(CASE WHEN p.statut='GAGNE' THEN 1 END) * 100.0 / COUNT(p.id) AS win_rate
|
||||
FROM paris p
|
||||
WHERE p.date_course BETWEEN :start AND :end
|
||||
AND (:strategy IS NULL OR p.source_reco = :strategy)
|
||||
GROUP BY p.source_reco
|
||||
```
|
||||
|
||||
**Paramètres query** :
|
||||
- `?strategy=xgboost_sg` — filtrer par stratégie (optionnel)
|
||||
- `?days=30` — fenêtre temporelle en jours (défaut : 30, max : 365)
|
||||
|
||||
**Réponse JSON** :
|
||||
```json
|
||||
{
|
||||
"period": {"start": "2026-04-01", "end": "2026-04-30", "days": 30},
|
||||
"models": [
|
||||
{
|
||||
"model_source": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"mise": 42.0,
|
||||
"gain": 51.3,
|
||||
"roi_pct": 22.1,
|
||||
"win_rate": 28.6
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**Accès plan** :
|
||||
- `free` : 1 stratégie max
|
||||
- `premium` : complet
|
||||
- `pro` : complet + historique illimité
|
||||
|
||||
### 4.2 Blueprint `api_v1/routes/roi.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.roi import roi_bp
|
||||
app.register_blueprint(roi_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. API ML Feedback — `/api/v1/ml/feedback/*`
|
||||
|
||||
### 5.1 Routes
|
||||
|
||||
| Méthode | Path | Auth | Description |
|
||||
|---|---|---|---|
|
||||
| `POST` | `/api/v1/ml/feedback/run` | Admin | Déclenche `ml_feedback_saas.py` manuellement |
|
||||
| `GET` | `/api/v1/ml/feedback/stats` | Premium+ | Stats paris par stratégie XGBoost |
|
||||
|
||||
### 5.2 `POST /api/v1/ml/feedback/run`
|
||||
|
||||
- Réservé aux admins (token admin requis)
|
||||
- Déclenche le script `ml_feedback_saas.py` en subprocess
|
||||
- Corps optionnel : `{"date": "2026-04-29"}` ou `{"backfill": "2026-04-20"}`
|
||||
|
||||
### 5.3 `GET /api/v1/ml/feedback/stats`
|
||||
|
||||
Retourne les statistiques agrégées par stratégie :
|
||||
|
||||
```json
|
||||
{
|
||||
"stats": [
|
||||
{
|
||||
"source_reco": "xgboost_sg",
|
||||
"nb_paris": 42,
|
||||
"nb_gagnes": 12,
|
||||
"win_rate_pct": 28.6,
|
||||
"mise_totale": 42.0,
|
||||
"gain_total": 51.3,
|
||||
"roi_pct": 22.1
|
||||
}
|
||||
],
|
||||
"last_run": "2026-04-29T18:30:00"
|
||||
}
|
||||
```
|
||||
|
||||
### 5.4 Blueprint `api_v1/routes/ml_feedback.py`
|
||||
|
||||
Enregistré dans `api_v1/__init__.py` avec :
|
||||
```python
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. Jointures de données — Schéma complet
|
||||
|
||||
```
|
||||
ml_predictions_cache
|
||||
date, num_reunion, num_course, horse_name, horse_number
|
||||
ml_score, recommendation, is_value_bet
|
||||
race_label, model_version
|
||||
│
|
||||
│ [ml_feedback_saas.py]
|
||||
▼
|
||||
paris
|
||||
date_course, race_label, numero1
|
||||
source_reco (= stratégie XGBoost)
|
||||
model_source (= xgboost_v1)
|
||||
type_pari, mise, statut, gain
|
||||
│
|
||||
├──── JOIN pmu_partants ──── date_programme + num_reunion + num_course + num_pmu
|
||||
│ ordre_arrivee (résultat réel)
|
||||
│
|
||||
└──── JOIN pmu_rapports ──── date_programme + num_reunion + num_course + type_pari
|
||||
dividende_euro (gain réel calculé)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Dashboard SaaS — Section ROI
|
||||
|
||||
Le dashboard `dashboard_saas.html` intègre une section **"Performance & ROI"** (implémentée dans HRT-94) :
|
||||
|
||||
- Graphique ROI par `model_source` (histogramme Chart.js)
|
||||
- Évolution ROI dans le temps (line chart, 7j/30j/90j)
|
||||
- Tableau : `model_source | nb paris | mise | gain | ROI% | win_rate%`
|
||||
- Filtre dropdown par stratégie
|
||||
- Gating plan : Free = 1 stratégie, Premium/Pro = complet
|
||||
|
||||
Appel API dashboard :
|
||||
```javascript
|
||||
fetch('/api/v1/roi/by-model?days=30')
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Points d'attention & limites
|
||||
|
||||
1. **Données ML limitées** : actuellement 1 000 prédictions sur 2 dates (2026-04-24 et 2026-04-25). La pertinence du ROI augmentera avec le volume de données.
|
||||
|
||||
2. **Pas de paris XGBoost actifs** : la table `paris` contient des paris `manual`, `scoring_v2`, `canalturf` mais pas encore de paris `xgboost_*`. HRT-93 (ml_feedback_saas.py) doit être complété et exécuté.
|
||||
|
||||
3. **Modèle unique** : `model_version = 'xgboost_v1'`. L'évolution vers des versions de modèle multiples est prévue dans la roadmap.
|
||||
|
||||
4. **Sync turf_scraper → turf_saas** : le mécanisme de synchronisation de `ml_predictions_cache` n'est pas encore documenté formellement. À documenter dans une prochaine Note Intelligence.
|
||||
|
||||
5. **update_paris_results.py** : script de mise à jour des statuts paris (`EN_ATTENTE → GAGNE/PERDU`) à partir de `pmu_rapports` — dépendance critique pour le calcul du ROI réel.
|
||||
|
||||
---
|
||||
|
||||
## 9. Fichiers clés
|
||||
|
||||
| Fichier | Rôle |
|
||||
|---|---|
|
||||
| `turf_saas.db` | Base de données principale SaaS |
|
||||
| `ml_feedback_saas.py` | Feedback loop ML (à créer — HRT-93) |
|
||||
| `api_v1/routes/roi.py` | Routes API ROI (à créer — HRT-92) |
|
||||
| `api_v1/routes/ml_feedback.py` | Routes API feedback (à créer — HRT-93) |
|
||||
| `api_v1/__init__.py` | Enregistrement des blueprints |
|
||||
| `dashboard_saas.html` | Dashboard SaaS avec section ROI |
|
||||
| `update_paris_results.py` | MAJ statuts paris depuis résultats PMU |
|
||||
| `scoring_v2.py` | Scoring engine (stratégie scoring_v2) |
|
||||
|
||||
---
|
||||
|
||||
## 10. Références tickets
|
||||
|
||||
| Ticket | Description | Statut |
|
||||
|---|---|---|
|
||||
| HRT-90 | Orchestration ML SaaS (parent) | blocked |
|
||||
| HRT-92 | Backend: API ROI par modèle | in_progress |
|
||||
| HRT-93 | ML feedback loop ml_feedback_saas | in_progress |
|
||||
| HRT-94 | Frontend: Dashboard ROI | in_progress |
|
||||
| HRT-95 | QA: Tests end-to-end ML + ROI | in_progress |
|
||||
| HRT-96 | Note Intelligence ML + documentation (ce ticket) | in_progress |
|
||||
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`
|
||||
383
account.html
Normal file
383
account.html
Normal file
@@ -0,0 +1,383 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Mon Compte — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 10px; --error: #f85149; --gold: #ffd600;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 14px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-back { color: var(--muted); font-size: .9rem; }
|
||||
.nav-back:hover { color: var(--text); }
|
||||
|
||||
main { flex: 1; padding: 40px 5%; max-width: 900px; margin: 0 auto; width: 100%; }
|
||||
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 28px; }
|
||||
|
||||
/* TABS */
|
||||
.tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); margin-bottom: 28px; }
|
||||
.tab-btn {
|
||||
padding: 10px 18px; border: none; background: transparent; color: var(--muted);
|
||||
font-size: .9rem; font-weight: 600; cursor: pointer; border-bottom: 2px solid transparent;
|
||||
transition: all .2s; margin-bottom: -1px;
|
||||
}
|
||||
.tab-btn:hover { color: var(--text); }
|
||||
.tab-btn.active { color: var(--green); border-bottom-color: var(--green); }
|
||||
.tab-panel { display: none; }
|
||||
.tab-panel.active { display: block; }
|
||||
|
||||
/* CARDS */
|
||||
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius); padding: 24px; margin-bottom: 20px; }
|
||||
.card-title { font-size: 1rem; font-weight: 700; margin-bottom: 18px; padding-bottom: 12px; border-bottom: 1px solid var(--border); }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
label { display: block; font-size: .83rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input {
|
||||
width: 100%; padding: 10px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus { border-color: var(--green); }
|
||||
input:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .88rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-danger { background: transparent; border: 1px solid var(--error); color: var(--error); }
|
||||
.btn-danger:hover { background: rgba(248,81,73,.1); }
|
||||
.btn-upgrade { background: linear-gradient(135deg, var(--gold), #ff6d00); color: #000; }
|
||||
|
||||
/* PLAN CARDS */
|
||||
.plan-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 14px; margin-bottom: 24px; }
|
||||
.plan-card { background: var(--dark3); border: 2px solid var(--border); border-radius: var(--radius); padding: 20px; position: relative; transition: border-color .2s; }
|
||||
.plan-card.current { border-color: var(--green); }
|
||||
.plan-current-badge { position: absolute; top: -10px; left: 16px; background: var(--green); color: #000; padding: 2px 10px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||
.plan-name { font-weight: 800; font-size: 1rem; margin-bottom: 6px; }
|
||||
.plan-price { font-size: 1.5rem; font-weight: 800; color: var(--green); }
|
||||
.plan-price span { font-size: .85rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-features-mini { list-style: none; margin-top: 12px; }
|
||||
.plan-features-mini li { font-size: .8rem; color: var(--muted); padding: 3px 0; }
|
||||
.plan-features-mini li::before { content: "✓ "; color: var(--green); }
|
||||
.plan-card .btn { width: 100%; justify-content: center; margin-top: 14px; font-size: .82rem; padding: 8px; }
|
||||
|
||||
/* ALERT */
|
||||
.alert { padding: 12px 16px; border-radius: 8px; font-size: .88rem; margin-bottom: 14px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-success { background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3); color: var(--green); }
|
||||
.alert-error { background: rgba(248,81,73,.1); border: 1px solid rgba(248,81,73,.3); color: var(--error); }
|
||||
|
||||
/* DANGER ZONE */
|
||||
.danger-card { border-color: rgba(248,81,73,.3); }
|
||||
|
||||
/* Toast */
|
||||
#toast { position: fixed; bottom: 24px; right: 24px; z-index: 999; padding: 12px 20px; border-radius: 10px; font-size: .88rem; font-weight: 600; transform: translateY(60px); opacity: 0; transition: all .3s; pointer-events: none; }
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.success { background: var(--green); color: #000; }
|
||||
#toast.error { background: var(--error); color: #fff; }
|
||||
|
||||
@media (max-width: 600px) { .form-row { grid-template-columns: 1fr; } main { padding: 20px 4%; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/dashboard" class="nav-back">← Retour au dashboard</a>
|
||||
</nav>
|
||||
<main>
|
||||
<h1>⚙️ Mon compte</h1>
|
||||
|
||||
<div class="tabs">
|
||||
<button class="tab-btn active" data-tab="profile">Profil</button>
|
||||
<button class="tab-btn" data-tab="security">Sécurité</button>
|
||||
<button class="tab-btn" data-tab="upgrade">Mon plan</button>
|
||||
<button class="tab-btn" data-tab="notifications">Notifications</button>
|
||||
</div>
|
||||
|
||||
<!-- PROFIL -->
|
||||
<div class="tab-panel active" id="tab-profile">
|
||||
<div class="card">
|
||||
<div class="card-title">Informations personnelles</div>
|
||||
<div class="alert alert-success" id="profile-success">Profil mis à jour avec succès.</div>
|
||||
<div class="alert alert-error" id="profile-error"></div>
|
||||
<form id="profile-form">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Prénom</label><input type="text" id="p-firstname" placeholder="Jean"></div>
|
||||
<div class="form-group"><label>Nom</label><input type="text" id="p-lastname" placeholder="Dupont"></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Adresse email</label><input type="email" id="p-email" placeholder="vous@exemple.fr"></div>
|
||||
<button type="submit" class="btn btn-primary" id="profile-btn">Enregistrer les modifications</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card">
|
||||
<div class="card-title">Informations de compte</div>
|
||||
<div class="form-group"><label>Plan actuel</label><input type="text" id="p-plan-display" disabled></div>
|
||||
<div class="form-group"><label>Membre depuis</label><input type="text" id="p-created" disabled></div>
|
||||
<div class="form-group"><label>Identifiant utilisateur</label><input type="text" id="p-id" disabled></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SECURITE -->
|
||||
<div class="tab-panel" id="tab-security">
|
||||
<div class="card">
|
||||
<div class="card-title">Changer le mot de passe</div>
|
||||
<div class="alert alert-success" id="pwd-success">Mot de passe mis à jour.</div>
|
||||
<div class="alert alert-error" id="pwd-error"></div>
|
||||
<form id="password-form">
|
||||
<div class="form-group"><label>Mot de passe actuel</label><input type="password" id="s-current-pwd" placeholder="••••••••" autocomplete="current-password" required></div>
|
||||
<div class="form-group"><label>Nouveau mot de passe</label><input type="password" id="s-new-pwd" placeholder="8 caractères minimum" autocomplete="new-password" required></div>
|
||||
<div class="form-group"><label>Confirmer le nouveau mot de passe</label><input type="password" id="s-confirm-pwd" placeholder="••••••••" autocomplete="new-password" required></div>
|
||||
<button type="submit" class="btn btn-primary" id="pwd-btn">Mettre à jour le mot de passe</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="card danger-card">
|
||||
<div class="card-title">Zone dangereuse</div>
|
||||
<p style="font-size:.9rem;color:var(--muted);margin-bottom:16px">La suppression de votre compte est irréversible. Toutes vos données seront perdues.</p>
|
||||
<button class="btn btn-danger" id="delete-account-btn">Supprimer mon compte</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- UPGRADE / PLAN -->
|
||||
<div class="tab-panel" id="tab-upgrade">
|
||||
<div class="card">
|
||||
<div class="card-title">Votre plan actuel</div>
|
||||
<div class="plan-grid">
|
||||
<div class="plan-card" id="plan-card-free">
|
||||
<div class="plan-name">Free</div>
|
||||
<div class="plan-price">0€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>1 course complète/jour</li>
|
||||
<li>Aperçu Top-3</li>
|
||||
</ul>
|
||||
<button class="btn btn-ghost" id="select-free-btn">Plan actuel</button>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-card-premium">
|
||||
<div class="plan-name">Premium ⭐</div>
|
||||
<div class="plan-price">9,90€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>Toutes les courses</li>
|
||||
<li>Alertes Telegram</li>
|
||||
<li>Value bets</li>
|
||||
<li>Historique 90j</li>
|
||||
</ul>
|
||||
<button class="btn btn-upgrade" id="select-premium-btn">Choisir Premium</button>
|
||||
</div>
|
||||
<div class="plan-card" id="plan-card-pro">
|
||||
<div class="plan-name">Pro 🚀</div>
|
||||
<div class="plan-price">24,90€ <span>/mois</span></div>
|
||||
<ul class="plan-features-mini">
|
||||
<li>Tout Premium</li>
|
||||
<li>Export CSV</li>
|
||||
<li>API REST</li>
|
||||
<li>Support prioritaire</li>
|
||||
</ul>
|
||||
<button class="btn btn-ghost" id="select-pro-btn">Choisir Pro</button>
|
||||
</div>
|
||||
</div>
|
||||
<p style="font-size:.82rem;color:var(--muted)">💳 Paiement sécurisé via Stripe (disponible dans le Sprint 5-6). Pour l'instant, contactez-nous pour activer un plan payant.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- NOTIFICATIONS -->
|
||||
<div class="tab-panel" id="tab-notifications">
|
||||
<div class="card">
|
||||
<div class="card-title">Alertes Telegram</div>
|
||||
<div class="alert alert-success" id="notif-success">Préférences enregistrées.</div>
|
||||
<form id="notif-form">
|
||||
<div class="form-group">
|
||||
<label>Chat ID Telegram</label>
|
||||
<input type="text" id="n-telegram-id" placeholder="Votre Chat ID (ex: 123456789)">
|
||||
<p style="font-size:.78rem;color:var(--muted);margin-top:5px">Envoyez /start à @TurfIABot pour obtenir votre Chat ID.</p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p style="font-size:.88rem;font-weight:600;margin-bottom:10px;color:var(--muted)">Recevoir des alertes pour :</p>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-vb" style="width:auto;accent-color:var(--green)"> Value bets identifiés
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-top1" style="width:auto;accent-color:var(--green)"> Favori IA Top-1
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="n-alert-quinte" style="width:auto;accent-color:var(--green)"> Quinté+ uniquement
|
||||
</label>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Enregistrer les préférences</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
function getToken() { return localStorage.getItem('turf_token'); }
|
||||
|
||||
async function fetchJson(url, opts = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return null; }
|
||||
return res.json().catch(() => null);
|
||||
}
|
||||
|
||||
function showToast(msg, type = 'success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg; t.className = `show ${type}`;
|
||||
setTimeout(() => t.className = '', 3500);
|
||||
}
|
||||
|
||||
function showAlert(id, show = true) {
|
||||
document.getElementById(id)?.classList.toggle('show', show);
|
||||
}
|
||||
function setAlertMsg(id, msg, show = true) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) { el.textContent = msg; el.classList.toggle('show', show); }
|
||||
}
|
||||
|
||||
// TABS
|
||||
document.querySelectorAll('.tab-btn').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
document.getElementById(`tab-${btn.dataset.tab}`)?.classList.add('active');
|
||||
});
|
||||
});
|
||||
|
||||
// URL param: ?tab=upgrade
|
||||
(function() {
|
||||
const tab = new URLSearchParams(location.search).get('tab');
|
||||
if (tab) {
|
||||
document.querySelectorAll('.tab-btn').forEach(b => b.classList.toggle('active', b.dataset.tab === tab));
|
||||
document.querySelectorAll('.tab-panel').forEach(p => p.classList.toggle('active', p.id === `tab-${tab}`));
|
||||
}
|
||||
})();
|
||||
|
||||
// Load user
|
||||
async function loadUser() {
|
||||
if (!getToken()) { location.href = '/login'; return; }
|
||||
const data = await fetchJson(`${API}/auth/me`);
|
||||
if (!data) return;
|
||||
const user = data.user || data;
|
||||
document.getElementById('p-firstname').value = user.firstname || '';
|
||||
document.getElementById('p-lastname').value = user.lastname || '';
|
||||
document.getElementById('p-email').value = user.email || '';
|
||||
document.getElementById('p-plan-display').value = { free: 'Free', premium: 'Premium', pro: 'Pro' }[user.plan] || 'Free';
|
||||
document.getElementById('p-created').value = user.created_at ? new Date(user.created_at).toLocaleDateString('fr-FR') : '—';
|
||||
document.getElementById('p-id').value = user.id || '—';
|
||||
document.getElementById('n-telegram-id').value = user.telegram_chat_id || '';
|
||||
document.getElementById('n-alert-vb').checked = user.alert_value_bets !== false;
|
||||
document.getElementById('n-alert-top1').checked = user.alert_top1 !== false;
|
||||
document.getElementById('n-alert-quinte').checked = !!user.alert_quinte_only;
|
||||
setPlanCards(user.plan || 'free');
|
||||
localStorage.setItem('turf_user', JSON.stringify(user));
|
||||
}
|
||||
|
||||
function setPlanCards(plan) {
|
||||
['free','premium','pro'].forEach(p => {
|
||||
const card = document.getElementById(`plan-card-${p}`);
|
||||
if (!card) return;
|
||||
card.classList.toggle('current', p === plan);
|
||||
const existing = card.querySelector('.plan-current-badge');
|
||||
if (p === plan && !existing) {
|
||||
const badge = document.createElement('div');
|
||||
badge.className = 'plan-current-badge'; badge.textContent = '✓ Actuel';
|
||||
card.prepend(badge);
|
||||
} else if (p !== plan && existing) existing.remove();
|
||||
});
|
||||
}
|
||||
|
||||
// Profile form
|
||||
document.getElementById('profile-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('profile-success', false); showAlert('profile-error', false);
|
||||
const btn = document.getElementById('profile-btn');
|
||||
btn.disabled = true; btn.textContent = 'Enregistrement…';
|
||||
const res = await fetchJson(`${API}/auth/update-profile`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
firstname: document.getElementById('p-firstname').value.trim(),
|
||||
lastname: document.getElementById('p-lastname').value.trim(),
|
||||
email: document.getElementById('p-email').value.trim()
|
||||
})
|
||||
});
|
||||
btn.disabled = false; btn.textContent = 'Enregistrer les modifications';
|
||||
if (res && res.ok !== false) { showAlert('profile-success'); showToast('Profil mis à jour.'); }
|
||||
else setAlertMsg('profile-error', res?.error || 'Erreur lors de la mise à jour.');
|
||||
});
|
||||
|
||||
// Password form
|
||||
document.getElementById('password-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('pwd-success', false); showAlert('pwd-error', false);
|
||||
const np = document.getElementById('s-new-pwd').value;
|
||||
const cp = document.getElementById('s-confirm-pwd').value;
|
||||
if (np !== cp) { setAlertMsg('pwd-error', 'Les mots de passe ne correspondent pas.'); return; }
|
||||
if (np.length < 8) { setAlertMsg('pwd-error', 'Minimum 8 caractères.'); return; }
|
||||
const btn = document.getElementById('pwd-btn');
|
||||
btn.disabled = true; btn.textContent = 'Mise à jour…';
|
||||
const res = await fetchJson(`${API}/auth/change-password`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ current_password: document.getElementById('s-current-pwd').value, new_password: np })
|
||||
});
|
||||
btn.disabled = false; btn.textContent = 'Mettre à jour le mot de passe';
|
||||
if (res && res.ok !== false) { showAlert('pwd-success'); showToast('Mot de passe mis à jour.'); document.getElementById('password-form').reset(); }
|
||||
else setAlertMsg('pwd-error', res?.error || 'Mot de passe actuel incorrect.');
|
||||
});
|
||||
|
||||
// Notifications form
|
||||
document.getElementById('notif-form').addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
showAlert('notif-success', false);
|
||||
const res = await fetchJson(`${API}/auth/update-preferences`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
telegram_chat_id: document.getElementById('n-telegram-id').value.trim(),
|
||||
alert_value_bets: document.getElementById('n-alert-vb').checked,
|
||||
alert_top1: document.getElementById('n-alert-top1').checked,
|
||||
alert_quinte_only: document.getElementById('n-alert-quinte').checked
|
||||
})
|
||||
});
|
||||
if (res && res.ok !== false) { showAlert('notif-success'); showToast('Préférences enregistrées.'); }
|
||||
else showToast('Erreur lors de la sauvegarde.', 'error');
|
||||
});
|
||||
|
||||
// Plan selection (placeholder until Stripe Sprint 5-6)
|
||||
['free','premium','pro'].forEach(p => {
|
||||
document.getElementById(`select-${p}-btn`)?.addEventListener('click', () => {
|
||||
if (p === 'free') {
|
||||
showToast('Vous êtes déjà sur le plan Free.', 'info');
|
||||
} else {
|
||||
showToast('Paiement Stripe disponible dans le Sprint 5-6. Contactez-nous pour activer ce plan.', 'info');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Delete account
|
||||
document.getElementById('delete-account-btn').addEventListener('click', () => {
|
||||
if (confirm('⚠️ Êtes-vous sûr de vouloir supprimer votre compte ? Cette action est irréversible.')) {
|
||||
if (confirm('Confirmez la suppression définitive de votre compte Turf IA.')) {
|
||||
fetchJson(`${API}/auth/delete-account`, { method: 'DELETE' }).then(() => {
|
||||
localStorage.clear();
|
||||
location.href = '/';
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
loadUser();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
48
alembic.ini
Normal file
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.")
|
||||
63
api_v1/__init__.py
Normal file
63
api_v1/__init__.py
Normal file
@@ -0,0 +1,63 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
API v1 Blueprint package — Turf SaaS
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
Sprint 5-6: HRT-31 — Billing Stripe
|
||||
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||
HRT-80: API Token personnel + Webhook alertes (Pro)
|
||||
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||
|
||||
Registers sub-blueprints:
|
||||
/api/v1/health — public health-check
|
||||
/api/v1/courses/ — courses du jour
|
||||
/api/v1/predictions/— predictions ML
|
||||
/api/v1/valuebets — value bets (premium+)
|
||||
/api/v1/backtest — backtest historique (pro)
|
||||
/api/v1/export/ — export CSV (pro)
|
||||
/api/v1/metrics — métriques perf ML (premium+)
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||
/api/v1/user/api-token — Personal API token (Pro)
|
||||
/api/v1/user/webhook — Webhook config (Pro)
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
/api/v1/ml/feedback/run — trigger feedback loop ML (admin)
|
||||
/api/v1/ml/feedback/stats — stats par stratégie (premium+)
|
||||
"""
|
||||
|
||||
from flask import Blueprint
|
||||
|
||||
from .routes.health import health_bp
|
||||
from .routes.courses import courses_bp
|
||||
from .routes.predictions import predictions_bp
|
||||
from .routes.valuebets import valuebets_bp
|
||||
from .routes.backtest import backtest_bp
|
||||
from .routes.export import export_bp
|
||||
from .routes.metrics import metrics_bp
|
||||
from .routes.billing import billing_bp
|
||||
from .routes.user import user_bp
|
||||
from .routes.user_tokens import user_tokens_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
from .routes.ml_feedback import ml_feedback_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
def register_api_v1(app):
|
||||
"""Register all API v1 blueprints onto the Flask app."""
|
||||
app.register_blueprint(health_bp)
|
||||
app.register_blueprint(courses_bp)
|
||||
app.register_blueprint(predictions_bp)
|
||||
app.register_blueprint(valuebets_bp)
|
||||
app.register_blueprint(backtest_bp)
|
||||
app.register_blueprint(export_bp)
|
||||
app.register_blueprint(metrics_bp)
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(user_tokens_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
app.register_blueprint(ml_feedback_bp)
|
||||
0
api_v1/routes/__init__.py
Normal file
0
api_v1/routes/__init__.py
Normal file
195
api_v1/routes/backtest.py
Normal file
195
api_v1/routes/backtest.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Backtest route for API v1.
|
||||
|
||||
GET /api/v1/backtest — Résultats backtest historiques (pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
backtest_bp = Blueprint("v1_backtest", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@backtest_bp.route("/backtest", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def backtest():
|
||||
"""
|
||||
Backtest historique
|
||||
---
|
||||
tags:
|
||||
- Backtest
|
||||
summary: Résultats backtest historiques des paris simulés — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD), défaut = -30j
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD), défaut = aujourd'hui
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Résultats backtest
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
|
||||
# Validate date formats
|
||||
for label, val in [("start", start), ("end", end)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
if not start:
|
||||
start = (datetime.now() - timedelta(days=30)).strftime("%Y-%m-%d")
|
||||
if not end:
|
||||
end = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": 0,
|
||||
"message": "Aucune donnée bet_results",
|
||||
},
|
||||
"by_type": {},
|
||||
"details": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Summary
|
||||
summary_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
|
||||
total_bets = summary_row["total"] or 0
|
||||
gagne = summary_row["gagne"] or 0
|
||||
mise = float(summary_row["mise"] or 0)
|
||||
gain = float(summary_row["gain"] or 0)
|
||||
roi = round((gain - mise) / mise * 100, 1) if mise > 0 else 0.0
|
||||
precision = round(gagne / total_bets * 100, 1) if total_bets > 0 else 0.0
|
||||
|
||||
# By type
|
||||
by_type_rows = conn.execute(
|
||||
"""SELECT
|
||||
type_pari,
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
GROUP BY type_pari""",
|
||||
(start, end),
|
||||
).fetchall()
|
||||
|
||||
by_type = {}
|
||||
for row in by_type_rows:
|
||||
t = row["total"] or 0
|
||||
g = row["gagne"] or 0
|
||||
m = float(row["mise"] or 0)
|
||||
gn = float(row["gain"] or 0)
|
||||
by_type[row["type_pari"]] = {
|
||||
"count": t,
|
||||
"gagne": g,
|
||||
"mise": round(m, 2),
|
||||
"gain": round(gn, 2),
|
||||
"roi": round((gn - m) / m * 100, 1) if m > 0 else 0.0,
|
||||
"precision": round(g / t * 100, 1) if t > 0 else 0.0,
|
||||
}
|
||||
|
||||
# Paginated details
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM bet_results WHERE date BETWEEN ? AND ?",
|
||||
(start, end),
|
||||
).fetchone()
|
||||
detail_total = count_row["cnt"] if count_row else 0
|
||||
|
||||
detail_rows = conn.execute(
|
||||
"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC, id DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(start, end, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
details = [dict(r) for r in detail_rows]
|
||||
pagination = paginate_query(details, detail_total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start, "end": end},
|
||||
"summary": {
|
||||
"total_bets": total_bets,
|
||||
"gagne": gagne,
|
||||
"perdu": total_bets - gagne,
|
||||
"precision": precision,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
"roi": roi,
|
||||
},
|
||||
"by_type": by_type,
|
||||
"details": details,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
664
api_v1/routes/billing.py
Normal file
664
api_v1/routes/billing.py
Normal file
@@ -0,0 +1,664 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Billing Blueprint — Stripe integration
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/billing/checkout — create Stripe Checkout session (auth required)
|
||||
POST /api/v1/billing/portal — create Stripe Customer Portal session (auth required)
|
||||
POST /api/v1/billing/webhook — Stripe webhook handler (public, signature-verified)
|
||||
GET /api/v1/billing/status — current subscription status (auth required)
|
||||
|
||||
Environment variables required:
|
||||
STRIPE_SECRET_KEY — Stripe secret key (sk_live_... or sk_test_...)
|
||||
STRIPE_PUBLISHABLE_KEY — Stripe publishable key (pk_...)
|
||||
STRIPE_WEBHOOK_SECRET — webhook signing secret (whsec_...)
|
||||
STRIPE_PRICE_PREMIUM — Stripe Price ID for Premium plan (price_...)
|
||||
STRIPE_PRICE_PRO — Stripe Price ID for Pro plan (price_...)
|
||||
APP_BASE_URL — e.g. https://turf-ia.h3r7.tech (default http://localhost:8793)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import stripe
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from billing_db import get_db, migrate_billing_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.billing")
|
||||
|
||||
billing_bp = Blueprint("billing", __name__, url_prefix="/api/v1/billing")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Stripe configuration
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
stripe.api_key = os.environ.get("STRIPE_SECRET_KEY", "")
|
||||
STRIPE_WEBHOOK_SECRET = os.environ.get("STRIPE_WEBHOOK_SECRET", "")
|
||||
STRIPE_PUBLISHABLE_KEY = os.environ.get("STRIPE_PUBLISHABLE_KEY", "")
|
||||
APP_BASE_URL = os.environ.get("APP_BASE_URL", "http://localhost:8793")
|
||||
|
||||
# Plan → Stripe Price ID mapping
|
||||
PLAN_PRICE_IDS = {
|
||||
"premium": os.environ.get("STRIPE_PRICE_PREMIUM", ""),
|
||||
"pro": os.environ.get("STRIPE_PRICE_PRO", ""),
|
||||
}
|
||||
|
||||
# Plan display names
|
||||
PLAN_NAMES = {
|
||||
"free": "Free",
|
||||
"premium": "Premium",
|
||||
"pro": "Pro",
|
||||
}
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DB helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _sget(obj, key, default=None):
|
||||
"""Safely get a value from a dict OR a Stripe StripeObject.
|
||||
|
||||
Stripe v7+ uses attribute-style access; plain dicts use [] / .get().
|
||||
"""
|
||||
try:
|
||||
# StripeObject supports [] but not .get(); dict supports both
|
||||
val = obj[key]
|
||||
return val if val is not None else default
|
||||
except (KeyError, TypeError):
|
||||
return default
|
||||
|
||||
|
||||
def _get_active_subscription(db, user_id):
|
||||
"""Return the most recent active subscription row for a user."""
|
||||
return db.execute(
|
||||
"""SELECT * FROM saas_subscriptions
|
||||
WHERE user_id = ?
|
||||
ORDER BY start_date DESC
|
||||
LIMIT 1""",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id, **fields):
|
||||
"""
|
||||
Update existing subscription or insert a new one.
|
||||
fields: plan, stripe_customer_id, stripe_subscription_id,
|
||||
status, current_period_end, grace_period_end, end_date
|
||||
"""
|
||||
existing = _get_active_subscription(db, user_id)
|
||||
if existing:
|
||||
# Build SET clause dynamically from provided fields
|
||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [existing["id"]]
|
||||
db.execute(f"UPDATE saas_subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [str(user_id)] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO saas_subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
|
||||
|
||||
def _update_user_plan(db, user_id, plan: str):
|
||||
"""Sync saas_users.plan field to match active subscription."""
|
||||
db.execute("UPDATE saas_users SET plan = ? WHERE id = ?", (plan, str(user_id)))
|
||||
|
||||
|
||||
def _get_or_create_stripe_customer(user, db) -> str:
|
||||
"""Return existing stripe_customer_id or create a new Stripe Customer."""
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
if sub and sub["stripe_customer_id"]:
|
||||
return sub["stripe_customer_id"]
|
||||
|
||||
# Create new customer in Stripe
|
||||
customer = stripe.Customer.create(
|
||||
email=user["email"],
|
||||
metadata={"user_id": str(user["id"])},
|
||||
)
|
||||
return customer["id"]
|
||||
|
||||
|
||||
def _record_billing_event(
|
||||
db, stripe_event_id: str, event_type: str, user_id=None, payload=None
|
||||
):
|
||||
"""Insert a billing_events audit row (idempotent on stripe_event_id)."""
|
||||
try:
|
||||
db.execute(
|
||||
"""INSERT OR IGNORE INTO billing_events
|
||||
(stripe_event_id, event_type, user_id, payload)
|
||||
VALUES (?, ?, ?, ?)""",
|
||||
(
|
||||
stripe_event_id,
|
||||
event_type,
|
||||
user_id,
|
||||
json.dumps(payload) if payload else None,
|
||||
),
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning("Could not record billing event %s: %s", stripe_event_id, e)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/checkout
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/checkout", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_checkout():
|
||||
"""
|
||||
Create a Stripe Checkout session for upgrading to Premium or Pro.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [plan]
|
||||
properties:
|
||||
plan:
|
||||
type: string
|
||||
enum: [premium, pro]
|
||||
responses:
|
||||
200:
|
||||
description: Checkout session URL
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
checkout_url:
|
||||
type: string
|
||||
session_id:
|
||||
type: string
|
||||
400:
|
||||
description: Invalid plan or Stripe not configured
|
||||
503:
|
||||
description: Stripe API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
plan = body.get("plan", "").lower()
|
||||
|
||||
if plan not in ("premium", "pro"):
|
||||
return jsonify({"error": "Plan invalide. Choisir 'premium' ou 'pro'"}), 400
|
||||
|
||||
price_id = PLAN_PRICE_IDS.get(plan)
|
||||
if not price_id:
|
||||
return jsonify({"error": f"Prix Stripe non configuré pour le plan {plan}"}), 503
|
||||
|
||||
user = request.current_user
|
||||
if user["plan"] == plan:
|
||||
return jsonify({"error": f"Vous êtes déjà sur le plan {plan}"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
customer_id = _get_or_create_stripe_customer(user, db)
|
||||
# Persist customer_id early to prevent duplicates
|
||||
_upsert_subscription(
|
||||
db, user["id"], stripe_customer_id=customer_id, plan=user["plan"]
|
||||
)
|
||||
db.commit()
|
||||
|
||||
session = stripe.checkout.Session.create(
|
||||
customer=customer_id,
|
||||
payment_method_types=["card"],
|
||||
line_items=[{"price": price_id, "quantity": 1}],
|
||||
mode="subscription",
|
||||
success_url=f"{APP_BASE_URL}/billing/success?session_id={{CHECKOUT_SESSION_ID}}",
|
||||
cancel_url=f"{APP_BASE_URL}/billing/cancel",
|
||||
metadata={"user_id": str(user["id"]), "plan": plan},
|
||||
subscription_data={"metadata": {"user_id": str(user["id"]), "plan": plan}},
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe checkout error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"checkout_url": session.url,
|
||||
"session_id": session.id,
|
||||
"plan": plan,
|
||||
"publishable_key": STRIPE_PUBLISHABLE_KEY,
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/portal
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/portal", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def create_portal():
|
||||
"""
|
||||
Create a Stripe Customer Portal session for managing subscription.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Portal session URL
|
||||
400:
|
||||
description: No Stripe customer found
|
||||
503:
|
||||
description: Stripe not configured or API error
|
||||
"""
|
||||
if not stripe.api_key:
|
||||
return jsonify({"error": "Stripe non configuré"}), 503
|
||||
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
customer_id = sub["stripe_customer_id"] if sub else None
|
||||
|
||||
if not customer_id:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Aucun abonnement Stripe trouvé. "
|
||||
"Souscrivez d'abord à un plan payant."
|
||||
}
|
||||
), 400
|
||||
|
||||
session = stripe.billing_portal.Session.create(
|
||||
customer=customer_id,
|
||||
return_url=f"{APP_BASE_URL}/account",
|
||||
)
|
||||
except stripe.StripeError as e:
|
||||
logger.error("Stripe portal error for user %s: %s", user["id"], e)
|
||||
return jsonify({"error": "Erreur Stripe", "detail": str(e)}), 503
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"portal_url": session.url}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/billing/status
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/status", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def billing_status():
|
||||
"""
|
||||
Return current subscription status for the authenticated user.
|
||||
---
|
||||
tags:
|
||||
- Billing
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Subscription status
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
sub = _get_active_subscription(db, user["id"])
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
if not sub:
|
||||
return jsonify(
|
||||
{
|
||||
"plan": "free",
|
||||
"status": "active",
|
||||
"stripe_customer_id": None,
|
||||
"stripe_subscription_id": None,
|
||||
"current_period_end": None,
|
||||
"grace_period_end": None,
|
||||
}
|
||||
), 200
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"plan": sub["plan"],
|
||||
"status": sub["status"] or "active",
|
||||
"stripe_customer_id": sub["stripe_customer_id"],
|
||||
"stripe_subscription_id": sub["stripe_subscription_id"],
|
||||
"start_date": sub["start_date"],
|
||||
"end_date": sub["end_date"],
|
||||
"current_period_end": sub["current_period_end"],
|
||||
"grace_period_end": sub["grace_period_end"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/billing/webhook
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@billing_bp.route("/webhook", methods=["POST"])
|
||||
def stripe_webhook():
|
||||
"""
|
||||
Stripe webhook handler — no auth, signature-verified.
|
||||
|
||||
Handled events:
|
||||
checkout.session.completed → activate subscription
|
||||
customer.subscription.updated → sync plan/status
|
||||
customer.subscription.deleted → downgrade to free
|
||||
invoice.payment_failed → set past_due + 3-day grace period
|
||||
invoice.payment_succeeded → clear grace period
|
||||
"""
|
||||
payload = request.get_data()
|
||||
sig_header = request.headers.get("Stripe-Signature", "")
|
||||
|
||||
# Verify webhook signature (required in production)
|
||||
if STRIPE_WEBHOOK_SECRET:
|
||||
try:
|
||||
event = stripe.Webhook.construct_event(
|
||||
payload, sig_header, STRIPE_WEBHOOK_SECRET
|
||||
)
|
||||
except stripe.SignatureVerificationError as e:
|
||||
logger.warning("Stripe webhook signature invalid: %s", e)
|
||||
return jsonify({"error": "Signature invalide"}), 400
|
||||
except ValueError as e:
|
||||
logger.warning("Stripe webhook payload invalid: %s", e)
|
||||
return jsonify({"error": "Payload invalide"}), 400
|
||||
else:
|
||||
# Dev/test: accept without verification (log a warning)
|
||||
logger.warning("STRIPE_WEBHOOK_SECRET not set — skipping signature check!")
|
||||
try:
|
||||
event = stripe.Event.construct_from(json.loads(payload), stripe.api_key)
|
||||
except Exception as e:
|
||||
return jsonify({"error": "Payload invalide", "detail": str(e)}), 400
|
||||
|
||||
event_type = event["type"]
|
||||
event_id = event["id"]
|
||||
logger.info("Stripe webhook received: %s (%s)", event_type, event_id)
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
if event_type == "checkout.session.completed":
|
||||
_handle_checkout_completed(db, event)
|
||||
|
||||
elif event_type in (
|
||||
"customer.subscription.updated",
|
||||
"customer.subscription.created",
|
||||
):
|
||||
_handle_subscription_updated(db, event)
|
||||
|
||||
elif event_type == "customer.subscription.deleted":
|
||||
_handle_subscription_deleted(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_failed":
|
||||
_handle_payment_failed(db, event)
|
||||
|
||||
elif event_type == "invoice.payment_succeeded":
|
||||
_handle_payment_succeeded(db, event)
|
||||
|
||||
else:
|
||||
logger.debug("Unhandled Stripe event type: %s", event_type)
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("Error processing Stripe webhook %s: %s", event_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
return jsonify({"status": "ok"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Webhook handlers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _resolve_user_from_customer(db, customer_id: str):
|
||||
"""Look up user_id via subscriptions.stripe_customer_id."""
|
||||
row = db.execute(
|
||||
"SELECT user_id FROM saas_subscriptions WHERE stripe_customer_id = ? LIMIT 1",
|
||||
(customer_id,),
|
||||
).fetchone()
|
||||
if row:
|
||||
return row["user_id"]
|
||||
|
||||
# Fallback: query Stripe for user_id metadata
|
||||
try:
|
||||
customer = stripe.Customer.retrieve(customer_id)
|
||||
meta = _sget(customer, "metadata") or {}
|
||||
uid = _sget(meta, "user_id")
|
||||
if uid:
|
||||
return int(uid)
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_plan_from_price(price_id: str) -> str:
|
||||
"""Map Stripe price ID to internal plan name."""
|
||||
for plan, pid in PLAN_PRICE_IDS.items():
|
||||
if pid and pid == price_id:
|
||||
return plan
|
||||
# Unknown price — default to premium (safer than pro)
|
||||
return "premium"
|
||||
|
||||
|
||||
def _handle_checkout_completed(db, event):
|
||||
"""checkout.session.completed → activate subscription for the user."""
|
||||
session = event["data"]["object"]
|
||||
customer_id = _sget(session, "customer")
|
||||
subscription_id = _sget(session, "subscription")
|
||||
metadata = _sget(session, "metadata") or {}
|
||||
plan = _sget(metadata, "plan") or "premium"
|
||||
user_id = _sget(metadata, "user_id")
|
||||
|
||||
if user_id:
|
||||
user_id = str(user_id)
|
||||
else:
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"checkout.session.completed: cannot resolve user for customer %s",
|
||||
customer_id,
|
||||
)
|
||||
return
|
||||
|
||||
# Fetch subscription details from Stripe
|
||||
current_period_end = None
|
||||
if subscription_id:
|
||||
try:
|
||||
sub = stripe.Subscription.retrieve(subscription_id)
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
sub["current_period_end"], tz=timezone.utc
|
||||
).isoformat()
|
||||
# Sync plan from price if metadata plan is missing
|
||||
if sub["items"]["data"]:
|
||||
price_id = sub["items"]["data"][0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
except Exception as e:
|
||||
logger.warning("Could not fetch subscription %s: %s", subscription_id, e)
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status="active",
|
||||
current_period_end=current_period_end,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("checkout.session.completed: user %s upgraded to %s", user_id, plan)
|
||||
|
||||
|
||||
def _handle_subscription_updated(db, event):
|
||||
"""customer.subscription.updated → sync status and plan."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
subscription_id = _sget(sub_obj, "id")
|
||||
stripe_status = _sget(sub_obj, "status") or "active"
|
||||
current_period_end = None
|
||||
|
||||
cpe = _sget(sub_obj, "current_period_end")
|
||||
if cpe:
|
||||
current_period_end = datetime.fromtimestamp(cpe, tz=timezone.utc).isoformat()
|
||||
|
||||
# Resolve plan from price
|
||||
plan = "premium"
|
||||
items_data = _sget(_sget(sub_obj, "items") or {}, "data")
|
||||
if items_data:
|
||||
price_id = items_data[0]["price"]["id"]
|
||||
plan = _resolve_plan_from_price(price_id)
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
# Try metadata
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.updated: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan=plan,
|
||||
stripe_customer_id=customer_id,
|
||||
stripe_subscription_id=subscription_id,
|
||||
status=stripe_status,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_update_user_plan(db, user_id, plan)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info(
|
||||
"subscription.updated: user %s plan=%s status=%s", user_id, plan, stripe_status
|
||||
)
|
||||
|
||||
|
||||
def _handle_subscription_deleted(db, event):
|
||||
"""customer.subscription.deleted → downgrade to free."""
|
||||
sub_obj = event["data"]["object"]
|
||||
customer_id = _sget(sub_obj, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
meta = _sget(sub_obj, "metadata") or {}
|
||||
meta_uid = _sget(meta, "user_id")
|
||||
if meta_uid:
|
||||
user_id = str(meta_uid)
|
||||
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"subscription.deleted: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
plan="free",
|
||||
stripe_subscription_id=None,
|
||||
status="canceled",
|
||||
end_date=datetime.now(timezone.utc).isoformat(),
|
||||
current_period_end=None,
|
||||
grace_period_end=None,
|
||||
)
|
||||
_update_user_plan(db, user_id, "free")
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("subscription.deleted: user %s downgraded to free", user_id)
|
||||
|
||||
|
||||
def _handle_payment_failed(db, event):
|
||||
"""invoice.payment_failed → mark past_due + 3-day grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
subscription_id = _sget(invoice, "subscription")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
logger.error(
|
||||
"invoice.payment_failed: cannot resolve user for customer %s", customer_id
|
||||
)
|
||||
return
|
||||
|
||||
grace_end = (datetime.now(timezone.utc) + timedelta(days=3)).isoformat()
|
||||
|
||||
_upsert_subscription(db, user_id, status="past_due", grace_period_end=grace_end)
|
||||
_record_billing_event(
|
||||
db,
|
||||
event["id"],
|
||||
event["type"],
|
||||
user_id=user_id,
|
||||
payload={"subscription_id": subscription_id},
|
||||
)
|
||||
|
||||
# TODO: send notification email via /api/notifications
|
||||
logger.warning(
|
||||
"invoice.payment_failed: user %s past_due, grace period until %s",
|
||||
user_id,
|
||||
grace_end,
|
||||
)
|
||||
|
||||
|
||||
def _handle_payment_succeeded(db, event):
|
||||
"""invoice.payment_succeeded → clear past_due / grace period."""
|
||||
invoice = event["data"]["object"]
|
||||
customer_id = _sget(invoice, "customer")
|
||||
|
||||
user_id = _resolve_user_from_customer(db, customer_id)
|
||||
if not user_id:
|
||||
return
|
||||
|
||||
# Refresh subscription period end
|
||||
current_period_end = None
|
||||
lines = _sget(invoice, "lines") or {}
|
||||
lines_data = _sget(lines, "data") or []
|
||||
if lines_data:
|
||||
period = lines_data[0].get("period") or {}
|
||||
period_end = (
|
||||
period.get("end") if isinstance(period, dict) else _sget(period, "end")
|
||||
)
|
||||
if period_end:
|
||||
current_period_end = datetime.fromtimestamp(
|
||||
period_end, tz=timezone.utc
|
||||
).isoformat()
|
||||
|
||||
_upsert_subscription(
|
||||
db,
|
||||
user_id,
|
||||
status="active",
|
||||
grace_period_end=None,
|
||||
current_period_end=current_period_end,
|
||||
)
|
||||
_record_billing_event(db, event["id"], event["type"], user_id=user_id)
|
||||
logger.info("invoice.payment_succeeded: user %s payment cleared", user_id)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import: ensure DB migration ran
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_billing_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("billing_db migration skipped (test env?): %s", _e)
|
||||
277
api_v1/routes/courses.py
Normal file
277
api_v1/routes/courses.py
Normal file
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Courses routes for API v1.
|
||||
|
||||
GET /api/v1/courses/today — liste des courses du jour (public, paginated)
|
||||
GET /api/v1/courses/{id}/predictions — prédictions ML pour une course (free tier, 1/day limit)
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
error_response,
|
||||
bad_request,
|
||||
not_found,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, free_daily_limit_check
|
||||
|
||||
courses_bp = Blueprint("v1_courses", __name__, url_prefix="/api/v1/courses")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/today
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/today", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def courses_today():
|
||||
"""
|
||||
Courses du jour
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Liste toutes les courses du jour avec info course
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: filter
|
||||
in: query
|
||||
type: string
|
||||
enum: [all, quinte, trot, plat]
|
||||
default: all
|
||||
description: Filtre par type de course
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Liste des courses du jour
|
||||
401:
|
||||
description: Token manquant ou invalide
|
||||
"""
|
||||
race_filter = request.args.get("filter", "all").lower()
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=200)
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Build SQL condition
|
||||
if race_filter == "quinte":
|
||||
cond = "AND (c.libelle LIKE '%Quinté%' OR c.libelle LIKE '%Quinte%')"
|
||||
elif race_filter == "trot":
|
||||
cond = "AND c.discipline LIKE '%Trot%'"
|
||||
elif race_filter == "plat":
|
||||
cond = "AND c.discipline LIKE '%Plat%'"
|
||||
else:
|
||||
cond = ""
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Graceful handling if pmu_courses table doesn't exist yet
|
||||
if not table_exists(conn, "pmu_courses"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": [],
|
||||
"pagination": {
|
||||
"total": 0,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": False,
|
||||
},
|
||||
}
|
||||
), 200
|
||||
|
||||
# Count total
|
||||
count_row = conn.execute(
|
||||
f"""SELECT COUNT(*) as cnt
|
||||
FROM pmu_courses c
|
||||
WHERE c.date_programme = ? {cond}""",
|
||||
(today,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT
|
||||
c.date_programme,
|
||||
c.num_reunion,
|
||||
c.num_course,
|
||||
c.libelle,
|
||||
c.discipline,
|
||||
c.distance,
|
||||
c.hippodrome,
|
||||
c.px_type,
|
||||
COUNT(p.id_cheval) as nb_partants
|
||||
FROM pmu_courses c
|
||||
LEFT JOIN pmu_partants p
|
||||
ON p.date_programme = c.date_programme
|
||||
AND p.num_reunion = c.num_reunion
|
||||
AND p.num_course = c.num_course
|
||||
WHERE c.date_programme = ? {cond}
|
||||
GROUP BY c.date_programme, c.num_reunion, c.num_course
|
||||
ORDER BY c.num_reunion ASC, c.num_course ASC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(today, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
courses = []
|
||||
for r in rows:
|
||||
course_id = f"{r['num_reunion']}-{r['num_course']}"
|
||||
courses.append(
|
||||
{
|
||||
"id": course_id,
|
||||
"date": r["date_programme"],
|
||||
"num_reunion": r["num_reunion"],
|
||||
"num_course": r["num_course"],
|
||||
"libelle": r["libelle"],
|
||||
"discipline": r["discipline"],
|
||||
"distance": r["distance"],
|
||||
"hippodrome": r["hippodrome"],
|
||||
"type_pari": r["px_type"],
|
||||
"nb_partants": r["nb_partants"],
|
||||
}
|
||||
)
|
||||
|
||||
pagination = paginate_query(courses, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": today,
|
||||
"filter": race_filter,
|
||||
"courses": courses,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/courses/<course_id>/predictions
|
||||
# course_id format: "{num_reunion}-{num_course}" e.g. "1-3"
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@courses_bp.route("/<course_id>/predictions", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def course_predictions(course_id):
|
||||
"""
|
||||
Prédictions pour une course
|
||||
---
|
||||
tags:
|
||||
- Courses
|
||||
summary: Prédictions ML pour une course identifiée par {num_reunion}-{num_course}
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: course_id
|
||||
in: path
|
||||
type: string
|
||||
required: true
|
||||
description: Identifiant de la course (format num_reunion-num_course, ex "1-3")
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de la course (YYYY-MM-DD), défaut = aujourd'hui
|
||||
responses:
|
||||
200:
|
||||
description: Prédictions ML pour la course
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
404:
|
||||
description: Course introuvable
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
# Parse course_id
|
||||
parts = course_id.split("-")
|
||||
if len(parts) != 2:
|
||||
return bad_request(
|
||||
"course_id doit être au format {num_reunion}-{num_course}, ex: 1-3"
|
||||
)
|
||||
|
||||
try:
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except ValueError:
|
||||
return bad_request("num_reunion et num_course doivent être des entiers")
|
||||
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# Fetch course info
|
||||
course_row = conn.execute(
|
||||
"""SELECT libelle, discipline, distance, hippodrome, px_type
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchone()
|
||||
|
||||
if not course_row:
|
||||
return not_found(
|
||||
f"Course R{num_reunion}C{num_course} introuvable pour le {date_param}"
|
||||
)
|
||||
|
||||
# Fetch ML predictions from cache
|
||||
preds = []
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
preds = conn.execute(
|
||||
"""SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
# Fetch partants
|
||||
partants = conn.execute(
|
||||
"""SELECT nom, num_pmu, cote_direct, cote_reference, tendance_cote, favoris,
|
||||
tx_victoire, tx_place, forme_recente, driver, entraineur, musique
|
||||
FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY num_pmu ASC""",
|
||||
(date_param, num_reunion, num_course),
|
||||
).fetchall()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"course": {
|
||||
"id": course_id,
|
||||
"libelle": course_row["libelle"],
|
||||
"discipline": course_row["discipline"],
|
||||
"distance": course_row["distance"],
|
||||
"hippodrome": course_row["hippodrome"],
|
||||
"type_pari": course_row["px_type"],
|
||||
},
|
||||
"predictions": [dict(p) for p in preds],
|
||||
"partants": [dict(p) for p in partants],
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
185
api_v1/routes/export.py
Normal file
185
api_v1/routes/export.py
Normal file
@@ -0,0 +1,185 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Export route for API v1.
|
||||
|
||||
GET /api/v1/export/csv — Export CSV des prédictions ou paris (pro)
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, Response
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
export_bp = Blueprint("v1_export", __name__, url_prefix="/api/v1/export")
|
||||
|
||||
# Maximum rows exportable in one request
|
||||
EXPORT_MAX_ROWS = 5000
|
||||
|
||||
|
||||
@export_bp.route("/csv", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def export_csv():
|
||||
"""
|
||||
Export CSV
|
||||
---
|
||||
tags:
|
||||
- Export
|
||||
summary: Export CSV des prédictions ML ou des paris historiques — accès pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: type
|
||||
in: query
|
||||
type: string
|
||||
enum: [predictions, bets]
|
||||
default: predictions
|
||||
description: Type de données à exporter
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début (YYYY-MM-DD)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin (YYYY-MM-DD)
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date unique (YYYY-MM-DD), ignoré si start/end fournis
|
||||
responses:
|
||||
200:
|
||||
description: Fichier CSV
|
||||
content:
|
||||
text/csv:
|
||||
schema:
|
||||
type: string
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (pro requis)
|
||||
"""
|
||||
export_type = request.args.get("type", "predictions").lower()
|
||||
if export_type not in ("predictions", "bets"):
|
||||
return bad_request(
|
||||
"Paramètre 'type' invalide. Valeurs acceptées: predictions, bets"
|
||||
)
|
||||
|
||||
start = request.args.get("start")
|
||||
end = request.args.get("end")
|
||||
date = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
for label, val in [("start", start), ("end", end), ("date", date)]:
|
||||
if val:
|
||||
try:
|
||||
datetime.strptime(val, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(
|
||||
f"Paramètre '{label}' invalide, format attendu: YYYY-MM-DD"
|
||||
)
|
||||
|
||||
# Build date range
|
||||
if start and end:
|
||||
date_cond = "date BETWEEN ? AND ?"
|
||||
date_params = [start, end]
|
||||
elif start:
|
||||
date_cond = "date >= ?"
|
||||
date_params = [start]
|
||||
else:
|
||||
date_cond = "date = ?"
|
||||
date_params = [date]
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
output = io.StringIO()
|
||||
|
||||
if export_type == "predictions":
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return bad_request("Table ml_predictions_cache introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label
|
||||
FROM ml_predictions_cache
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_label",
|
||||
"hippodrome",
|
||||
"discipline",
|
||||
"distance",
|
||||
"heure",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"odds",
|
||||
"prob_top1",
|
||||
"prob_top3",
|
||||
"ml_score",
|
||||
"recommendation",
|
||||
"is_value_bet",
|
||||
"risque_label",
|
||||
]
|
||||
|
||||
else: # bets
|
||||
if not table_exists(conn, "bet_results"):
|
||||
return bad_request("Table bet_results introuvable")
|
||||
|
||||
rows = conn.execute(
|
||||
f"""SELECT date, race_name, type_pari, horse_name, horse_number,
|
||||
COALESCE(cote, 0) AS cote, mise, resultat, gain
|
||||
FROM bet_results
|
||||
WHERE {date_cond}
|
||||
ORDER BY date DESC
|
||||
LIMIT {EXPORT_MAX_ROWS}""",
|
||||
date_params,
|
||||
).fetchall()
|
||||
|
||||
fieldnames = [
|
||||
"date",
|
||||
"race_name",
|
||||
"type_pari",
|
||||
"horse_name",
|
||||
"horse_number",
|
||||
"cote",
|
||||
"mise",
|
||||
"resultat",
|
||||
"gain",
|
||||
]
|
||||
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for row in rows:
|
||||
writer.writerow(dict(row))
|
||||
|
||||
filename = f"turf_{export_type}_{date_params[0]}.csv"
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
status=200,
|
||||
mimetype="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
44
api_v1/routes/health.py
Normal file
44
api_v1/routes/health.py
Normal file
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
GET /api/v1/health — public healthcheck endpoint.
|
||||
No authentication required.
|
||||
"""
|
||||
|
||||
from flask import Blueprint, jsonify
|
||||
from datetime import datetime, timezone
|
||||
|
||||
health_bp = Blueprint("v1_health", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@health_bp.route("/health", methods=["GET"])
|
||||
def health():
|
||||
"""
|
||||
Health check
|
||||
---
|
||||
tags:
|
||||
- System
|
||||
summary: Public healthcheck — returns API status and timestamp
|
||||
responses:
|
||||
200:
|
||||
description: API is healthy
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
status:
|
||||
type: string
|
||||
example: ok
|
||||
version:
|
||||
type: string
|
||||
example: "1.0"
|
||||
timestamp:
|
||||
type: string
|
||||
format: date-time
|
||||
"""
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"version": "1.0",
|
||||
"api": "Turf SaaS API v1",
|
||||
"timestamp": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
), 200
|
||||
212
api_v1/routes/history.py
Normal file
212
api_v1/routes/history.py
Normal file
@@ -0,0 +1,212 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
History routes for API v1.
|
||||
|
||||
GET /api/v1/history — Historique des prédictions avec filtre date range,
|
||||
limité selon le plan (Free: 7j, Premium: 90j, Pro: illimité)
|
||||
|
||||
Ticket: HRT-81 — Historique limité/illimité selon plan (Free/Premium/Pro)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
forbidden,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware
|
||||
|
||||
history_bp = Blueprint("v1_history", __name__, url_prefix="/api/v1/history")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Plan limits (days of history accessible; None = unlimited)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
HISTORY_DAYS = {
|
||||
"free": 7,
|
||||
"premium": 90,
|
||||
"pro": None, # illimité
|
||||
}
|
||||
|
||||
# Fallback for unknown plans: treat like free
|
||||
_DEFAULT_LIMIT = 7
|
||||
|
||||
|
||||
def _get_plan_max_days(plan: str):
|
||||
"""Return the max history days allowed for the given plan, or default."""
|
||||
return HISTORY_DAYS.get(plan, _DEFAULT_LIMIT)
|
||||
|
||||
|
||||
def _parse_date(date_str: str, param_name: str):
|
||||
"""Parse YYYY-MM-DD date string, raise ValueError with context on failure."""
|
||||
try:
|
||||
return datetime.strptime(date_str, "%Y-%m-%d").date()
|
||||
except ValueError:
|
||||
raise ValueError(
|
||||
f"Paramètre '{param_name}' invalide : format attendu YYYY-MM-DD, reçu '{date_str}'"
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/history
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@history_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
def get_history():
|
||||
"""
|
||||
Historique des prédictions ML avec filtre date range
|
||||
---
|
||||
tags:
|
||||
- Historique
|
||||
summary: |
|
||||
Historique des prédictions sur une plage de dates.
|
||||
Limite selon le plan :
|
||||
- Free : 7 derniers jours
|
||||
- Premium : 90 derniers jours
|
||||
- Pro : illimité
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: start
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de début au format YYYY-MM-DD (défaut : aujourd'hui - max_days du plan)
|
||||
- name: end
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date de fin au format YYYY-MM-DD (défaut : aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 50
|
||||
description: Nombre de résultats par page (max 500)
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Historique des prédictions ML
|
||||
400:
|
||||
description: Paramètre de date invalide
|
||||
401:
|
||||
description: Token invalide ou manquant
|
||||
403:
|
||||
description: Plage de dates hors limite du plan — upgrade requis
|
||||
"""
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().date()
|
||||
max_days = _get_plan_max_days(plan)
|
||||
|
||||
# ── Parse end date ────────────────────────────────────────
|
||||
end_str = request.args.get("end", today.isoformat())
|
||||
try:
|
||||
end_date = _parse_date(end_str, "end")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Parse start date ─────────────────────────────────────
|
||||
if max_days is not None:
|
||||
default_start = today - timedelta(days=max_days - 1)
|
||||
else:
|
||||
# Pro: default to 30 days back when no start provided
|
||||
default_start = today - timedelta(days=29)
|
||||
|
||||
start_str = request.args.get("start", default_start.isoformat())
|
||||
try:
|
||||
start_date = _parse_date(start_str, "start")
|
||||
except ValueError as exc:
|
||||
return bad_request(str(exc))
|
||||
|
||||
# ── Validate ordering ─────────────────────────────────────
|
||||
if start_date > end_date:
|
||||
return bad_request(
|
||||
f"'start' ({start_str}) ne peut pas être postérieur à 'end' ({end_str})"
|
||||
)
|
||||
|
||||
# ── Enforce plan window ───────────────────────────────────
|
||||
if max_days is not None:
|
||||
earliest_allowed = today - timedelta(days=max_days - 1)
|
||||
if start_date < earliest_allowed:
|
||||
return forbidden(
|
||||
message=(
|
||||
f"Historique limité à {max_days} jours pour le plan '{plan}'. "
|
||||
f"Date de début minimale autorisée : {earliest_allowed.isoformat()}. "
|
||||
f"Passez à un plan supérieur pour accéder à un historique plus long."
|
||||
),
|
||||
required_plans=["premium", "pro"] if plan == "free" else ["pro"],
|
||||
current_plan=plan,
|
||||
)
|
||||
|
||||
# ── Pagination ────────────────────────────────────────────
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
# ── Query ─────────────────────────────────────────────────
|
||||
conn = get_db()
|
||||
try:
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": [],
|
||||
**paginate_query([], 0, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?""",
|
||||
(start_date.isoformat(), end_date.isoformat()),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
sql = """
|
||||
SELECT
|
||||
id, date, horse_name, prob_top1, prob_top3,
|
||||
ml_score, race_label, hippodrome, heure, is_value_bet
|
||||
FROM ml_predictions_cache
|
||||
WHERE date >= ? AND date <= ?
|
||||
ORDER BY date DESC, ml_score DESC
|
||||
LIMIT ? OFFSET ?
|
||||
"""
|
||||
rows = conn.execute(
|
||||
sql,
|
||||
(start_date.isoformat(), end_date.isoformat(), limit, offset),
|
||||
).fetchall()
|
||||
|
||||
history = [dict(r) for r in rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"plan": plan,
|
||||
"history_limit_days": max_days,
|
||||
"start": start_date.isoformat(),
|
||||
"end": end_date.isoformat(),
|
||||
"history": history,
|
||||
**paginate_query(history, total, limit, offset),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
144
api_v1/routes/metrics.py
Normal file
144
api_v1/routes/metrics.py
Normal file
@@ -0,0 +1,144 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Metrics route for API v1.
|
||||
|
||||
GET /api/v1/metrics — Métriques performances ML (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
bad_request,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
metrics_bp = Blueprint("v1_metrics", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@metrics_bp.route("/metrics", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def metrics():
|
||||
"""
|
||||
Métriques ML
|
||||
---
|
||||
tags:
|
||||
- Métriques
|
||||
summary: Métriques de performance du modèle ML (precision, ROI, top-3 rate) — premium+
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: days
|
||||
in: query
|
||||
type: integer
|
||||
default: 30
|
||||
description: Nombre de jours à analyser (max 365)
|
||||
responses:
|
||||
200:
|
||||
description: Métriques de performance ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
try:
|
||||
days = int(request.args.get("days", 30))
|
||||
except (ValueError, TypeError):
|
||||
return bad_request("Paramètre 'days' doit être un entier")
|
||||
|
||||
days = max(1, min(days, 365))
|
||||
end_date = datetime.now().strftime("%Y-%m-%d")
|
||||
start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
# ── Bet-level metrics from bet_results ──
|
||||
bet_metrics = {
|
||||
"available": False,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
}
|
||||
ml_metrics = {"available": False}
|
||||
daily_stats = []
|
||||
|
||||
if table_exists(conn, "bet_results"):
|
||||
row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(CASE WHEN resultat='GAGNE' THEN 1 ELSE 0 END) AS gagne,
|
||||
SUM(mise) AS mise,
|
||||
SUM(gain) AS gain
|
||||
FROM bet_results
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
total = row["total"] or 0
|
||||
gagne = row["gagne"] or 0
|
||||
mise = float(row["mise"] or 0)
|
||||
gain = float(row["gain"] or 0)
|
||||
|
||||
bet_metrics = {
|
||||
"available": True,
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"total_bets": total,
|
||||
"precision_pct": round(gagne / total * 100, 2) if total > 0 else 0.0,
|
||||
"roi_pct": round((gain - mise) / mise * 100, 2) if mise > 0 else 0.0,
|
||||
"mise_totale": round(mise, 2),
|
||||
"gain_total": round(gain, 2),
|
||||
}
|
||||
|
||||
# ── ML predictions cache metrics ──
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
cache_row = conn.execute(
|
||||
"""SELECT
|
||||
COUNT(*) AS total,
|
||||
SUM(is_value_bet) AS value_bets,
|
||||
AVG(prob_top1) AS avg_prob_top1,
|
||||
AVG(prob_top3) AS avg_prob_top3,
|
||||
AVG(ml_score) AS avg_ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date BETWEEN ? AND ?""",
|
||||
(start_date, end_date),
|
||||
).fetchone()
|
||||
|
||||
if cache_row and cache_row["total"]:
|
||||
ml_metrics = {
|
||||
"available": True,
|
||||
"total_predictions": cache_row["total"],
|
||||
"value_bets": cache_row["value_bets"] or 0,
|
||||
"avg_prob_top1": round(float(cache_row["avg_prob_top1"] or 0), 4),
|
||||
"avg_prob_top3": round(float(cache_row["avg_prob_top3"] or 0), 4),
|
||||
"avg_ml_score": round(float(cache_row["avg_ml_score"] or 0), 4),
|
||||
}
|
||||
|
||||
# ── Daily breakdown ──
|
||||
if table_exists(conn, "daily_stats"):
|
||||
daily_rows = conn.execute(
|
||||
"""SELECT date, total_bets, bets_gagne, precision_pct, roi_pct,
|
||||
mise_totale, gain_total
|
||||
FROM daily_stats
|
||||
WHERE date BETWEEN ? AND ?
|
||||
ORDER BY date DESC
|
||||
LIMIT 60""",
|
||||
(start_date, end_date),
|
||||
).fetchall()
|
||||
daily_stats = [dict(r) for r in daily_rows]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"period": {"start": start_date, "end": end_date, "days": days},
|
||||
"bet_metrics": bet_metrics,
|
||||
"ml_metrics": ml_metrics,
|
||||
"daily": daily_stats,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
191
api_v1/routes/ml_feedback.py
Normal file
191
api_v1/routes/ml_feedback.py
Normal file
@@ -0,0 +1,191 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback.py — API routes pour le feedback loop ML (turf_saas).
|
||||
|
||||
Routes:
|
||||
POST /api/v1/ml/feedback/run — Déclenche le feedback loop (admin uniquement)
|
||||
GET /api/v1/ml/feedback/stats — Stats performances par stratégie
|
||||
|
||||
Sécurité admin : token via variable d'environnement ML_ADMIN_TOKEN
|
||||
ou plan "pro" en fallback pour les stats.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
from flask import Blueprint, jsonify, request, g
|
||||
|
||||
# Ajoute le répertoire parent de api_v1 dans le path pour importer ml_feedback_saas
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(__file__))))
|
||||
|
||||
from api_v1.utils import get_db, internal_error, bad_request
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
ml_feedback_bp = Blueprint("v1_ml_feedback", __name__, url_prefix="/api/v1/ml/feedback")
|
||||
|
||||
# Token admin interne — configurable via variable d'environnement
|
||||
ML_ADMIN_TOKEN = os.environ.get("ML_ADMIN_TOKEN", "")
|
||||
|
||||
|
||||
def _check_admin(req):
|
||||
"""Vérifie le token admin via header X-Admin-Token ou Authorization Bearer (plan pro)."""
|
||||
# 1. Token interne (scheduler/cron)
|
||||
admin_token = req.headers.get("X-Admin-Token", "").strip()
|
||||
if ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN:
|
||||
return True, None
|
||||
|
||||
# 2. Pas de token admin configuré → autoriser les utilisateurs "pro" authentifiés
|
||||
user = getattr(g, "current_user", None)
|
||||
if user and user.get("plan") == "pro":
|
||||
return True, None
|
||||
|
||||
return False, jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/run", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
def feedback_run():
|
||||
"""
|
||||
Déclenche le feedback loop ML pour une date donnée.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Déclenche le feedback loop XGBoost (admin only)
|
||||
security:
|
||||
- Bearer: []
|
||||
- AdminToken: []
|
||||
parameters:
|
||||
- name: body
|
||||
in: body
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
date:
|
||||
type: string
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
example: "2026-04-25"
|
||||
mode:
|
||||
type: string
|
||||
description: "run (défaut) ou backfill"
|
||||
enum: [run, backfill]
|
||||
example: run
|
||||
responses:
|
||||
200:
|
||||
description: Feedback loop exécuté avec succès
|
||||
400:
|
||||
description: Paramètre invalide
|
||||
403:
|
||||
description: Accès refusé
|
||||
500:
|
||||
description: Erreur interne
|
||||
"""
|
||||
# Vérification admin
|
||||
user = getattr(g, "current_user", None)
|
||||
admin_token = request.headers.get("X-Admin-Token", "").strip()
|
||||
is_admin = (ML_ADMIN_TOKEN and admin_token == ML_ADMIN_TOKEN) or (
|
||||
user and user.get("plan") == "pro"
|
||||
)
|
||||
if not is_admin:
|
||||
return jsonify({"error": "Accès admin requis", "code": 403}), 403
|
||||
|
||||
body = request.get_json(silent=True) or {}
|
||||
date_str = body.get("date") or datetime.now().strftime("%Y-%m-%d")
|
||||
mode = body.get("mode", "run")
|
||||
|
||||
# Validation date
|
||||
try:
|
||||
datetime.strptime(date_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"Date invalide : {date_str}. Format attendu : YYYY-MM-DD")
|
||||
|
||||
if mode not in ("run", "backfill"):
|
||||
return bad_request("mode doit être 'run' ou 'backfill'")
|
||||
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
if mode == "backfill":
|
||||
inseres, maj = ml_feedback_saas.backfill(date_str)
|
||||
total_inseres = inseres
|
||||
else:
|
||||
result = ml_feedback_saas.run(date_str)
|
||||
total_inseres = sum(result["inseres"].values())
|
||||
maj = result["maj"]
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_str,
|
||||
"mode": mode,
|
||||
"paris_inseres": total_inseres,
|
||||
"paris_mis_a_jour": maj,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
|
||||
|
||||
@ml_feedback_bp.route("/stats", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def feedback_stats():
|
||||
"""
|
||||
Stats performances ML par stratégie.
|
||||
---
|
||||
tags:
|
||||
- ML Feedback
|
||||
summary: Stats paris ML par stratégie (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date_debut
|
||||
in: query
|
||||
type: string
|
||||
description: Date de début YYYY-MM-DD
|
||||
- name: date_fin
|
||||
in: query
|
||||
type: string
|
||||
description: Date de fin YYYY-MM-DD
|
||||
responses:
|
||||
200:
|
||||
description: Stats par stratégie
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_debut = request.args.get("date_debut")
|
||||
date_fin = request.args.get("date_fin")
|
||||
|
||||
# Validation optionnelle des dates
|
||||
for d_str, label in [(date_debut, "date_debut"), (date_fin, "date_fin")]:
|
||||
if d_str:
|
||||
try:
|
||||
datetime.strptime(d_str, "%Y-%m-%d")
|
||||
except ValueError:
|
||||
return bad_request(f"{label} invalide : {d_str}. Format : YYYY-MM-DD")
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
import ml_feedback_saas
|
||||
|
||||
stats = ml_feedback_saas.get_feedback_stats(conn, date_debut, date_fin)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"strategies": stats,
|
||||
"filters": {
|
||||
"date_debut": date_debut,
|
||||
"date_fin": date_fin,
|
||||
},
|
||||
"total_strategies": len(stats),
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org Blueprint — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||
GET /api/v1/org — infos org courante
|
||||
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||
GET /api/v1/org/members — liste des membres
|
||||
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||
|
||||
Plan enforcement:
|
||||
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from org_db import get_db, migrate_org_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.org")
|
||||
|
||||
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||
|
||||
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Decorator: plan Pro requis
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_pro(fn):
|
||||
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(request, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user.get("plan") != "pro":
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": "pro",
|
||||
"current_plan": user.get("plan", "free"),
|
||||
"upgrade_url": "/api/v1/billing/checkout",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers DB
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org_by_owner(db, owner_id: str):
|
||||
return db.execute(
|
||||
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _get_org_by_id(db, org_id: str):
|
||||
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||
|
||||
|
||||
def _get_member_org(db, user_id: str):
|
||||
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||
row = db.execute(
|
||||
"""SELECT o.* FROM organizations o
|
||||
JOIN org_members m ON m.org_id = o.id
|
||||
WHERE m.user_id = ?
|
||||
LIMIT 1""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def _count_org_members(db, org_id: str) -> int:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _get_user_by_email(db, email: str):
|
||||
"""Lookup dans saas_users par email."""
|
||||
return db.execute(
|
||||
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _org_to_dict(org) -> dict:
|
||||
return {
|
||||
"id": org["id"],
|
||||
"owner_id": org["owner_id"],
|
||||
"name": org["name"],
|
||||
"max_members": org["max_members"],
|
||||
"created_at": org["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _member_to_dict(m) -> dict:
|
||||
return {
|
||||
"id": m["id"],
|
||||
"org_id": m["org_id"],
|
||||
"user_id": m["user_id"],
|
||||
"role": m["role"],
|
||||
"invited_at": m["invited_at"],
|
||||
"joined_at": m["joined_at"],
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org — créer une organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def create_org():
|
||||
"""
|
||||
Crée une organisation.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Nom de l'organisation (1-100 caractères)
|
||||
responses:
|
||||
201:
|
||||
description: Organisation créée
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
409:
|
||||
description: L'utilisateur possède déjà une organisation
|
||||
"""
|
||||
user = request.current_user
|
||||
owner_id = user["id"]
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name or len(name) > 100:
|
||||
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# 1 org max par owner
|
||||
existing = _get_org_by_owner(db, owner_id)
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Vous possédez déjà une organisation",
|
||||
"org_id": existing["id"],
|
||||
}
|
||||
), 409
|
||||
|
||||
org_id = secrets.token_hex(16)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||
)
|
||||
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'owner', ?, ?)",
|
||||
(org_id, owner_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
org = _get_org_by_id(db, org_id)
|
||||
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||
return jsonify({"org": _org_to_dict(org)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("create_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org — infos org courante
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def get_org():
|
||||
"""
|
||||
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Infos de l'organisation
|
||||
404:
|
||||
description: Aucune organisation trouvée
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
member_count = _count_org_members(db, org["id"])
|
||||
result = _org_to_dict(org)
|
||||
result["member_count"] = member_count
|
||||
return jsonify({"org": result}), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org — supprimer l'organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def delete_org():
|
||||
"""
|
||||
Supprime l'organisation (owner uniquement).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Organisation supprimée
|
||||
403:
|
||||
description: Seul l'owner peut supprimer l'organisation
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||
db.commit()
|
||||
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("delete_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org/invite — inviter un membre par email
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/invite", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def invite_member():
|
||||
"""
|
||||
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||
Limite : 5 membres totaux (owner inclus).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: Email de l'utilisateur à inviter
|
||||
responses:
|
||||
201:
|
||||
description: Membre ajouté
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||
404:
|
||||
description: Utilisateur introuvable ou organisation inexistante
|
||||
409:
|
||||
description: L'utilisateur est déjà membre
|
||||
"""
|
||||
user = request.current_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# Vérifier que l'appelant est bien owner d'une org
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# Vérifier la limite de membres
|
||||
current_count = _count_org_members(db, org["id"])
|
||||
if current_count >= org["max_members"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||
"current_count": current_count,
|
||||
}
|
||||
), 403
|
||||
|
||||
# Résoudre l'utilisateur cible
|
||||
target_user = _get_user_by_email(db, email)
|
||||
if not target_user:
|
||||
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||
|
||||
target_id = target_user["id"]
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||
existing_member = db.execute(
|
||||
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
if existing_member:
|
||||
return jsonify(
|
||||
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||
), 409
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'member', ?, ?)",
|
||||
(org["id"], target_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
member_row = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
logger.info(
|
||||
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("invite_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org/members — liste des membres
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def list_members():
|
||||
"""
|
||||
Liste les membres de l'organisation courante.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Liste des membres
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
members = db.execute(
|
||||
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||
"FROM org_members m "
|
||||
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||
"WHERE m.org_id = ? "
|
||||
"ORDER BY m.invited_at ASC",
|
||||
(org["id"],),
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
d = _member_to_dict(m)
|
||||
d["email"] = m["email"]
|
||||
d["firstname"] = m["firstname"] or ""
|
||||
d["lastname"] = m["lastname"] or ""
|
||||
result.append(d)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": org["id"],
|
||||
"members": result,
|
||||
"count": len(result),
|
||||
"max_members": org["max_members"],
|
||||
}
|
||||
), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def remove_member(target_user_id: str):
|
||||
"""
|
||||
Retire un membre de l'organisation (owner uniquement).
|
||||
L'owner ne peut pas se retirer lui-même.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_id
|
||||
type: string
|
||||
required: true
|
||||
description: ID de l'utilisateur à retirer
|
||||
responses:
|
||||
200:
|
||||
description: Membre retiré
|
||||
400:
|
||||
description: Tentative de retirer l'owner lui-même
|
||||
403:
|
||||
description: Seul l'owner peut retirer des membres
|
||||
404:
|
||||
description: Membre ou organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||
if target_user_id == user["id"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||
}
|
||||
), 400
|
||||
|
||||
member = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
).fetchone()
|
||||
if not member:
|
||||
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||
|
||||
db.execute(
|
||||
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("remove_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import : migration idempotente
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_org_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||
226
api_v1/routes/predictions.py
Normal file
226
api_v1/routes/predictions.py
Normal file
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Predictions routes for API v1.
|
||||
|
||||
GET /api/v1/predictions/top3 — Top 3 global du jour (free tier, 1/day limit)
|
||||
GET /api/v1/predictions/all — Toutes prédictions (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
not_found,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required, free_daily_limit_check
|
||||
|
||||
predictions_bp = Blueprint("v1_predictions", __name__, url_prefix="/api/v1/predictions")
|
||||
|
||||
|
||||
def _fetch_ml_predictions(
|
||||
conn, date: str, limit: int = None, offset: int = 0, include_weather: bool = False
|
||||
):
|
||||
"""Shared helper — returns rows from ml_predictions_cache.
|
||||
|
||||
include_weather=True adds terrain_condition and weather_impact columns
|
||||
via LEFT JOIN on pmu_meteo (premium routes only).
|
||||
"""
|
||||
if not table_exists(conn, "ml_predictions_cache"):
|
||||
return [], 0
|
||||
|
||||
count_row = conn.execute(
|
||||
"SELECT COUNT(*) as cnt FROM ml_predictions_cache WHERE date = ?",
|
||||
(date,),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
if (
|
||||
include_weather
|
||||
and table_exists(conn, "pmu_meteo")
|
||||
and table_exists(conn, "pmu_courses")
|
||||
):
|
||||
sql = """SELECT
|
||||
m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||
m.ml_score, m.recommendation, m.is_value_bet, m.risque_label, m.risque_score,
|
||||
c.penetrometre_intitule,
|
||||
mt.nebulositecode, mt.nebulosite_court, mt.temperature, mt.force_vent
|
||||
FROM ml_predictions_cache m
|
||||
LEFT JOIN pmu_courses c
|
||||
ON c.date_programme = m.date
|
||||
AND c.num_reunion = m.num_reunion
|
||||
AND c.num_course = m.num_course
|
||||
LEFT JOIN pmu_meteo mt
|
||||
ON mt.date_programme = m.date
|
||||
AND mt.num_reunion = m.num_reunion
|
||||
WHERE m.date = ?
|
||||
ORDER BY m.ml_score DESC"""
|
||||
else:
|
||||
sql = """SELECT
|
||||
race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
ORDER BY ml_score DESC"""
|
||||
params = [date]
|
||||
|
||||
if limit is not None:
|
||||
sql += " LIMIT ? OFFSET ?"
|
||||
params += [limit, offset]
|
||||
|
||||
rows = conn.execute(sql, params).fetchall()
|
||||
|
||||
results = []
|
||||
for r in rows:
|
||||
row_dict = dict(r)
|
||||
if include_weather:
|
||||
# Compute derived fields from raw columns
|
||||
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||
# Import inline to avoid circular dependency at module level
|
||||
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_data = None
|
||||
if (
|
||||
row_dict.get("nebulositecode") is not None
|
||||
or row_dict.get("temperature") is not None
|
||||
):
|
||||
weather_data = {
|
||||
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||
"temperature": row_dict.pop("temperature", None),
|
||||
"force_vent": row_dict.pop("force_vent", None),
|
||||
}
|
||||
else:
|
||||
# Remove raw meteo columns even if NULL
|
||||
row_dict.pop("nebulositecode", None)
|
||||
row_dict.pop("nebulosite_court", None)
|
||||
row_dict.pop("temperature", None)
|
||||
row_dict.pop("force_vent", None)
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
row_dict["terrain_condition"] = terrain_condition
|
||||
row_dict["weather_impact"] = weather_impact
|
||||
results.append(row_dict)
|
||||
|
||||
return results, total
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/top3
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/top3", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@free_daily_limit_check
|
||||
def predictions_top3():
|
||||
"""
|
||||
Top 3 prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Top 3 chevaux avec le meilleur score ML du jour (free tier inclus)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
responses:
|
||||
200:
|
||||
description: Top 3 prédictions ML du jour
|
||||
401:
|
||||
description: Token invalide
|
||||
429:
|
||||
description: Limite quotidienne free tier atteinte
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, _ = _fetch_ml_predictions(conn, date_param, limit=3, offset=0)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"top3": predictions,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/predictions/all
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@predictions_bp.route("/all", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def predictions_all():
|
||||
"""
|
||||
Toutes les prédictions du jour
|
||||
---
|
||||
tags:
|
||||
- Prédictions
|
||||
summary: Toutes les prédictions ML du jour — accès premium et pro uniquement
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date au format YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Toutes les prédictions ML
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=50, max_limit=500)
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
predictions, total = _fetch_ml_predictions(
|
||||
conn, date_param, limit=limit, offset=offset, include_weather=True
|
||||
)
|
||||
pagination = paginate_query(predictions, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"predictions": predictions,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
216
api_v1/routes/user.py
Normal file
216
api_v1/routes/user.py
Normal file
@@ -0,0 +1,216 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
User route for API v1 — Telegram alert configuration
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
GET /api/v1/user/telegram-config — Lire la config Telegram de l'utilisateur connecté
|
||||
POST /api/v1/user/telegram-config — Mettre à jour la config Telegram
|
||||
|
||||
Accès : Premium / Pro uniquement (@jwt_required_middleware + @plan_required)
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import internal_error, bad_request
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
user_bp = Blueprint("v1_user", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
# DB_PATH est résolu via la même variable d'env que auth_db.py
|
||||
import os
|
||||
|
||||
_DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(_DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
# ── GET /api/v1/user/telegram-config ──────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def get_telegram_config():
|
||||
"""
|
||||
Retourne la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Lire la config alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Configuration Telegram courante
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
nullable: true
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
alert_top1:
|
||||
type: boolean
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"""
|
||||
SELECT telegram_chat_id, alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE id = ?
|
||||
""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
|
||||
if not row:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 404
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": row["telegram_chat_id"],
|
||||
"alert_value_bets": bool(row["alert_value_bets"]),
|
||||
"alert_top1": bool(row["alert_top1"]),
|
||||
"alert_quinte_only": bool(row["alert_quinte_only"]),
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
# Colonnes absentes : migration non appliquée
|
||||
return jsonify(
|
||||
{
|
||||
"telegram_chat_id": None,
|
||||
"alert_value_bets": True,
|
||||
"alert_top1": True,
|
||||
"alert_quinte_only": False,
|
||||
"_warning": "Migration Telegram non appliquée",
|
||||
}
|
||||
), 200
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── POST /api/v1/user/telegram-config ─────────────────────────────────────────
|
||||
|
||||
|
||||
@user_bp.route("/telegram-config", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def update_telegram_config():
|
||||
"""
|
||||
Met à jour la configuration Telegram de l'utilisateur connecté.
|
||||
---
|
||||
tags:
|
||||
- Utilisateur
|
||||
summary: Configurer les alertes Telegram (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
properties:
|
||||
telegram_chat_id:
|
||||
type: string
|
||||
description: Chat ID Telegram (ou null pour désactiver)
|
||||
alert_value_bets:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_top1:
|
||||
type: boolean
|
||||
default: true
|
||||
alert_quinte_only:
|
||||
type: boolean
|
||||
default: false
|
||||
responses:
|
||||
200:
|
||||
description: Configuration mise à jour
|
||||
400:
|
||||
description: Paramètres invalides
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
"""
|
||||
user_id = request.user_id # injecté par jwt_required_middleware
|
||||
|
||||
data = request.get_json(silent=True)
|
||||
if not data:
|
||||
return bad_request("Corps JSON requis")
|
||||
|
||||
# Validation et extraction des champs
|
||||
telegram_chat_id = data.get("telegram_chat_id")
|
||||
if telegram_chat_id is not None and not isinstance(telegram_chat_id, str):
|
||||
return bad_request("telegram_chat_id doit être une chaîne ou null")
|
||||
if isinstance(telegram_chat_id, str):
|
||||
telegram_chat_id = telegram_chat_id.strip() or None
|
||||
|
||||
alert_value_bets = data.get("alert_value_bets", True)
|
||||
alert_top1 = data.get("alert_top1", True)
|
||||
alert_quinte_only = data.get("alert_quinte_only", False)
|
||||
|
||||
if not isinstance(alert_value_bets, bool):
|
||||
return bad_request("alert_value_bets doit être un booléen")
|
||||
if not isinstance(alert_top1, bool):
|
||||
return bad_request("alert_top1 doit être un booléen")
|
||||
if not isinstance(alert_quinte_only, bool):
|
||||
return bad_request("alert_quinte_only doit être un booléen")
|
||||
|
||||
conn = _get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"""
|
||||
UPDATE users
|
||||
SET telegram_chat_id = ?,
|
||||
alert_value_bets = ?,
|
||||
alert_top1 = ?,
|
||||
alert_quinte_only = ?
|
||||
WHERE id = ?
|
||||
""",
|
||||
(
|
||||
telegram_chat_id,
|
||||
int(alert_value_bets),
|
||||
int(alert_top1),
|
||||
int(alert_quinte_only),
|
||||
user_id,
|
||||
),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"telegram_chat_id": telegram_chat_id,
|
||||
"alert_value_bets": alert_value_bets,
|
||||
"alert_top1": alert_top1,
|
||||
"alert_quinte_only": alert_quinte_only,
|
||||
}
|
||||
), 200
|
||||
|
||||
except sqlite3.OperationalError as exc:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Migration Telegram non appliquée — contacter le support",
|
||||
"detail": str(exc),
|
||||
}
|
||||
), 500
|
||||
except Exception as exc:
|
||||
return internal_error(str(exc))
|
||||
finally:
|
||||
conn.close()
|
||||
195
api_v1/routes/user_tokens.py
Normal file
195
api_v1/routes/user_tokens.py
Normal file
@@ -0,0 +1,195 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
user_tokens.py — Personal API tokens + Webhook configuration (Pro plan)
|
||||
HRT-80
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/user/api-token
|
||||
DELETE /api/v1/user/api-token
|
||||
POST /api/v1/user/webhook
|
||||
DELETE /api/v1/user/webhook
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import secrets
|
||||
|
||||
from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from api_tokens_db import get_db, migrate_api_tokens_tables
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
logger = logging.getLogger("turf_saas.user_tokens")
|
||||
|
||||
user_tokens_bp = Blueprint("user_tokens", __name__, url_prefix="/api/v1/user")
|
||||
|
||||
try:
|
||||
migrate_api_tokens_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("api_tokens_db migration skipped (test env?): %s", _e)
|
||||
|
||||
|
||||
def _hash_token(raw: str) -> str:
|
||||
return hashlib.sha256(raw.encode()).hexdigest()
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id, token_prefix, created_at FROM user_api_tokens "
|
||||
"WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Un token actif existe déjà. Révoquez-le avant d'en créer un nouveau.",
|
||||
"existing_prefix": existing["token_prefix"],
|
||||
"created_at": existing["created_at"],
|
||||
}
|
||||
), 409
|
||||
|
||||
raw_token = "trf_" + secrets.token_urlsafe(40)
|
||||
token_hash = _hash_token(raw_token)
|
||||
token_prefix = raw_token[:12]
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO user_api_tokens (user_id, token_hash, token_prefix) VALUES (?, ?, ?)",
|
||||
(user_id, token_hash, token_prefix),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
row = conn.execute(
|
||||
"SELECT created_at FROM user_api_tokens WHERE token_hash = ?",
|
||||
(token_hash,),
|
||||
).fetchone()
|
||||
created_at = row["created_at"] if row else None
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
logger.info("API token created for user %s (prefix=%s)", user_id, token_prefix)
|
||||
return jsonify(
|
||||
{
|
||||
"token": raw_token,
|
||||
"prefix": token_prefix,
|
||||
"created_at": created_at,
|
||||
"warning": "Conservez ce token en lieu sûr. Il ne sera plus affiché.",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/api-token", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def revoke_api_token():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute(
|
||||
"UPDATE user_api_tokens SET revoked = 1 WHERE user_id = ? AND revoked = 0",
|
||||
(user_id,),
|
||||
)
|
||||
conn.commit()
|
||||
revoked_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("revoke_api_token error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if revoked_count == 0:
|
||||
return jsonify({"error": "Aucun token actif trouvé"}), 404
|
||||
|
||||
logger.info("API token(s) revoked for user %s (%d tokens)", user_id, revoked_count)
|
||||
return jsonify({"revoked": True, "count": revoked_count}), 200
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def create_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
data = request.get_json(silent=True) or {}
|
||||
url = (data.get("url") or "").strip()
|
||||
|
||||
if not url:
|
||||
return jsonify({"error": "URL du webhook manquante"}), 400
|
||||
if not url.startswith("https://"):
|
||||
return jsonify(
|
||||
{"error": "L'URL du webhook doit utiliser HTTPS (commencer par https://)"}
|
||||
), 400
|
||||
|
||||
secret = (data.get("secret") or "").strip() or secrets.token_hex(32)
|
||||
|
||||
conn = get_db()
|
||||
existing = None
|
||||
try:
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM user_webhooks WHERE user_id = ?", (user_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE user_webhooks SET url = ?, secret = ?, created_at = datetime('now') "
|
||||
"WHERE user_id = ?",
|
||||
(url, secret, user_id),
|
||||
)
|
||||
else:
|
||||
conn.execute(
|
||||
"INSERT INTO user_webhooks (user_id, url, secret) VALUES (?, ?, ?)",
|
||||
(user_id, url, secret),
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("create_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
action = "mis à jour" if existing else "configuré"
|
||||
logger.info("Webhook %s for user %s: %s", action, user_id, url)
|
||||
return jsonify(
|
||||
{
|
||||
"webhook_url": url,
|
||||
"secret": secret,
|
||||
"message": f"Webhook {action} avec succès",
|
||||
}
|
||||
), 201
|
||||
|
||||
|
||||
@user_tokens_bp.route("/webhook", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("pro")
|
||||
def delete_webhook():
|
||||
user = g.current_user
|
||||
user_id = str(user["id"])
|
||||
conn = get_db()
|
||||
try:
|
||||
result = conn.execute("DELETE FROM user_webhooks WHERE user_id = ?", (user_id,))
|
||||
conn.commit()
|
||||
deleted_count = result.rowcount
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error("delete_webhook error for user %s: %s", user_id, e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if deleted_count == 0:
|
||||
return jsonify({"error": "Aucun webhook configuré"}), 404
|
||||
|
||||
logger.info("Webhook deleted for user %s", user_id)
|
||||
return jsonify({"deleted": True}), 200
|
||||
166
api_v1/routes/valuebets.py
Normal file
166
api_v1/routes/valuebets.py
Normal file
@@ -0,0 +1,166 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Value bets route for API v1.
|
||||
|
||||
GET /api/v1/valuebets — Value bets du jour (premium+)
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from api_v1.utils import (
|
||||
get_db,
|
||||
table_exists,
|
||||
internal_error,
|
||||
get_pagination_params,
|
||||
paginate_query,
|
||||
)
|
||||
from auth import jwt_required_middleware, plan_required
|
||||
|
||||
valuebets_bp = Blueprint("v1_valuebets", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
@valuebets_bp.route("/valuebets", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def valuebets():
|
||||
"""
|
||||
Value bets du jour
|
||||
---
|
||||
tags:
|
||||
- Value Bets
|
||||
summary: Value bets du jour — chevaux à cote surévaluée par le marché (premium+)
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- name: date
|
||||
in: query
|
||||
type: string
|
||||
format: date
|
||||
description: Date YYYY-MM-DD (défaut aujourd'hui)
|
||||
- name: min_odds
|
||||
in: query
|
||||
type: number
|
||||
default: 2.0
|
||||
description: Cote minimale pour filtrer les value bets
|
||||
- name: limit
|
||||
in: query
|
||||
type: integer
|
||||
default: 20
|
||||
- name: offset
|
||||
in: query
|
||||
type: integer
|
||||
default: 0
|
||||
responses:
|
||||
200:
|
||||
description: Value bets du jour avec météo et terrain (HRT-83)
|
||||
401:
|
||||
description: Token invalide
|
||||
403:
|
||||
description: Plan insuffisant (premium ou pro requis)
|
||||
"""
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
limit, offset = get_pagination_params(default_limit=20, max_limit=100)
|
||||
|
||||
try:
|
||||
min_odds = float(request.args.get("min_odds", 2.0))
|
||||
except (ValueError, TypeError):
|
||||
min_odds = 2.0
|
||||
|
||||
conn = get_db()
|
||||
try:
|
||||
rows_raw = []
|
||||
total = 0
|
||||
|
||||
if table_exists(conn, "ml_predictions_cache"):
|
||||
count_row = conn.execute(
|
||||
"""SELECT COUNT(*) as cnt
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?""",
|
||||
(date_param, min_odds),
|
||||
).fetchone()
|
||||
total = count_row["cnt"] if count_row else 0
|
||||
|
||||
# LEFT JOIN pmu_courses (terrain) + pmu_meteo (météo) — HRT-83
|
||||
has_courses = table_exists(conn, "pmu_courses")
|
||||
has_meteo = table_exists(conn, "pmu_meteo")
|
||||
|
||||
if has_courses and has_meteo:
|
||||
rows_raw = conn.execute(
|
||||
"""SELECT m.race_label, m.hippodrome, m.discipline, m.distance, m.heure,
|
||||
m.horse_name, m.horse_number, m.odds, m.prob_top1, m.prob_top3,
|
||||
m.ml_score, m.recommendation, m.risque_label, m.risque_score,
|
||||
c.penetrometre_intitule,
|
||||
mt.nebulositecode, mt.nebulosite_court,
|
||||
mt.temperature, mt.force_vent
|
||||
FROM ml_predictions_cache m
|
||||
LEFT JOIN pmu_courses c
|
||||
ON c.date_programme = m.date
|
||||
AND c.num_reunion = m.num_reunion
|
||||
AND c.num_course = m.num_course
|
||||
LEFT JOIN pmu_meteo mt
|
||||
ON mt.date_programme = m.date
|
||||
AND mt.num_reunion = m.num_reunion
|
||||
WHERE m.date = ? AND m.is_value_bet = 1 AND m.odds >= ?
|
||||
ORDER BY m.ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
else:
|
||||
rows_raw = conn.execute(
|
||||
"""SELECT race_label, hippodrome, discipline, distance, heure,
|
||||
horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, risque_label, risque_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1 AND odds >= ?
|
||||
ORDER BY ml_score DESC
|
||||
LIMIT ? OFFSET ?""",
|
||||
(date_param, min_odds, limit, offset),
|
||||
).fetchall()
|
||||
|
||||
from scoring_v2 import get_terrain_condition, compute_weather_impact
|
||||
|
||||
valuebets_list = []
|
||||
for r in rows_raw:
|
||||
row_dict = dict(r)
|
||||
penetrometre = row_dict.pop("penetrometre_intitule", None) or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_data = None
|
||||
if (
|
||||
row_dict.get("nebulositecode") is not None
|
||||
or row_dict.get("temperature") is not None
|
||||
):
|
||||
weather_data = {
|
||||
"nebulositecode": row_dict.pop("nebulositecode", None),
|
||||
"nebulosite_court": row_dict.pop("nebulosite_court", None),
|
||||
"temperature": row_dict.pop("temperature", None),
|
||||
"force_vent": row_dict.pop("force_vent", None),
|
||||
}
|
||||
else:
|
||||
row_dict.pop("nebulositecode", None)
|
||||
row_dict.pop("nebulosite_court", None)
|
||||
row_dict.pop("temperature", None)
|
||||
row_dict.pop("force_vent", None)
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
row_dict["terrain_condition"] = terrain_condition
|
||||
row_dict["weather_impact"] = weather_impact
|
||||
valuebets_list.append(row_dict)
|
||||
|
||||
pagination = paginate_query(valuebets_list, total, limit, offset)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"status": "ok",
|
||||
"date": date_param,
|
||||
"min_odds": min_odds,
|
||||
"valuebets": valuebets_list,
|
||||
**pagination,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
return internal_error(str(e))
|
||||
finally:
|
||||
conn.close()
|
||||
98
api_v1/utils.py
Normal file
98
api_v1/utils.py
Normal file
@@ -0,0 +1,98 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Shared utilities for API v1 — error helpers, pagination, DB access.
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
from flask import jsonify, request
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Database
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
"""Return a SQLite connection with Row factory."""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def table_exists(conn, table_name: str) -> bool:
|
||||
row = conn.execute(
|
||||
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
|
||||
).fetchone()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Uniform error responses
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def error_response(message: str, code: int, status: str = "error"):
|
||||
"""Return a JSON error envelope consistent with the API contract.
|
||||
|
||||
Shape: {"status": "error", "message": "...", "code": 400}
|
||||
"""
|
||||
return jsonify({"status": status, "message": message, "code": code}), code
|
||||
|
||||
|
||||
def not_found(message: str = "Resource not found"):
|
||||
return error_response(message, 404)
|
||||
|
||||
|
||||
def bad_request(message: str = "Bad request"):
|
||||
return error_response(message, 400)
|
||||
|
||||
|
||||
def forbidden(message: str = "Forbidden", required_plans=None, current_plan=None):
|
||||
payload = {"status": "error", "message": message, "code": 403}
|
||||
if required_plans:
|
||||
payload["required_plans"] = required_plans
|
||||
if current_plan:
|
||||
payload["current_plan"] = current_plan
|
||||
payload["upgrade_url"] = "/api/v1/subscription/upgrade"
|
||||
return jsonify(payload), 403
|
||||
|
||||
|
||||
def internal_error(message: str = "Internal server error"):
|
||||
return error_response(message, 500)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Pagination helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_pagination_params(default_limit: int = 20, max_limit: int = 100):
|
||||
"""Extract and validate limit/offset from query-string."""
|
||||
try:
|
||||
limit = int(request.args.get("limit", default_limit))
|
||||
except (ValueError, TypeError):
|
||||
limit = default_limit
|
||||
|
||||
try:
|
||||
offset = int(request.args.get("offset", 0))
|
||||
except (ValueError, TypeError):
|
||||
offset = 0
|
||||
|
||||
limit = max(1, min(limit, max_limit))
|
||||
offset = max(0, offset)
|
||||
return limit, offset
|
||||
|
||||
|
||||
def paginate_query(rows, total: int, limit: int, offset: int):
|
||||
"""Wrap a list of rows in a pagination envelope."""
|
||||
return {
|
||||
"pagination": {
|
||||
"total": total,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"has_more": (offset + limit) < total,
|
||||
}
|
||||
}
|
||||
80
api_v1/utils_webhook.py
Normal file
80
api_v1/utils_webhook.py
Normal file
@@ -0,0 +1,80 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
utils_webhook.py — Webhook dispatch utility (fire-and-forget, HMAC-SHA256)
|
||||
HRT-80
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
|
||||
import requests
|
||||
|
||||
from api_tokens_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.webhook")
|
||||
|
||||
EVENT_NEW_PREDICTION = "new_prediction"
|
||||
EVENT_VALUE_BET = "value_bet"
|
||||
|
||||
|
||||
def dispatch_webhook(user_id: str, event_type: str, payload: dict) -> None:
|
||||
"""
|
||||
Send HMAC-signed webhook POST to URL configured by user.
|
||||
Fire-and-forget: errors logged, never re-raised. Timeout: 5s.
|
||||
"""
|
||||
conn = get_db()
|
||||
try:
|
||||
row = conn.execute(
|
||||
"SELECT url, secret FROM user_webhooks WHERE user_id = ?",
|
||||
(str(user_id),),
|
||||
).fetchone()
|
||||
except Exception as e:
|
||||
logger.warning("dispatch_webhook: DB error for user %s: %s", user_id, e)
|
||||
return
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
if not row:
|
||||
return
|
||||
|
||||
url = row["url"]
|
||||
secret = row["secret"]
|
||||
body = json.dumps(
|
||||
{"event": event_type, "data": payload},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"), body.encode("utf-8"), hashlib.sha256
|
||||
).hexdigest()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"X-Turf-Signature": f"sha256={signature}",
|
||||
"X-Turf-Event": event_type,
|
||||
"User-Agent": "TurfSaaS-Webhook/1.0",
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, data=body, headers=headers, timeout=5)
|
||||
logger.info(
|
||||
"Webhook dispatched to user %s (event=%s, status=%s)",
|
||||
user_id,
|
||||
event_type,
|
||||
resp.status_code,
|
||||
)
|
||||
except requests.exceptions.Timeout:
|
||||
logger.warning(
|
||||
"Webhook timeout for user %s (event=%s, url=%s)", user_id, event_type, url
|
||||
)
|
||||
except requests.exceptions.RequestException as e:
|
||||
logger.warning(
|
||||
"Webhook failed for user %s (event=%s): %s", user_id, event_type, e
|
||||
)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Webhook unexpected error for user %s (event=%s): %s",
|
||||
user_id,
|
||||
event_type,
|
||||
e,
|
||||
)
|
||||
138
app_v1.py
Normal file
138
app_v1.py
Normal file
@@ -0,0 +1,138 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
app_v1.py — Turf SaaS Flask application with versioned API /v1/
|
||||
|
||||
This module creates the Flask app, registers:
|
||||
- Auth JWT (from Sprint 2-3)
|
||||
- API v1 blueprints
|
||||
- Swagger/OpenAPI documentation at /api/v1/docs
|
||||
|
||||
Usage:
|
||||
python app_v1.py
|
||||
# or via gunicorn:
|
||||
gunicorn -w 2 -b 0.0.0.0:8792 app_v1:app
|
||||
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
from flask import Flask, jsonify
|
||||
from flask_cors import CORS
|
||||
from flask_jwt_extended import JWTManager
|
||||
from flasgger import Swagger
|
||||
|
||||
from auth_db import init_auth_tables
|
||||
from auth import auth_bp
|
||||
from api_v1 import register_api_v1
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("turf_saas.app_v1")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
"""Application factory."""
|
||||
app = Flask(__name__)
|
||||
|
||||
# ── CORS ──
|
||||
CORS(app, origins=["*"], methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"])
|
||||
|
||||
# ── JWT config ──
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "change-me-in-production-use-strong-random-secret"
|
||||
)
|
||||
app.config["JWT_ACCESS_TOKEN_EXPIRES"] = timedelta(minutes=15)
|
||||
app.config["JWT_REFRESH_TOKEN_EXPIRES"] = timedelta(days=30)
|
||||
JWTManager(app)
|
||||
|
||||
# ── Swagger / OpenAPI ──
|
||||
swagger_config = {
|
||||
"headers": [],
|
||||
"specs": [
|
||||
{
|
||||
"endpoint": "apispec_v1",
|
||||
"route": "/api/v1/apispec.json",
|
||||
"rule_filter": lambda rule: str(rule).startswith("/api/v1"),
|
||||
"model_filter": lambda tag: True,
|
||||
}
|
||||
],
|
||||
"static_url_path": "/flasgger_static",
|
||||
"swagger_ui": True,
|
||||
"specs_route": "/api/v1/docs",
|
||||
}
|
||||
|
||||
swagger_template = {
|
||||
"swagger": "2.0",
|
||||
"info": {
|
||||
"title": "Turf SaaS API",
|
||||
"description": (
|
||||
"API v1 — Prédictions turf IA, value bets, backtest & métriques.\n\n"
|
||||
"**Plans:** `free` | `premium` | `pro`\n\n"
|
||||
"**Auth:** Bearer JWT — obtenir un token via `POST /api/v1/auth/login`"
|
||||
),
|
||||
"version": "1.0.0",
|
||||
"contact": {"name": "H3R7 Tech"},
|
||||
},
|
||||
"basePath": "/",
|
||||
"schemes": ["http", "https"],
|
||||
"securityDefinitions": {
|
||||
"Bearer": {
|
||||
"type": "apiKey",
|
||||
"name": "Authorization",
|
||||
"in": "header",
|
||||
"description": "Entrer: **Bearer <token>**",
|
||||
}
|
||||
},
|
||||
"consumes": ["application/json"],
|
||||
"produces": ["application/json"],
|
||||
}
|
||||
|
||||
Swagger(app, config=swagger_config, template=swagger_template)
|
||||
|
||||
# ── Auth DB init ──
|
||||
with app.app_context():
|
||||
try:
|
||||
init_auth_tables()
|
||||
except Exception as e:
|
||||
logger.warning("init_auth_tables warning: %s", e)
|
||||
|
||||
# ── Register auth blueprint ──
|
||||
app.register_blueprint(auth_bp)
|
||||
|
||||
# ── Register API v1 blueprints ──
|
||||
register_api_v1(app)
|
||||
|
||||
# ── Global error handlers ──
|
||||
@app.errorhandler(404)
|
||||
def not_found_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Route introuvable", "code": 404}
|
||||
), 404
|
||||
|
||||
@app.errorhandler(405)
|
||||
def method_not_allowed_handler(e):
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Méthode non autorisée", "code": 405}
|
||||
), 405
|
||||
|
||||
@app.errorhandler(500)
|
||||
def internal_error_handler(e):
|
||||
logger.exception("Unhandled 500 error")
|
||||
return jsonify(
|
||||
{"status": "error", "message": "Erreur serveur interne", "code": 500}
|
||||
), 500
|
||||
|
||||
logger.info("Turf SaaS API v1 ready — docs at /api/v1/docs")
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
|
||||
if __name__ == "__main__":
|
||||
port = int(os.environ.get("PORT", 8792))
|
||||
app.run(host="0.0.0.0", port=port, debug=False)
|
||||
408
auth.py
Normal file
408
auth.py
Normal file
@@ -0,0 +1,408 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth Blueprint — JWT authentication + multi-tenant plan enforcement
|
||||
Sprint 2-3: HRT-28
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/auth/register — email/password registration
|
||||
POST /api/v1/auth/login — returns access_token (15min) + refresh_token (30d)
|
||||
POST /api/v1/auth/refresh — rotate refresh token, issue new access_token
|
||||
POST /api/v1/auth/logout — revoke refresh token
|
||||
|
||||
Middleware exposed:
|
||||
jwt_required_middleware() — decorator: valid access JWT required
|
||||
plan_required(plans) — decorator: user plan must be in given list
|
||||
"""
|
||||
|
||||
import os
|
||||
import hashlib
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from functools import wraps
|
||||
|
||||
import bcrypt
|
||||
from flask import Blueprint, request, jsonify, g, current_app
|
||||
from flask_jwt_extended import (
|
||||
JWTManager,
|
||||
create_access_token,
|
||||
create_refresh_token,
|
||||
decode_token,
|
||||
get_jwt_identity,
|
||||
verify_jwt_in_request,
|
||||
)
|
||||
from flask_jwt_extended.exceptions import JWTExtendedException
|
||||
from jwt.exceptions import PyJWTError
|
||||
|
||||
from auth_db import get_db
|
||||
|
||||
logger = logging.getLogger("turf_saas.auth")
|
||||
|
||||
auth_bp = Blueprint("auth", __name__, url_prefix="/api/v1/auth")
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _hash_token(raw_token: str) -> str:
|
||||
"""SHA-256 hash of a token string for secure DB storage."""
|
||||
return hashlib.sha256(raw_token.encode()).hexdigest()
|
||||
|
||||
|
||||
def _get_user_by_email(email: str):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE email = ? AND is_active = 1", (email.lower(),)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _get_user_by_id(user_id: int):
|
||||
db = get_db()
|
||||
user = db.execute(
|
||||
"SELECT * FROM users WHERE id = ? AND is_active = 1", (user_id,)
|
||||
).fetchone()
|
||||
db.close()
|
||||
return user
|
||||
|
||||
|
||||
def _store_refresh_token(user_id: int, raw_token: str, expires_at: datetime):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"INSERT INTO refresh_tokens (user_id, token_hash, expires_at) VALUES (?,?,?)",
|
||||
(user_id, token_hash, expires_at.isoformat()),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _revoke_refresh_token(raw_token: str):
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
db.execute(
|
||||
"UPDATE refresh_tokens SET revoked = 1 WHERE token_hash = ?", (token_hash,)
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
|
||||
def _is_refresh_token_valid(raw_token: str, user_id: int) -> bool:
|
||||
token_hash = _hash_token(raw_token)
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"""SELECT id FROM refresh_tokens
|
||||
WHERE token_hash = ? AND user_id = ? AND revoked = 0
|
||||
AND expires_at > datetime('now')""",
|
||||
(token_hash, user_id),
|
||||
).fetchone()
|
||||
db.close()
|
||||
return row is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Auth endpoints
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""POST /api/v1/auth/register — create a new user account (plan=free)."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
if len(password) < 8:
|
||||
return jsonify({"error": "Mot de passe trop court (min 8 caractères)"}), 400
|
||||
|
||||
# Check uniqueness
|
||||
existing = _get_user_by_email(email)
|
||||
if existing:
|
||||
return jsonify({"error": "Email déjà enregistré"}), 409
|
||||
|
||||
password_hash = bcrypt.hashpw(password.encode(), bcrypt.gensalt()).decode()
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
cursor = db.execute(
|
||||
"INSERT INTO users (email, password_hash, plan) VALUES (?,?,?)",
|
||||
(email, password_hash, "free"),
|
||||
)
|
||||
user_id = cursor.lastrowid
|
||||
# Create initial subscription record
|
||||
db.execute(
|
||||
"INSERT INTO subscriptions (user_id, plan) VALUES (?,?)",
|
||||
(user_id, "free"),
|
||||
)
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("register error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
logger.info("New user registered: %s (id=%s)", email, user_id)
|
||||
return jsonify({"message": "Compte créé avec succès", "user_id": user_id}), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""POST /api/v1/auth/login — returns JWT access_token + refresh_token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email et mot de passe requis"}), 400
|
||||
|
||||
user = _get_user_by_email(email)
|
||||
if not user:
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
if not bcrypt.checkpw(password.encode(), user["password_hash"].encode()):
|
||||
logger.warning("Failed login attempt for %s", email)
|
||||
return jsonify({"error": "Identifiants invalides"}), 401
|
||||
|
||||
# Create tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
|
||||
access_token = create_access_token(
|
||||
identity=identity,
|
||||
additional_claims=additional_claims,
|
||||
)
|
||||
raw_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], raw_refresh, refresh_expires)
|
||||
|
||||
logger.info("User %s logged in (plan=%s)", email, user["plan"])
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": access_token,
|
||||
"refresh_token": raw_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/refresh", methods=["POST"])
|
||||
def refresh():
|
||||
"""POST /api/v1/auth/refresh — rotate refresh token, issue new access_token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if not raw_refresh:
|
||||
return jsonify({"error": "refresh_token manquant"}), 400
|
||||
|
||||
# Decode without verifying in DB first (to get user_id)
|
||||
try:
|
||||
decoded = decode_token(raw_refresh)
|
||||
except Exception:
|
||||
return jsonify({"error": "Refresh token invalide ou expiré"}), 401
|
||||
|
||||
user_id = int(decoded.get("sub", 0))
|
||||
|
||||
if not _is_refresh_token_valid(raw_refresh, user_id):
|
||||
return jsonify({"error": "Refresh token invalide, révoqué ou expiré"}), 401
|
||||
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
|
||||
# Revoke old refresh token (rotation)
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
# Issue new tokens
|
||||
identity = str(user["id"])
|
||||
additional_claims = {"plan": user["plan"], "email": user["email"]}
|
||||
new_access = create_access_token(
|
||||
identity=identity, additional_claims=additional_claims
|
||||
)
|
||||
new_refresh = create_refresh_token(identity=identity)
|
||||
|
||||
refresh_expires = datetime.now(timezone.utc) + timedelta(days=30)
|
||||
_store_refresh_token(user["id"], new_refresh, refresh_expires)
|
||||
|
||||
logger.info("Token refreshed for user_id=%s", user_id)
|
||||
return jsonify(
|
||||
{
|
||||
"access_token": new_access,
|
||||
"refresh_token": new_refresh,
|
||||
"token_type": "Bearer",
|
||||
"plan": user["plan"],
|
||||
}
|
||||
), 200
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
def logout():
|
||||
"""POST /api/v1/auth/logout — revoke refresh token."""
|
||||
data = request.get_json(silent=True) or {}
|
||||
raw_refresh = (data.get("refresh_token") or "").strip()
|
||||
|
||||
if raw_refresh:
|
||||
_revoke_refresh_token(raw_refresh)
|
||||
|
||||
return jsonify({"message": "Déconnexion réussie"}), 200
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# JWT-protected middleware
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def validate_api_key(raw_key: str):
|
||||
"""
|
||||
Validate a personal API token (X-API-Key header).
|
||||
Returns user dict or None. Updates last_used_at on success.
|
||||
HRT-80: Personal API token support.
|
||||
"""
|
||||
if not raw_key:
|
||||
return None
|
||||
key_hash = hashlib.sha256(raw_key.encode()).hexdigest()
|
||||
db = get_db()
|
||||
try:
|
||||
row = db.execute(
|
||||
"SELECT t.user_id, u.* FROM user_api_tokens t "
|
||||
"JOIN users u ON CAST(t.user_id AS INTEGER) = u.id "
|
||||
"WHERE t.token_hash = ? AND t.revoked = 0 AND u.is_active = 1",
|
||||
(key_hash,),
|
||||
).fetchone()
|
||||
if row:
|
||||
db.execute(
|
||||
"UPDATE user_api_tokens SET last_used_at = datetime('now') "
|
||||
"WHERE token_hash = ?",
|
||||
(key_hash,),
|
||||
)
|
||||
db.commit()
|
||||
return dict(row) if row else None
|
||||
except Exception as e:
|
||||
logger.warning("validate_api_key error: %s", e)
|
||||
return None
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
def jwt_required_middleware(fn):
|
||||
"""
|
||||
Decorator: require a valid Bearer JWT access token OR X-API-Key personal token.
|
||||
HRT-80: Added X-API-Key fallback for personal API tokens (Pro plan only).
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
# 1. Try Bearer JWT (existing flow — unchanged)
|
||||
try:
|
||||
verify_jwt_in_request()
|
||||
user_id = int(get_jwt_identity())
|
||||
user = _get_user_by_id(user_id)
|
||||
if not user:
|
||||
return jsonify({"error": "Utilisateur introuvable"}), 401
|
||||
g.current_user = dict(user)
|
||||
g.current_user_id = user_id
|
||||
return fn(*args, **kwargs)
|
||||
except (JWTExtendedException, PyJWTError) as e:
|
||||
logger.debug("JWT auth failed: %s", e)
|
||||
|
||||
# 2. Fallback: X-API-Key personal token (HRT-80)
|
||||
api_key = request.headers.get("X-API-Key", "").strip()
|
||||
if api_key:
|
||||
user = validate_api_key(api_key)
|
||||
if user:
|
||||
g.current_user = user
|
||||
g.current_user_id = user.get("id")
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return jsonify({"error": "Token invalide ou expiré"}), 401
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def plan_required(*allowed_plans):
|
||||
"""
|
||||
Decorator factory: user's plan must be in allowed_plans.
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
|
||||
Example:
|
||||
@app.route("/api/v1/predictions")
|
||||
@jwt_required_middleware
|
||||
@plan_required("premium", "pro")
|
||||
def premium_predictions():
|
||||
...
|
||||
"""
|
||||
|
||||
def decorator(fn):
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user["plan"] not in allowed_plans:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": list(allowed_plans),
|
||||
"current_plan": user["plan"],
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def free_daily_limit_check(fn):
|
||||
"""
|
||||
Decorator: enforce free plan daily limit (1 course/jour).
|
||||
Must be applied AFTER @jwt_required_middleware.
|
||||
"""
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(g, "current_user", None)
|
||||
if not user or user["plan"] != "free":
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
today = datetime.now(timezone.utc).date().isoformat()
|
||||
db = get_db()
|
||||
row = db.execute(
|
||||
"SELECT daily_usage, last_usage_date FROM users WHERE id = ?",
|
||||
(user["id"],),
|
||||
).fetchone()
|
||||
db.close()
|
||||
|
||||
if row and row["last_usage_date"] == today and row["daily_usage"] >= 1:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Limite quotidienne atteinte (plan free: 1 course/jour)",
|
||||
"upgrade_url": "/api/v1/subscription/upgrade",
|
||||
}
|
||||
), 429
|
||||
|
||||
# Increment usage
|
||||
db = get_db()
|
||||
if row and row["last_usage_date"] == today:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = daily_usage + 1 WHERE id = ?",
|
||||
(user["id"],),
|
||||
)
|
||||
else:
|
||||
db.execute(
|
||||
"UPDATE users SET daily_usage = 1, last_usage_date = ? WHERE id = ?",
|
||||
(today, user["id"]),
|
||||
)
|
||||
db.commit()
|
||||
db.close()
|
||||
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
99
auth_db.py
Normal file
99
auth_db.py
Normal file
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Auth DB — users and subscriptions schema for turf_saas.db
|
||||
Sprint 2-3: Auth JWT + Multi-tenant (HRT-28)
|
||||
HRT-79: migration Telegram columns
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_auth_tables():
|
||||
"""Create users and subscriptions tables if they don't exist."""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
email TEXT NOT NULL UNIQUE,
|
||||
password_hash TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free'
|
||||
CHECK(plan IN ('free','premium','pro')),
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
daily_usage INTEGER NOT NULL DEFAULT 0,
|
||||
last_usage_date TEXT DEFAULT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
plan TEXT NOT NULL CHECK(plan IN ('free','premium','pro')),
|
||||
start_date DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id),
|
||||
token_hash TEXT NOT NULL UNIQUE,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
expires_at DATETIME NOT NULL,
|
||||
revoked INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_user ON subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[auth_db] Tables users, subscriptions, refresh_tokens created/verified.")
|
||||
|
||||
# Apply Telegram columns migration (idempotent)
|
||||
migrate_telegram_columns()
|
||||
|
||||
|
||||
def migrate_telegram_columns():
|
||||
"""
|
||||
Migration idempotente : ajoute les colonnes Telegram à la table users.
|
||||
Utilise ALTER TABLE ... ADD COLUMN avec try/except OperationalError
|
||||
pour être safe si les colonnes existent déjà (SQLite ne supporte pas IF NOT EXISTS).
|
||||
HRT-79
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
columns = [
|
||||
("telegram_chat_id", "TEXT DEFAULT NULL"),
|
||||
("alert_value_bets", "INTEGER DEFAULT 1"),
|
||||
("alert_top1", "INTEGER DEFAULT 1"),
|
||||
("alert_quinte_only", "INTEGER DEFAULT 0"),
|
||||
]
|
||||
for col, definition in columns:
|
||||
try:
|
||||
c.execute(f"ALTER TABLE users ADD COLUMN {col} {definition}")
|
||||
print(f"[auth_db] Colonne '{col}' ajoutée.")
|
||||
except sqlite3.OperationalError:
|
||||
# Column already exists — safe to ignore
|
||||
pass
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print("[auth_db] Migration Telegram columns OK.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
init_auth_tables()
|
||||
143
billing_db.py
Normal file
143
billing_db.py
Normal file
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
DB Migration — Billing Stripe
|
||||
Sprint 5-6: HRT-31
|
||||
|
||||
Adds stripe_subscription_id and status columns to subscriptions table,
|
||||
and an invoices / grace-period tracking table.
|
||||
|
||||
Run once:
|
||||
./venv/bin/python billing_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.billing_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_billing_tables():
|
||||
"""Idempotent migration: add billing columns and billing_events table.
|
||||
|
||||
Requires auth tables (users, subscriptions) to exist first.
|
||||
Calls init_auth_tables() automatically if subscriptions is absent.
|
||||
"""
|
||||
from auth_db import init_auth_tables as _init_auth
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Ensure base auth tables exist
|
||||
tables = {
|
||||
row[0] for row in c.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
|
||||
if "subscriptions" not in tables:
|
||||
_init_auth()
|
||||
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
# Add stripe_subscription_id if missing
|
||||
columns = {row[1] for row in c.execute("PRAGMA table_info(subscriptions)")}
|
||||
|
||||
if "stripe_subscription_id" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN stripe_subscription_id TEXT")
|
||||
logger.info("[billing_db] Added stripe_subscription_id column to subscriptions")
|
||||
|
||||
if "status" not in columns:
|
||||
c.execute(
|
||||
"ALTER TABLE subscriptions ADD COLUMN "
|
||||
"status TEXT NOT NULL DEFAULT 'active' "
|
||||
"CHECK(status IN ('active','past_due','canceled','trialing','incomplete'))"
|
||||
)
|
||||
logger.info("[billing_db] Added status column to subscriptions")
|
||||
|
||||
if "grace_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN grace_period_end DATETIME")
|
||||
logger.info("[billing_db] Added grace_period_end column to subscriptions")
|
||||
|
||||
if "current_period_end" not in columns:
|
||||
c.execute("ALTER TABLE subscriptions ADD COLUMN current_period_end DATETIME")
|
||||
logger.info("[billing_db] Added current_period_end column to subscriptions")
|
||||
|
||||
# billing_events table — audit trail for all webhook events
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS billing_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
stripe_event_id TEXT NOT NULL UNIQUE,
|
||||
event_type TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
payload TEXT,
|
||||
processed_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS saas_subscriptions (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id TEXT NOT NULL,
|
||||
plan TEXT NOT NULL DEFAULT 'free',
|
||||
start_date DATETIME DEFAULT (datetime('now')),
|
||||
end_date DATETIME,
|
||||
stripe_customer_id TEXT,
|
||||
stripe_subscription_id TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'active',
|
||||
grace_period_end DATETIME,
|
||||
current_period_end DATETIME
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_user ON billing_events(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_billing_events_type ON billing_events(event_type);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_user ON saas_subscriptions(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_customer ON saas_subscriptions(stripe_customer_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_saas_subs_stripe ON saas_subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_stripe ON subscriptions(stripe_subscription_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_subscriptions_customer ON subscriptions(stripe_customer_id);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(
|
||||
"[billing_db] Migration complete: subscriptions + billing_events tables ready."
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_billing_tables()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Re-exported helpers for test usage
|
||||
# (primary implementations live in api_v1/routes/billing.py)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _upsert_subscription(db, user_id: int, **fields):
|
||||
"""
|
||||
Update existing subscription row or insert a new one.
|
||||
Convenience re-export for test helpers.
|
||||
"""
|
||||
existing = db.execute(
|
||||
"SELECT id FROM subscriptions WHERE user_id = ? ORDER BY start_date DESC LIMIT 1",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
if existing:
|
||||
set_parts = ", ".join(f"{k} = ?" for k in fields)
|
||||
values = list(fields.values()) + [existing["id"]]
|
||||
db.execute(f"UPDATE subscriptions SET {set_parts} WHERE id = ?", values)
|
||||
else:
|
||||
cols = ", ".join(["user_id"] + list(fields.keys()))
|
||||
placeholders = ", ".join(["?"] * (1 + len(fields)))
|
||||
values = [user_id] + list(fields.values())
|
||||
db.execute(
|
||||
f"INSERT INTO subscriptions ({cols}) VALUES ({placeholders})", values
|
||||
)
|
||||
@@ -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")
|
||||
|
||||
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)
|
||||
|
||||
1551
dashboard_saas.html
Normal file
1551
dashboard_saas.html
Normal file
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
|
||||
467
landing.html
Normal file
467
landing.html
Normal file
@@ -0,0 +1,467 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Turf IA — Prédictions PMU par Intelligence Artificielle</title>
|
||||
<meta name="description" content="Boostez vos paris PMU avec nos prédictions IA. Analyse XGBoost, value bets, alertes Telegram. Essai gratuit.">
|
||||
<style>
|
||||
/* ===== RESET & BASE ===== */
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853;
|
||||
--green-d: #009624;
|
||||
--blue: #1565c0;
|
||||
--blue-l: #1e88e5;
|
||||
--gold: #ffd600;
|
||||
--dark: #0d1117;
|
||||
--dark2: #161b22;
|
||||
--dark3: #21262d;
|
||||
--text: #e6edf3;
|
||||
--muted: #8b949e;
|
||||
--border: #30363d;
|
||||
--radius: 12px;
|
||||
}
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
background: var(--dark);
|
||||
color: var(--text);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; }
|
||||
|
||||
/* ===== NAV ===== */
|
||||
nav {
|
||||
position: sticky; top: 0; z-index: 100;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 16px 5%;
|
||||
background: rgba(13,17,23,0.95);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.nav-logo { display: flex; align-items: center; gap: 10px; font-weight: 700; font-size: 1.2rem; }
|
||||
.nav-logo .badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 20px; font-size: .75rem; }
|
||||
.nav-links { display: flex; align-items: center; gap: 28px; }
|
||||
.nav-links a { color: var(--muted); font-size: .95rem; transition: color .2s; }
|
||||
.nav-links a:hover { color: var(--text); }
|
||||
.nav-cta { display: flex; gap: 10px; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 6px; padding: 9px 20px; border-radius: 8px; font-size: .9rem; font-weight: 600; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--text); }
|
||||
.btn-ghost:hover { border-color: var(--muted); }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); transform: translateY(-1px); }
|
||||
.btn-lg { padding: 14px 32px; font-size: 1.05rem; border-radius: 10px; }
|
||||
|
||||
/* ===== HERO ===== */
|
||||
.hero {
|
||||
min-height: 90vh;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
text-align: center;
|
||||
padding: 80px 5% 60px;
|
||||
background: radial-gradient(ellipse 80% 60% at 50% 0%, rgba(0,200,83,.08) 0%, transparent 70%);
|
||||
position: relative;
|
||||
}
|
||||
.hero-eyebrow {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: rgba(0,200,83,.1); border: 1px solid rgba(0,200,83,.3);
|
||||
padding: 6px 16px; border-radius: 20px;
|
||||
font-size: .85rem; color: var(--green); margin-bottom: 24px;
|
||||
}
|
||||
.hero h1 { font-size: clamp(2rem, 5vw, 3.6rem); font-weight: 800; line-height: 1.15; max-width: 780px; margin-bottom: 20px; }
|
||||
.hero h1 span { color: var(--green); }
|
||||
.hero p { font-size: 1.15rem; color: var(--muted); max-width: 580px; margin-bottom: 36px; }
|
||||
.hero-actions { display: flex; gap: 14px; flex-wrap: wrap; justify-content: center; }
|
||||
.hero-stats {
|
||||
display: flex; gap: 40px; margin-top: 60px; flex-wrap: wrap; justify-content: center;
|
||||
}
|
||||
.stat { text-align: center; }
|
||||
.stat strong { display: block; font-size: 2rem; font-weight: 800; color: var(--green); }
|
||||
.stat span { font-size: .85rem; color: var(--muted); }
|
||||
|
||||
/* ===== SECTIONS ===== */
|
||||
section { padding: 80px 5%; }
|
||||
.section-label { color: var(--green); font-size: .85rem; font-weight: 600; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 12px; }
|
||||
h2 { font-size: clamp(1.6rem, 3vw, 2.4rem); font-weight: 800; margin-bottom: 16px; }
|
||||
.subtitle { color: var(--muted); font-size: 1.05rem; max-width: 560px; margin: 0 auto 50px; text-align: center; }
|
||||
.section-center { text-align: center; }
|
||||
|
||||
/* ===== FEATURES ===== */
|
||||
.features-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: 20px; max-width: 1100px; margin: 0 auto; }
|
||||
.feature-card {
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 28px; transition: border-color .2s, transform .2s;
|
||||
}
|
||||
.feature-card:hover { border-color: var(--green); transform: translateY(-3px); }
|
||||
.feature-icon { font-size: 2rem; margin-bottom: 14px; }
|
||||
.feature-card h3 { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.feature-card p { color: var(--muted); font-size: .9rem; line-height: 1.6; }
|
||||
|
||||
/* ===== PRICING ===== */
|
||||
.pricing-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 20px; max-width: 960px; margin: 0 auto; }
|
||||
.plan-card {
|
||||
background: var(--dark2); border: 1px solid var(--border); border-radius: var(--radius);
|
||||
padding: 32px; position: relative; transition: transform .2s;
|
||||
}
|
||||
.plan-card:hover { transform: translateY(-4px); }
|
||||
.plan-card.popular { border-color: var(--green); }
|
||||
.popular-badge {
|
||||
position: absolute; top: -12px; left: 50%; transform: translateX(-50%);
|
||||
background: var(--green); color: #000; padding: 3px 16px; border-radius: 20px;
|
||||
font-size: .75rem; font-weight: 700;
|
||||
}
|
||||
.plan-name { font-size: 1.1rem; font-weight: 700; margin-bottom: 8px; }
|
||||
.plan-price { font-size: 2.6rem; font-weight: 800; margin: 12px 0 4px; }
|
||||
.plan-price sup { font-size: 1.2rem; vertical-align: top; margin-top: 10px; }
|
||||
.plan-price span { font-size: 1rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-desc { color: var(--muted); font-size: .88rem; margin-bottom: 20px; }
|
||||
.plan-features { list-style: none; margin-bottom: 28px; }
|
||||
.plan-features li { display: flex; align-items: flex-start; gap: 10px; padding: 7px 0; font-size: .9rem; }
|
||||
.plan-features li::before { content: "✓"; color: var(--green); font-weight: 700; flex-shrink: 0; }
|
||||
.plan-features li.disabled { color: var(--muted); }
|
||||
.plan-features li.disabled::before { content: "×"; color: var(--border); }
|
||||
.btn-plan { width: 100%; text-align: center; justify-content: center; }
|
||||
|
||||
/* ===== HOW IT WORKS ===== */
|
||||
.steps { display: flex; flex-direction: column; gap: 0; max-width: 700px; margin: 0 auto; }
|
||||
.step { display: flex; gap: 24px; padding: 28px 0; border-bottom: 1px solid var(--border); }
|
||||
.step:last-child { border-bottom: none; }
|
||||
.step-num { width: 44px; height: 44px; border-radius: 50%; background: rgba(0,200,83,.1); border: 2px solid var(--green); display: flex; align-items: center; justify-content: center; font-weight: 800; color: var(--green); flex-shrink: 0; }
|
||||
.step-body h3 { font-size: 1.05rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.step-body p { color: var(--muted); font-size: .9rem; }
|
||||
|
||||
/* ===== FAQ ===== */
|
||||
.faq-list { max-width: 720px; margin: 0 auto; }
|
||||
details { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 10px; overflow: hidden; }
|
||||
details[open] { border-color: var(--green); }
|
||||
summary {
|
||||
padding: 18px 22px; font-weight: 600; cursor: pointer; list-style: none;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
}
|
||||
summary::after { content: "+"; color: var(--green); font-size: 1.3rem; }
|
||||
details[open] summary::after { content: "−"; }
|
||||
.faq-answer { padding: 0 22px 18px; color: var(--muted); font-size: .93rem; line-height: 1.7; }
|
||||
|
||||
/* ===== CTA BANNER ===== */
|
||||
.cta-banner {
|
||||
background: linear-gradient(135deg, rgba(0,200,83,.12) 0%, rgba(21,101,192,.12) 100%);
|
||||
border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 60px; text-align: center; max-width: 820px; margin: 0 auto;
|
||||
}
|
||||
.cta-banner h2 { margin-bottom: 12px; }
|
||||
.cta-banner p { color: var(--muted); margin-bottom: 28px; }
|
||||
|
||||
/* ===== FOOTER ===== */
|
||||
footer {
|
||||
border-top: 1px solid var(--border);
|
||||
padding: 40px 5%;
|
||||
display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 40px;
|
||||
}
|
||||
.footer-brand p { color: var(--muted); font-size: .88rem; margin-top: 10px; max-width: 240px; }
|
||||
.footer-col h4 { font-size: .85rem; font-weight: 700; text-transform: uppercase; letter-spacing: .5px; margin-bottom: 14px; color: var(--muted); }
|
||||
.footer-col a { display: block; color: var(--muted); font-size: .88rem; margin-bottom: 8px; transition: color .2s; }
|
||||
.footer-col a:hover { color: var(--text); }
|
||||
.footer-bottom { border-top: 1px solid var(--border); padding: 20px 5%; text-align: center; color: var(--muted); font-size: .82rem; }
|
||||
|
||||
/* ===== TOAST ===== */
|
||||
#toast {
|
||||
position: fixed; bottom: 24px; right: 24px; z-index: 999;
|
||||
padding: 14px 22px; border-radius: 10px; font-size: .9rem; font-weight: 600;
|
||||
transform: translateY(80px); opacity: 0; transition: all .3s;
|
||||
pointer-events: none;
|
||||
}
|
||||
#toast.show { transform: translateY(0); opacity: 1; }
|
||||
#toast.success { background: var(--green); color: #000; }
|
||||
#toast.error { background: #f85149; color: #fff; }
|
||||
|
||||
/* ===== RESPONSIVE ===== */
|
||||
@media (max-width: 768px) {
|
||||
.nav-links { display: none; }
|
||||
footer { grid-template-columns: 1fr 1fr; }
|
||||
.hero-stats { gap: 24px; }
|
||||
.cta-banner { padding: 36px 24px; }
|
||||
}
|
||||
@media (max-width: 480px) {
|
||||
footer { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="nav-logo">
|
||||
🏇 Turf IA
|
||||
<span class="badge">BETA</span>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#features">Fonctionnalités</a>
|
||||
<a href="#pricing">Tarifs</a>
|
||||
<a href="#how">Comment ça marche</a>
|
||||
<a href="#faq">FAQ</a>
|
||||
</div>
|
||||
<div class="nav-cta">
|
||||
<a href="/login" class="btn btn-ghost">Connexion</a>
|
||||
<a href="/register" class="btn btn-primary">S'inscrire gratuitement</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-eyebrow">
|
||||
🤖 Intelligence Artificielle · XGBoost · Données PMU temps réel
|
||||
</div>
|
||||
<h1>Pariez plus <span>intelligemment</span><br>grâce à l'IA</h1>
|
||||
<p>Nos modèles XGBoost analysent chaque course PMU en temps réel — cotes, historique, jockeys, météo — pour vous donner les meilleures prédictions du marché.</p>
|
||||
<div class="hero-actions">
|
||||
<a href="/register" class="btn btn-primary btn-lg">Commencer gratuitement</a>
|
||||
<a href="#how" class="btn btn-ghost btn-lg">Voir comment ça marche</a>
|
||||
</div>
|
||||
<div class="hero-stats">
|
||||
<div class="stat"><strong>+73%</strong><span>précision Top-3</span></div>
|
||||
<div class="stat"><strong>150+</strong><span>courses analysées/jour</span></div>
|
||||
<div class="stat"><strong>2.4s</strong><span>temps de réponse moyen</span></div>
|
||||
<div class="stat"><strong>3 plans</strong><span>adaptés à chaque profil</span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section id="features">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Fonctionnalités</div>
|
||||
<h2>Tout ce dont vous avez besoin pour gagner</h2>
|
||||
<p class="subtitle">Un moteur IA complet, des alertes instantanées, et des analyses détaillées pour chaque parieur.</p>
|
||||
</div>
|
||||
<div class="features-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🧠</div>
|
||||
<h3>Prédictions XGBoost</h3>
|
||||
<p>Modèle entraîné sur des milliers de courses PMU. Probabilités Top-1 et Top-3 pour chaque partant, mis à jour en continu.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💎</div>
|
||||
<h3>Value Bets identifiés</h3>
|
||||
<p>Détection automatique des cotes sous-évaluées par le marché. Seulement les paris où l'espérance mathématique est positive.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📱</div>
|
||||
<h3>Alertes Telegram</h3>
|
||||
<p>Recevez les meilleures opportunités directement sur votre téléphone, avant le départ, avec toutes les infos clés.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📊</div>
|
||||
<h3>Dashboard temps réel</h3>
|
||||
<p>Tableau de bord complet : courses du jour, historique de performance, ROI, statistiques par hippodrome et discipline.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🌤️</div>
|
||||
<h3>Analyse météo & terrain</h3>
|
||||
<p>Impact des conditions météo et de l'état du terrain intégré dans chaque prédiction pour une précision maximale.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">📤</div>
|
||||
<h3>Export CSV & API</h3>
|
||||
<p>Exportez vos données, intégrez nos prédictions dans vos propres outils via notre API documentée (plan Pro).</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section id="how" style="background: var(--dark2); margin: 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Comment ça marche</div>
|
||||
<h2>En 3 étapes, prêt à parier</h2>
|
||||
<p class="subtitle">De l'inscription à votre première prédiction en moins de 2 minutes.</p>
|
||||
</div>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<div class="step-body">
|
||||
<h3>Créez votre compte gratuitement</h3>
|
||||
<p>Inscription en 30 secondes, sans carte bancaire. Accès immédiat au plan Free avec un aperçu des prédictions du jour.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<div class="step-body">
|
||||
<h3>Choisissez votre plan</h3>
|
||||
<p>Free pour découvrir, Premium (9,90€/mois) pour toutes les courses et alertes, Pro (24,90€/mois) pour l'API et les exports.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<div class="step-body">
|
||||
<h3>Recevez vos premières prédictions</h3>
|
||||
<p>Notre IA analyse les 150+ courses du jour. Accédez aux Top-3, value bets et probabilités depuis votre dashboard ou Telegram.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- PRICING -->
|
||||
<section id="pricing">
|
||||
<div class="section-center">
|
||||
<div class="section-label">Tarifs</div>
|
||||
<h2>Des prix transparents</h2>
|
||||
<p class="subtitle">Commencez gratuitement. Passez au niveau supérieur quand vous êtes prêt.</p>
|
||||
</div>
|
||||
<div class="pricing-grid">
|
||||
<!-- FREE -->
|
||||
<div class="plan-card">
|
||||
<div class="plan-name">Free</div>
|
||||
<div class="plan-price">0<sup>€</sup><span>/mois</span></div>
|
||||
<p class="plan-desc">Pour découvrir la puissance de l'IA turf.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Aperçu Top-3 du jour (limité)</li>
|
||||
<li>1 course complète par jour</li>
|
||||
<li>Statistiques basiques</li>
|
||||
<li class="disabled">Alertes Telegram</li>
|
||||
<li class="disabled">Toutes les courses</li>
|
||||
<li class="disabled">Value bets</li>
|
||||
<li class="disabled">Export CSV</li>
|
||||
<li class="disabled">Accès API</li>
|
||||
</ul>
|
||||
<a href="/register" class="btn btn-ghost btn-plan">Commencer gratuitement</a>
|
||||
</div>
|
||||
<!-- PREMIUM -->
|
||||
<div class="plan-card popular">
|
||||
<div class="popular-badge">⭐ Le plus populaire</div>
|
||||
<div class="plan-name">Premium</div>
|
||||
<div class="plan-price">9<sup>€</sup>,90<span>/mois</span></div>
|
||||
<p class="plan-desc">Pour les parieurs sérieux qui veulent un vrai avantage.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Toutes les courses du jour</li>
|
||||
<li>Prédictions Top-1 et Top-3</li>
|
||||
<li>Value bets identifiés</li>
|
||||
<li>Alertes Telegram configurables</li>
|
||||
<li>Historique 90 jours</li>
|
||||
<li>Analyse météo & terrain</li>
|
||||
<li class="disabled">Export CSV</li>
|
||||
<li class="disabled">Accès API</li>
|
||||
</ul>
|
||||
<a href="/register?plan=premium" class="btn btn-primary btn-plan">Choisir Premium</a>
|
||||
</div>
|
||||
<!-- PRO -->
|
||||
<div class="plan-card">
|
||||
<div class="plan-name">Pro</div>
|
||||
<div class="plan-price">24<sup>€</sup>,90<span>/mois</span></div>
|
||||
<p class="plan-desc">Pour les professionnels et développeurs qui veulent tout.</p>
|
||||
<ul class="plan-features">
|
||||
<li>Tout du plan Premium</li>
|
||||
<li>Export CSV illimité</li>
|
||||
<li>Accès API REST documentée</li>
|
||||
<li>Backtest personnalisé</li>
|
||||
<li>Historique illimité</li>
|
||||
<li>Support prioritaire</li>
|
||||
<li>Webhook alertes personnalisées</li>
|
||||
<li>Multi-compte (5 utilisateurs)</li>
|
||||
</ul>
|
||||
<a href="/register?plan=pro" class="btn btn-ghost btn-plan">Choisir Pro</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FAQ -->
|
||||
<section id="faq" style="background: var(--dark2); border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);">
|
||||
<div class="section-center">
|
||||
<div class="section-label">FAQ</div>
|
||||
<h2>Questions fréquentes</h2>
|
||||
</div>
|
||||
<div class="faq-list">
|
||||
<details>
|
||||
<summary>Comment fonctionne le modèle IA ?</summary>
|
||||
<div class="faq-answer">Notre modèle XGBoost est entraîné sur plusieurs années de données PMU : cotes, historique des chevaux et drivers, conditions météo, état du terrain, statistiques par hippodrome. Il calcule pour chaque partant une probabilité d'arriver dans le Top-1 et Top-3, ainsi qu'un score de value bet comparant notre estimation à la cote du marché.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Les prédictions garantissent-elles des gains ?</summary>
|
||||
<div class="faq-answer">Non. Aucune prédiction ne garantit des gains. Le pari hippique reste un jeu de hasard. Notre IA améliore vos chances en identifiant les opportunités statistiquement favorables, mais les résultats passés ne préjugent pas des résultats futurs. Pariez de façon responsable.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Puis-je annuler à tout moment ?</summary>
|
||||
<div class="faq-answer">Oui, sans engagement. Vous pouvez annuler votre abonnement Premium ou Pro à tout moment depuis votre espace compte. L'accès reste actif jusqu'à la fin de la période payée.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Les alertes Telegram fonctionnent-elles sur mobile ?</summary>
|
||||
<div class="faq-answer">Oui. Après activation dans vos paramètres, vous recevrez les alertes value bets et top picks directement dans votre application Telegram, avec toutes les informations nécessaires pour parier rapidement avant le départ.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>L'API est-elle compatible avec d'autres outils ?</summary>
|
||||
<div class="faq-answer">Oui. L'API REST du plan Pro est documentée (OpenAPI/Swagger) et compatible avec n'importe quel outil : Python, JavaScript, n8n, Zapier, Excel, etc. Un token personnel vous est fourni dans votre dashboard.</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>Quelles disciplines sont couvertes ?</summary>
|
||||
<div class="faq-answer">Nous couvrons toutes les disciplines PMU : Plat, Trot Attelé, Trot Monté, et Galop sur les hippodromes français. Les courses Quinté+ sont signalées et prioritaires dans votre dashboard.</div>
|
||||
</details>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- CTA BANNER -->
|
||||
<section>
|
||||
<div class="cta-banner">
|
||||
<h2>Prêt à parier plus intelligemment ?</h2>
|
||||
<p>Rejoignez des centaines de parieurs qui utilisent déjà Turf IA chaque jour. Essai gratuit, sans carte bancaire.</p>
|
||||
<a href="/register" class="btn btn-primary btn-lg">Créer mon compte gratuit →</a>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<div class="footer-brand">
|
||||
<div style="font-weight: 700; font-size: 1.1rem;">🏇 Turf IA</div>
|
||||
<p>Prédictions PMU par intelligence artificielle. Analyse XGBoost, value bets, alertes temps réel.</p>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Produit</h4>
|
||||
<a href="#features">Fonctionnalités</a>
|
||||
<a href="#pricing">Tarifs</a>
|
||||
<a href="#how">Comment ça marche</a>
|
||||
<a href="/dashboard">Dashboard</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Compte</h4>
|
||||
<a href="/login">Connexion</a>
|
||||
<a href="/register">Inscription</a>
|
||||
<a href="/account">Mon compte</a>
|
||||
</div>
|
||||
<div class="footer-col">
|
||||
<h4>Légal</h4>
|
||||
<a href="/legal/cgu">CGU</a>
|
||||
<a href="/legal/cgv">CGV</a>
|
||||
<a href="/legal/privacy">Confidentialité</a>
|
||||
<a href="/legal/cookies">Cookies</a>
|
||||
</div>
|
||||
</footer>
|
||||
<div class="footer-bottom">
|
||||
<p>© 2026 Turf IA — H3R7 Tech. Tous droits réservés. Le jeu peut être dangereux, jouez de façon responsable. <strong>18+</strong></p>
|
||||
</div>
|
||||
|
||||
<!-- TOAST -->
|
||||
<div id="toast"></div>
|
||||
|
||||
<script>
|
||||
function showToast(msg, type = 'success') {
|
||||
const t = document.getElementById('toast');
|
||||
t.textContent = msg;
|
||||
t.className = `show ${type}`;
|
||||
setTimeout(() => { t.className = ''; }, 3500);
|
||||
}
|
||||
|
||||
// Smooth scroll for anchor links
|
||||
document.querySelectorAll('a[href^="#"]').forEach(a => {
|
||||
a.addEventListener('click', e => {
|
||||
const target = document.querySelector(a.getAttribute('href'));
|
||||
if (target) { e.preventDefault(); target.scrollIntoView({ behavior: 'smooth' }); }
|
||||
});
|
||||
});
|
||||
|
||||
// Track CTA clicks
|
||||
document.querySelectorAll('[href="/register"]').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
try { localStorage.setItem('turf_signup_source', 'landing'); } catch(_) {}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
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)
|
||||
182
login.html
Normal file
182
login.html
Normal file
@@ -0,0 +1,182 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Connexion — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--error: #f85149; --radius: 10px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
|
||||
main { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px 20px; }
|
||||
.auth-card {
|
||||
width: 100%; max-width: 420px;
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 40px;
|
||||
}
|
||||
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 18px; }
|
||||
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input {
|
||||
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus { border-color: var(--green); }
|
||||
input.error { border-color: var(--error); }
|
||||
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||
.field-error.show { display: block; }
|
||||
.forgot { float: right; font-size: .82rem; color: var(--muted); }
|
||||
.forgot:hover { color: var(--text); }
|
||||
.btn {
|
||||
width: 100%; padding: 12px; border: none; border-radius: var(--radius);
|
||||
font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
|
||||
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/register" class="nav-link">Pas encore de compte ? S'inscrire →</a>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Bon retour !</h1>
|
||||
<p class="auth-subtitle">Connectez-vous à votre compte Turf IA.</p>
|
||||
|
||||
<div class="alert alert-error" id="alert-error"></div>
|
||||
|
||||
<form id="login-form" novalidate>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||
<div class="field-error" id="email-error">Email invalide.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">
|
||||
Mot de passe
|
||||
<a href="/forgot-password" class="forgot">Mot de passe oublié ?</a>
|
||||
</label>
|
||||
<input type="password" id="password" name="password" placeholder="••••••••" autocomplete="current-password" required>
|
||||
<div class="field-error" id="password-error">Mot de passe requis.</div>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Se connecter</button>
|
||||
</form>
|
||||
|
||||
<div class="divider">ou</div>
|
||||
<div class="auth-footer">
|
||||
Pas encore de compte ? <a href="/register">Créer un compte gratuit</a>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
|
||||
const form = document.getElementById('login-form');
|
||||
const emailInput = document.getElementById('email');
|
||||
const passInput = document.getElementById('password');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const alertBox = document.getElementById('alert-error');
|
||||
|
||||
function showError(msg) {
|
||||
alertBox.textContent = msg;
|
||||
alertBox.classList.add('show');
|
||||
}
|
||||
function hideError() { alertBox.classList.remove('show'); }
|
||||
|
||||
function setLoading(on) {
|
||||
submitBtn.disabled = on;
|
||||
submitBtn.innerHTML = on ? '<span class="loader"></span>Connexion…' : 'Se connecter';
|
||||
}
|
||||
|
||||
function validateField(input, errId, condition, msg) {
|
||||
const err = document.getElementById(errId);
|
||||
if (condition) {
|
||||
input.classList.add('error');
|
||||
err.textContent = msg;
|
||||
err.classList.add('show');
|
||||
return false;
|
||||
}
|
||||
input.classList.remove('error');
|
||||
err.classList.remove('show');
|
||||
return true;
|
||||
}
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
|
||||
const email = emailInput.value.trim();
|
||||
const pass = passInput.value;
|
||||
|
||||
const v1 = validateField(emailInput, 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Adresse email invalide.');
|
||||
const v2 = validateField(passInput, 'password-error', !pass, 'Mot de passe requis.');
|
||||
if (!v1 || !v2) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password: pass })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showError(data.error || 'Identifiants incorrects. Veuillez réessayer.');
|
||||
} else {
|
||||
localStorage.setItem('turf_token', data.token);
|
||||
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||
const next = new URLSearchParams(location.search).get('next') || '/dashboard';
|
||||
location.href = next;
|
||||
}
|
||||
} catch(_) {
|
||||
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Remove errors on typing
|
||||
[emailInput, passInput].forEach(el => el.addEventListener('input', () => {
|
||||
el.classList.remove('error');
|
||||
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||
hideError();
|
||||
}));
|
||||
|
||||
// If already logged in, redirect
|
||||
(function() {
|
||||
const token = localStorage.getItem('turf_token');
|
||||
if (token) location.href = '/dashboard';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
255
metrics.py
Normal file
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")
|
||||
600
ml_feedback_saas.py
Normal file
600
ml_feedback_saas.py
Normal file
@@ -0,0 +1,600 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ml_feedback_saas.py — Feedback loop ML pour turf_saas.
|
||||
Enregistre les paris virtuels XGBoost depuis ml_predictions_cache
|
||||
et met à jour les résultats/dividendes depuis pmu_partants + pmu_rapports.
|
||||
|
||||
DB cible : /home/h3r7/turf_saas/turf_saas.db
|
||||
|
||||
Stratégies :
|
||||
A) xgboost_sg : simple_gagnant — top1 ML par course, ml_score >= 70, mise 1€
|
||||
B) xgboost_value : simple_gagnant — is_value_bet = 1, mise 1€
|
||||
C) xgboost_sp : simple_place — top1 ML par course, ml_score >= 50, mise 1€
|
||||
D) xgboost_2sur4 : deux_sur_quatre — top4 ML par course, 6 combos x 1€ = mise 6€
|
||||
|
||||
Usage :
|
||||
python3 ml_feedback_saas.py # Traite aujourd'hui
|
||||
python3 ml_feedback_saas.py --backfill 2026-04-25
|
||||
python3 ml_feedback_saas.py --date 2026-04-25
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import sys
|
||||
import logging
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
|
||||
os.makedirs("/home/h3r7/turf_saas/logs", exist_ok=True)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [ml_feedback_saas] %(levelname)s %(message)s",
|
||||
handlers=[
|
||||
logging.FileHandler("/home/h3r7/turf_saas/logs/ml_feedback_saas.log"),
|
||||
logging.StreamHandler(),
|
||||
],
|
||||
)
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UTILITAIRES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def pari_existe(cursor, date, num_reunion, num_course, numero1, type_pari, source_reco):
|
||||
"""Vérifie si un pari identique existe déjà (idempotence)."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND type_pari = ? AND numero1 = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, type_pari, numero1, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def pari_2sur4_existe(cursor, date, num_reunion, num_course, source_reco):
|
||||
"""Vérifie si un pari 2sur4 existe déjà pour cette course."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id FROM paris
|
||||
WHERE date_course = ? AND source_reco = ?
|
||||
AND race_label = ?
|
||||
""",
|
||||
(date, source_reco, f"R{num_reunion}C{num_course}"),
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
|
||||
|
||||
def get_top_ml_par_course(cursor, date, n=4, min_score=0):
|
||||
"""Retourne les n meilleurs chevaux ML par course pour une date."""
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, recommendation, is_value_bet,
|
||||
race_label, race_name, hippodrome, heure,
|
||||
discipline, distance
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND ml_score >= ?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date, min_score),
|
||||
)
|
||||
rows = cursor.fetchall()
|
||||
|
||||
courses = {}
|
||||
for r in rows:
|
||||
key = (r["num_reunion"], r["num_course"])
|
||||
if key not in courses:
|
||||
courses[key] = []
|
||||
if len(courses[key]) < n:
|
||||
courses[key].append(dict(r))
|
||||
return courses
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE A — Simple Gagnant top1 ML (score >= 70)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sg(conn, date):
|
||||
"""Insère 1 pari simple_gagnant par course : top1 ML avec ml_score >= 70."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=70)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_sg",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sg', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SG] {date} → {inseres} paris simple_gagnant insérés (score>=70)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE B — Value Bet (is_value_bet = 1)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_value(conn, date):
|
||||
"""Insère 1 pari simple_gagnant pour chaque cheval is_value_bet=1."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT num_reunion, num_course, horse_name, horse_number,
|
||||
ml_score, odds, race_label, race_name, hippodrome
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ? AND is_value_bet = 1
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
rows = [dict(r) for r in cursor.fetchall()]
|
||||
inseres = 0
|
||||
|
||||
for r in rows:
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["horse_number"],
|
||||
"simple_gagnant",
|
||||
"xgboost_value",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_gagnant', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_value', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
r.get("race_name") or "",
|
||||
r.get("race_label") or f"R{r['num_reunion']}C{r['num_course']}",
|
||||
r.get("hippodrome") or "",
|
||||
r["horse_name"],
|
||||
r["horse_name"],
|
||||
r["horse_number"],
|
||||
r["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[VALUE] {date} → {inseres} paris value_bet insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE C — Simple Placé top1 ML (score >= 50)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_sp(conn, date):
|
||||
"""Insère 1 pari simple_place par course : top1 ML avec ml_score >= 50."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=1, min_score=50)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
cheval = chevaux[0]
|
||||
if pari_existe(
|
||||
cursor,
|
||||
date,
|
||||
num_reunion,
|
||||
num_course,
|
||||
cheval["horse_number"],
|
||||
"simple_place",
|
||||
"xgboost_sp",
|
||||
):
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source)
|
||||
VALUES (?, ?, ?, ?, ?, 'simple_place', ?, ?, ?, ?, 1.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_sp', 'xgboost_v1')
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
cheval.get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
cheval.get("hippodrome") or "",
|
||||
cheval["horse_name"],
|
||||
cheval["horse_name"],
|
||||
cheval["horse_number"],
|
||||
cheval["odds"],
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[SP] {date} → {inseres} paris simple_place insérés (score>=50)")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STRATÉGIE D — 2sur4 top4 ML (6 combinaisons x 1€)
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def save_ml_paris_2sur4(conn, date):
|
||||
"""Insère 1 pari deux_sur_quatre par course : top4 ML, mise 6€."""
|
||||
cursor = conn.cursor()
|
||||
courses = get_top_ml_par_course(cursor, date, n=4, min_score=0)
|
||||
inseres = 0
|
||||
|
||||
for (num_reunion, num_course), chevaux in courses.items():
|
||||
if len(chevaux) < 4:
|
||||
continue
|
||||
if pari_2sur4_existe(cursor, date, num_reunion, num_course, "xgboost_2sur4"):
|
||||
continue
|
||||
|
||||
top4 = chevaux[:4]
|
||||
nums = [str(c["horse_number"]) for c in top4]
|
||||
noms = [c["horse_name"] for c in top4]
|
||||
chevaux_str = "/".join(noms)
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO paris
|
||||
(date_pari, date_course, race_name, race_label, hippodrome,
|
||||
type_pari, chevaux, cheval1, numero1, cote, mise,
|
||||
statut, gain, source_reco, model_source, commentaire)
|
||||
VALUES (?, ?, ?, ?, ?, 'deux_sur_quatre', ?, ?, ?, 0.0, 6.0,
|
||||
'EN_ATTENTE', 0.0, 'xgboost_2sur4', 'xgboost_v1', ?)
|
||||
""",
|
||||
(
|
||||
date,
|
||||
date,
|
||||
top4[0].get("race_name") or "",
|
||||
f"R{num_reunion}C{num_course}",
|
||||
top4[0].get("hippodrome") or "",
|
||||
chevaux_str,
|
||||
top4[0]["horse_name"],
|
||||
top4[0]["horse_number"],
|
||||
f"top4 ML: {'/'.join(nums)}",
|
||||
),
|
||||
)
|
||||
inseres += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[2S4] {date} → {inseres} paris deux_sur_quatre insérés")
|
||||
return inseres
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# UPDATE RÉSULTATS + DIVIDENDES
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def update_ml_paris_results(conn, date):
|
||||
"""
|
||||
Met à jour statut + gain (dividende PMU réel) pour tous les paris ML EN_ATTENTE.
|
||||
Sources: pmu_partants (ordre_arrivee) + pmu_rapports (dividende_euro).
|
||||
"""
|
||||
cursor = conn.cursor()
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT id, race_label, type_pari, numero1, chevaux, mise, source_reco, commentaire
|
||||
FROM paris
|
||||
WHERE date_course = ? AND statut = 'EN_ATTENTE'
|
||||
AND source_reco LIKE 'xgboost%'
|
||||
""",
|
||||
(date,),
|
||||
)
|
||||
paris = [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
if not paris:
|
||||
log.info(f"[UPDATE] {date} → aucun pari ML EN_ATTENTE")
|
||||
return 0
|
||||
|
||||
maj = 0
|
||||
for pari in paris:
|
||||
pari_id = pari["id"]
|
||||
race_label = pari["race_label"] or ""
|
||||
type_pari = pari["type_pari"]
|
||||
numero1 = pari["numero1"]
|
||||
mise = pari["mise"]
|
||||
|
||||
# Extraire num_reunion / num_course depuis le race_label "R{r}C{c}"
|
||||
try:
|
||||
parts = race_label.replace("R", "").split("C")
|
||||
num_reunion = int(parts[0])
|
||||
num_course = int(parts[1])
|
||||
except Exception:
|
||||
log.warning(f"[UPDATE] race_label invalide : {race_label}")
|
||||
continue
|
||||
|
||||
if type_pari == "simple_gagnant":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or row["ordre_arrivee"] is None or row["ordre_arrivee"] == 0:
|
||||
continue
|
||||
|
||||
gagne = row["ordre_arrivee"] == 1
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_GAGNANT'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "simple_place":
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT ordre_arrivee FROM pmu_partants
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND num_pmu = ?
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
row = cursor.fetchone()
|
||||
if not row or not row["ordre_arrivee"]:
|
||||
continue
|
||||
|
||||
gagne = 1 <= row["ordre_arrivee"] <= 3
|
||||
gain = 0.0
|
||||
if gagne:
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'SIMPLE_PLACE'
|
||||
AND CAST(combinaison AS INTEGER) = ?
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course, numero1),
|
||||
)
|
||||
div = cursor.fetchone()
|
||||
gain = div["dividende_euro"] if div and div["dividende_euro"] else 0.0
|
||||
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", gain, pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
elif type_pari == "deux_sur_quatre":
|
||||
# Récupère les 4 numéros depuis commentaire "top4 ML: n1/n2/n3/n4"
|
||||
try:
|
||||
nums_str = (
|
||||
pari["commentaire"].split(": ")[1]
|
||||
if pari.get("commentaire")
|
||||
else ""
|
||||
)
|
||||
nums_top4 = [int(n) for n in nums_str.split("/") if n.strip().isdigit()]
|
||||
except Exception:
|
||||
nums_top4 = []
|
||||
|
||||
if len(nums_top4) < 4:
|
||||
# Fallback : reconstituer top4 depuis ml_predictions_cache
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT horse_number FROM ml_predictions_cache
|
||||
WHERE date = ? AND num_reunion = ? AND num_course = ?
|
||||
ORDER BY ml_score DESC LIMIT 4
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
nums_top4 = [r["horse_number"] for r in cursor.fetchall()]
|
||||
|
||||
if len(nums_top4) < 2:
|
||||
continue
|
||||
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT combinaison, dividende_euro FROM pmu_rapports
|
||||
WHERE date_programme = ? AND num_reunion = ?
|
||||
AND num_course = ? AND type_pari = 'DEUX_SUR_QUATRE'
|
||||
AND libelle NOT LIKE '%NP%'
|
||||
""",
|
||||
(date, num_reunion, num_course),
|
||||
)
|
||||
rapports = [dict(r) for r in cursor.fetchall()]
|
||||
gain_total = 0.0
|
||||
|
||||
for rap in rapports:
|
||||
try:
|
||||
n1, n2 = [int(x) for x in rap["combinaison"].split("-")]
|
||||
except Exception:
|
||||
continue
|
||||
if n1 in nums_top4 and n2 in nums_top4:
|
||||
gain_total += rap["dividende_euro"]
|
||||
|
||||
gagne = gain_total > 0
|
||||
cursor.execute(
|
||||
"UPDATE paris SET statut=?, gain=? WHERE id=?",
|
||||
("GAGNE" if gagne else "PERDU", round(gain_total, 2), pari_id),
|
||||
)
|
||||
maj += 1
|
||||
|
||||
conn.commit()
|
||||
log.info(f"[UPDATE] {date} → {maj}/{len(paris)} paris ML mis à jour")
|
||||
return maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# STATS PAR STRATÉGIE
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def get_feedback_stats(conn, date_debut=None, date_fin=None):
|
||||
"""Stats performances ML par stratégie (source_reco)."""
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT source_reco,
|
||||
COUNT(*) as n_paris,
|
||||
SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END) as n_gagne,
|
||||
SUM(CASE WHEN statut='PERDU' THEN 1 ELSE 0 END) as n_perdu,
|
||||
SUM(CASE WHEN statut='EN_ATTENTE' THEN 1 ELSE 0 END) as n_attente,
|
||||
ROUND(100.0 * SUM(CASE WHEN statut='GAGNE' THEN 1 ELSE 0 END)
|
||||
/ NULLIF(SUM(CASE WHEN statut IN ('GAGNE','PERDU') THEN 1 ELSE 0 END), 0), 1) as win_rate_pct,
|
||||
ROUND(SUM(gain), 2) as gain_total,
|
||||
ROUND(SUM(mise), 2) as mise_totale,
|
||||
ROUND(SUM(gain) - SUM(mise), 2) as roi_net
|
||||
FROM paris
|
||||
WHERE source_reco LIKE 'xgboost%'
|
||||
AND (:debut IS NULL OR date_course >= :debut)
|
||||
AND (:fin IS NULL OR date_course <= :fin)
|
||||
GROUP BY source_reco
|
||||
ORDER BY source_reco
|
||||
""",
|
||||
{"debut": date_debut, "fin": date_fin},
|
||||
)
|
||||
return [dict(r) for r in cursor.fetchall()]
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# PIPELINE COMPLET
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def run(date):
|
||||
"""Enregistre les paris ML du jour + met à jour les résultats de J-1."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.run({date}) ===")
|
||||
|
||||
# 1. Enregistre les paris ML pour la date (depuis le cache du jour)
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → total insérés : SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
# 2. Met à jour les résultats de J-1 (résultats PMU disponibles)
|
||||
yesterday = (datetime.strptime(date, "%Y-%m-%d") - timedelta(days=1)).strftime(
|
||||
"%Y-%m-%d"
|
||||
)
|
||||
maj = update_ml_paris_results(conn, yesterday)
|
||||
log.info(f"[UPDATE] {yesterday} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return {"inseres": {"sg": sg, "value": vb, "sp": sp, "2sur4": s4}, "maj": maj}
|
||||
|
||||
|
||||
def backfill(date):
|
||||
"""Backfill : insère ET met à jour les résultats pour une date passée."""
|
||||
conn = get_db()
|
||||
log.info(f"=== ml_feedback_saas.backfill({date}) ===")
|
||||
|
||||
sg = save_ml_paris_sg(conn, date)
|
||||
vb = save_ml_paris_value(conn, date)
|
||||
sp = save_ml_paris_sp(conn, date)
|
||||
s4 = save_ml_paris_2sur4(conn, date)
|
||||
log.info(f"[SAVE] {date} → SG={sg} VALUE={vb} SP={sp} 2S4={s4}")
|
||||
|
||||
maj = update_ml_paris_results(conn, date)
|
||||
log.info(f"[UPDATE] {date} → {maj} paris mis à jour")
|
||||
|
||||
conn.close()
|
||||
return sg + vb + sp + s4, maj
|
||||
|
||||
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# MAIN
|
||||
# ─────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if "--backfill" in sys.argv:
|
||||
idx = sys.argv.index("--backfill")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --backfill YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
inseres, maj = backfill(date)
|
||||
print(f"Backfill {date} : {inseres} paris insérés, {maj} mis à jour")
|
||||
|
||||
elif "--date" in sys.argv:
|
||||
idx = sys.argv.index("--date")
|
||||
date = sys.argv[idx + 1] if idx + 1 < len(sys.argv) else None
|
||||
if not date:
|
||||
print("Usage: python3 ml_feedback_saas.py --date YYYY-MM-DD")
|
||||
sys.exit(1)
|
||||
result = run(date)
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run {date} : {total} paris insérés, {result['maj']} mis à jour")
|
||||
|
||||
else:
|
||||
result = run(datetime.now().strftime("%Y-%m-%d"))
|
||||
total = sum(result["inseres"].values())
|
||||
print(f"Run today : {total} paris insérés, {result['maj']} mis à jour")
|
||||
@@ -15,7 +15,7 @@ import sqlite3
|
||||
import re
|
||||
import os
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
DB_PATH = "/home/h3r7/turf_saas/turf_saas.db"
|
||||
HEADERS = {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
|
||||
'Accept-Language': 'fr-FR,fr;q=0.9,en;q=0.8',
|
||||
|
||||
335
onboarding.html
Normal file
335
onboarding.html
Normal file
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Bienvenue — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624; --blue: #1e88e5;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--radius: 12px; --gold: #ffd600;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px; }
|
||||
|
||||
.onboarding-wrap { width: 100%; max-width: 580px; }
|
||||
.step-indicator {
|
||||
display: flex; align-items: center; justify-content: center; gap: 0; margin-bottom: 36px;
|
||||
}
|
||||
.step-dot {
|
||||
width: 32px; height: 32px; border-radius: 50%; border: 2px solid var(--border);
|
||||
display: flex; align-items: center; justify-content: center; font-size: .8rem; font-weight: 700;
|
||||
color: var(--muted); background: var(--dark2); transition: all .3s; flex-shrink: 0;
|
||||
}
|
||||
.step-dot.active { border-color: var(--green); color: var(--green); background: rgba(0,200,83,.1); }
|
||||
.step-dot.done { border-color: var(--green); background: var(--green); color: #000; }
|
||||
.step-line { flex: 1; height: 2px; background: var(--border); max-width: 80px; transition: background .3s; }
|
||||
.step-line.done { background: var(--green); }
|
||||
|
||||
.step-panel { display: none; animation: fadeIn .3s ease; }
|
||||
.step-panel.active { display: block; }
|
||||
@keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } }
|
||||
|
||||
.card { background: var(--dark2); border: 1px solid var(--border); border-radius: 16px; padding: 40px; }
|
||||
.step-eyebrow { font-size: .8rem; color: var(--green); font-weight: 700; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 10px; }
|
||||
h2 { font-size: 1.6rem; font-weight: 800; margin-bottom: 10px; }
|
||||
.step-subtitle { color: var(--muted); font-size: .95rem; margin-bottom: 28px; line-height: 1.6; }
|
||||
|
||||
.plan-options { display: flex; flex-direction: column; gap: 12px; margin-bottom: 28px; }
|
||||
.plan-option {
|
||||
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||
padding: 16px 18px; border: 2px solid var(--border); border-radius: var(--radius);
|
||||
cursor: pointer; transition: all .2s; background: var(--dark3);
|
||||
}
|
||||
.plan-option:hover { border-color: var(--muted); }
|
||||
.plan-option.selected { border-color: var(--green); background: rgba(0,200,83,.06); }
|
||||
.plan-option-left { display: flex; align-items: center; gap: 14px; }
|
||||
.plan-icon { font-size: 1.6rem; }
|
||||
.plan-info h3 { font-size: 1rem; font-weight: 700; }
|
||||
.plan-info p { font-size: .82rem; color: var(--muted); margin-top: 2px; }
|
||||
.plan-price-tag { font-weight: 800; font-size: 1rem; color: var(--green); text-align: right; }
|
||||
.plan-price-tag .sub { font-size: .72rem; font-weight: 400; color: var(--muted); }
|
||||
.plan-radio { width: 18px; height: 18px; border-radius: 50%; border: 2px solid var(--border); flex-shrink: 0; transition: all .2s; }
|
||||
.plan-option.selected .plan-radio { border-color: var(--green); background: var(--green); }
|
||||
|
||||
.telegram-field { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 20px; }
|
||||
.telegram-field h3 { font-size: .95rem; font-weight: 700; margin-bottom: 6px; }
|
||||
.telegram-field p { font-size: .83rem; color: var(--muted); margin-bottom: 12px; }
|
||||
.telegram-input-row { display: flex; gap: 10px; }
|
||||
input[type=text], input[type=email] {
|
||||
flex: 1; padding: 10px 14px; background: var(--dark); border: 1px solid var(--border);
|
||||
border-radius: 8px; color: var(--text); font-size: .9rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input[type=text]:focus, input[type=email]:focus { border-color: var(--green); }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 12px 28px; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; border: none; transition: all .2s; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-ghost { background: transparent; border: 1px solid var(--border); color: var(--muted); padding: 12px 20px; }
|
||||
.btn-ghost:hover { color: var(--text); border-color: var(--muted); }
|
||||
.btn-full { width: 100%; justify-content: center; }
|
||||
.btn-row { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; }
|
||||
|
||||
.success-icon { font-size: 4rem; text-align: center; margin-bottom: 20px; }
|
||||
.checklist { list-style: none; margin: 20px 0; }
|
||||
.checklist li { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-bottom: 1px solid var(--border); font-size: .93rem; }
|
||||
.checklist li:last-child { border-bottom: none; }
|
||||
.checklist li::before { content: "✓"; background: var(--green); color: #000; width: 22px; height: 22px; border-radius: 50%; display: flex; align-items: center; justify-content: center; font-size: .75rem; font-weight: 800; flex-shrink: 0; }
|
||||
|
||||
.first-pred-preview { background: var(--dark3); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px; margin: 20px 0; }
|
||||
.pred-row { display: flex; justify-content: space-between; align-items: center; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: .88rem; }
|
||||
.pred-row:last-child { border-bottom: none; }
|
||||
.pred-rank { font-weight: 700; color: var(--gold); width: 24px; }
|
||||
.pred-name { flex: 1; font-weight: 600; }
|
||||
.pred-prob { color: var(--green); font-weight: 700; }
|
||||
.pred-cote { color: var(--muted); font-size: .82rem; }
|
||||
|
||||
footer { margin-top: 24px; color: var(--muted); font-size: .78rem; text-align: center; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="onboarding-wrap">
|
||||
<!-- Step indicator -->
|
||||
<div class="step-indicator">
|
||||
<div class="step-dot active" id="dot-1">1</div>
|
||||
<div class="step-line" id="line-1"></div>
|
||||
<div class="step-dot" id="dot-2">2</div>
|
||||
<div class="step-line" id="line-2"></div>
|
||||
<div class="step-dot" id="dot-3">3</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 1: Welcome + plan confirm -->
|
||||
<div class="step-panel active" id="step-1">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 1 sur 3</div>
|
||||
<h2>Bienvenue sur Turf IA 🏇</h2>
|
||||
<p class="step-subtitle">Votre compte est créé. Confirmez votre plan de départ pour personnaliser votre expérience.</p>
|
||||
|
||||
<div class="plan-options" id="plan-options">
|
||||
<div class="plan-option" data-plan="free">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">🆓</div>
|
||||
<div class="plan-info">
|
||||
<h3>Free</h3>
|
||||
<p>Aperçu quotidien, 1 course complète</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">0€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="premium">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">⭐</div>
|
||||
<div class="plan-info">
|
||||
<h3>Premium</h3>
|
||||
<p>Toutes les courses, alertes Telegram</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">9,90€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
<div class="plan-option" data-plan="pro">
|
||||
<div class="plan-option-left">
|
||||
<div class="plan-icon">🚀</div>
|
||||
<div class="plan-info">
|
||||
<h3>Pro</h3>
|
||||
<p>API, export CSV, support prioritaire</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="plan-price-tag">24,90€<div class="sub">/mois</div></div>
|
||||
</div>
|
||||
<div class="plan-radio"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-primary btn-full" id="step1-next">Continuer →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 2: Alerts & preferences -->
|
||||
<div class="step-panel" id="step-2">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 2 sur 3</div>
|
||||
<h2>Configurez vos alertes</h2>
|
||||
<p class="step-subtitle">Recevez les meilleures opportunités directement sur votre téléphone avant chaque départ.</p>
|
||||
|
||||
<div class="telegram-field">
|
||||
<h3>📱 Alertes Telegram</h3>
|
||||
<p>Pour activer les alertes, envoyez <strong>/start</strong> à notre bot Telegram <strong>@TurfIABot</strong> et collez ci-dessous votre Chat ID.</p>
|
||||
<div class="telegram-input-row">
|
||||
<input type="text" id="telegram-id" placeholder="Votre Chat ID Telegram (ex: 123456789)">
|
||||
<button class="btn btn-ghost" onclick="openTelegramBot()">Ouvrir le bot</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:24px">
|
||||
<p style="font-size:.88rem;color:var(--muted);margin-bottom:12px">🔔 Quand recevoir les alertes :</p>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-vb" checked style="width:auto;accent-color:var(--green)">
|
||||
Value bets identifiés (cote sous-évaluée)
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;margin-bottom:10px;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-top1" checked style="width:auto;accent-color:var(--green)">
|
||||
Favori IA Top-1 de chaque course
|
||||
</label>
|
||||
<label style="display:flex;gap:10px;align-items:center;cursor:pointer;font-size:.9rem">
|
||||
<input type="checkbox" id="alert-quinte" style="width:auto;accent-color:var(--green)">
|
||||
Quinté+ uniquement
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="btn-row">
|
||||
<button class="btn btn-ghost" id="step2-skip">Passer cette étape</button>
|
||||
<button class="btn btn-primary" id="step2-next">Enregistrer & continuer →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- STEP 3: First prediction preview -->
|
||||
<div class="step-panel" id="step-3">
|
||||
<div class="card">
|
||||
<div class="step-eyebrow">Étape 3 sur 3</div>
|
||||
<div class="success-icon">🎉</div>
|
||||
<h2 style="text-align:center">Vous êtes prêt !</h2>
|
||||
<p class="step-subtitle" style="text-align:center">Voici un aperçu de votre première prédiction du jour.</p>
|
||||
|
||||
<div class="first-pred-preview" id="first-pred">
|
||||
<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION EXEMPLE — R1C1</div>
|
||||
<div class="pred-row"><div class="pred-rank">🥇</div><div class="pred-name">CHARGEMENT...</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
<div class="pred-row"><div class="pred-rank">🥈</div><div class="pred-name">—</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
<div class="pred-row"><div class="pred-rank">🥉</div><div class="pred-name">—</div><div class="pred-prob">—%</div><div class="pred-cote">—</div></div>
|
||||
</div>
|
||||
|
||||
<ul class="checklist">
|
||||
<li>Compte créé et configuré</li>
|
||||
<li id="check-alerts">Alertes Telegram configurées</li>
|
||||
<li>Dashboard accessible 24h/24</li>
|
||||
</ul>
|
||||
|
||||
<button class="btn btn-primary btn-full" id="step3-finish">Accéder à mon dashboard →</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
let currentStep = 1;
|
||||
let selectedPlan = 'free';
|
||||
|
||||
function getToken() { return localStorage.getItem('turf_token'); }
|
||||
async function fetchJson(url, opts = {}) {
|
||||
const token = getToken();
|
||||
const res = await fetch(url, {
|
||||
...opts,
|
||||
headers: { 'Content-Type': 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), ...(opts.headers || {}) }
|
||||
});
|
||||
if (res.status === 401) { location.href = '/login'; return null; }
|
||||
return res.ok ? res.json() : null;
|
||||
}
|
||||
|
||||
// Pre-select plan from user storage
|
||||
(function() {
|
||||
if (!getToken()) { location.href = '/login'; return; }
|
||||
try {
|
||||
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
|
||||
selectedPlan = user.plan || 'free';
|
||||
// Mark paid plans as not selectable if not purchased
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
if (el.dataset.plan === selectedPlan) selectPlan(selectedPlan);
|
||||
});
|
||||
} catch(_) { selectPlan('free'); }
|
||||
if (!document.querySelector('.plan-option.selected')) selectPlan('free');
|
||||
})();
|
||||
|
||||
function selectPlan(plan) {
|
||||
selectedPlan = plan;
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
el.classList.toggle('selected', el.dataset.plan === plan);
|
||||
});
|
||||
}
|
||||
|
||||
document.querySelectorAll('.plan-option').forEach(el => {
|
||||
el.addEventListener('click', () => selectPlan(el.dataset.plan));
|
||||
});
|
||||
|
||||
function goToStep(n) {
|
||||
currentStep = n;
|
||||
document.querySelectorAll('.step-panel').forEach((el, i) => el.classList.toggle('active', i + 1 === n));
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
const dot = document.getElementById(`dot-${i}`);
|
||||
dot.classList.toggle('done', i < n);
|
||||
dot.classList.toggle('active', i === n);
|
||||
if (i < 3) document.getElementById(`line-${i}`)?.classList.toggle('done', i < n);
|
||||
}
|
||||
if (n === 3) loadFirstPrediction();
|
||||
}
|
||||
|
||||
document.getElementById('step1-next').addEventListener('click', async () => {
|
||||
// Update plan via API
|
||||
await fetchJson(`${API}/auth/update-plan`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ plan: selectedPlan })
|
||||
});
|
||||
const user = JSON.parse(localStorage.getItem('turf_user') || '{}');
|
||||
user.plan = selectedPlan;
|
||||
localStorage.setItem('turf_user', JSON.stringify(user));
|
||||
// If paid plan, show payment notice (will be handled in Sprint 5-6)
|
||||
if (selectedPlan !== 'free') {
|
||||
goToStep(2);
|
||||
} else {
|
||||
goToStep(2);
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('step2-skip').addEventListener('click', () => { document.getElementById('check-alerts').textContent = 'Alertes Telegram (non configurées)'; goToStep(3); });
|
||||
document.getElementById('step2-next').addEventListener('click', async () => {
|
||||
const chatId = document.getElementById('telegram-id').value.trim();
|
||||
const alertVb = document.getElementById('alert-vb').checked;
|
||||
const alertTop1 = document.getElementById('alert-top1').checked;
|
||||
const alertQ = document.getElementById('alert-quinte').checked;
|
||||
if (chatId) {
|
||||
await fetchJson(`${API}/auth/update-preferences`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ telegram_chat_id: chatId, alert_value_bets: alertVb, alert_top1: alertTop1, alert_quinte_only: alertQ })
|
||||
});
|
||||
}
|
||||
goToStep(3);
|
||||
});
|
||||
|
||||
document.getElementById('step3-finish').addEventListener('click', () => {
|
||||
localStorage.removeItem('turf_onboarding');
|
||||
location.href = '/dashboard';
|
||||
});
|
||||
|
||||
function openTelegramBot() {
|
||||
window.open('https://t.me/TurfIABot', '_blank');
|
||||
}
|
||||
|
||||
async function loadFirstPrediction() {
|
||||
const data = await fetchJson(`${API}/predictions/today`);
|
||||
if (!data || !data.predictions || data.predictions.length === 0) return;
|
||||
const sorted = [...data.predictions].sort((a, b) => b.ml_score - a.ml_score).slice(0, 3);
|
||||
const container = document.getElementById('first-pred');
|
||||
const label = data.predictions[0]?.race_label || 'R1C1';
|
||||
container.innerHTML = `<div style="font-size:.8rem;color:var(--muted);margin-bottom:10px;font-weight:700">PRÉDICTION DU JOUR — ${label}</div>` +
|
||||
sorted.map((h, i) => `<div class="pred-row">
|
||||
<div class="pred-rank">${['🥇','🥈','🥉'][i]}</div>
|
||||
<div class="pred-name">${h.horse_name || '—'}</div>
|
||||
<div class="pred-prob">${h.prob_top3 ? (h.prob_top3*100).toFixed(1)+'%' : '—'}</div>
|
||||
<div class="pred-cote">${h.odds ? h.odds.toFixed(1) : '—'}</div>
|
||||
</div>`).join('');
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org DB — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Migration idempotente : crée les tables organizations et org_members
|
||||
dans turf_saas.db si elles n'existent pas.
|
||||
|
||||
Run une seule fois :
|
||||
./venv/bin/python org_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.org_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_org_tables():
|
||||
"""
|
||||
Migration idempotente : crée organizations + org_members.
|
||||
|
||||
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
max_members INTEGER NOT NULL DEFAULT 5,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS org_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('owner', 'member')),
|
||||
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
joined_at DATETIME,
|
||||
UNIQUE(org_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||
print("[org_db] Migration OK: organizations, org_members.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_org_tables()
|
||||
@@ -38,7 +38,7 @@ from pathlib import Path
|
||||
# ─────────────────────────────────────────────────────────
|
||||
# CONFIG
|
||||
# ─────────────────────────────────────────────────────────
|
||||
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"
|
||||
|
||||
|
||||
199
portal_server.py
199
portal_server.py
@@ -5,22 +5,80 @@ 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"
|
||||
COMBINED_API_URL = "http://localhost:8790"
|
||||
SAAS_DIR = "/home/h3r7/turf_saas"
|
||||
|
||||
# ─── SaaS Auth & API v1 blueprints ────────────────────────────────────────────
|
||||
try:
|
||||
from saas_auth import auth_bp
|
||||
from saas_api_v1 import api_v1_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(api_v1_bp)
|
||||
print("[portal] SaaS auth & API v1 blueprints registered ✅")
|
||||
except Exception as e:
|
||||
print(f"[portal] Warning: could not register SaaS blueprints: {e}")
|
||||
|
||||
|
||||
# ─── Landing & SaaS pages ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@app.route("/health")
|
||||
def health():
|
||||
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
|
||||
return {"status": "ok", "service": "portal"}, 200
|
||||
|
||||
|
||||
@app.route("/")
|
||||
def portal():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "portail.html")
|
||||
def landing():
|
||||
"""Marketing landing page."""
|
||||
return send_from_directory(SAAS_DIR, "landing.html")
|
||||
|
||||
|
||||
@app.route("/login")
|
||||
def login_page():
|
||||
return send_from_directory(SAAS_DIR, "login.html")
|
||||
|
||||
|
||||
@app.route("/register")
|
||||
def register_page():
|
||||
return send_from_directory(SAAS_DIR, "register.html")
|
||||
|
||||
|
||||
@app.route("/dashboard")
|
||||
def dashboard_saas():
|
||||
return send_from_directory(SAAS_DIR, "dashboard_saas.html")
|
||||
|
||||
|
||||
@app.route("/onboarding")
|
||||
def onboarding():
|
||||
return send_from_directory(SAAS_DIR, "onboarding.html")
|
||||
|
||||
|
||||
@app.route("/account")
|
||||
def account():
|
||||
return send_from_directory(SAAS_DIR, "account.html")
|
||||
|
||||
|
||||
@app.route("/portal")
|
||||
@app.route("/portail")
|
||||
def portal_legacy():
|
||||
"""Legacy portal redirect."""
|
||||
return send_from_directory(SAAS_DIR, "portail.html")
|
||||
|
||||
|
||||
@app.route("/favicon.ico")
|
||||
def favicon():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "favicon.ico")
|
||||
return send_from_directory(SAAS_DIR, "favicon.ico")
|
||||
|
||||
|
||||
@app.route("/prompts", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@app.route("/prompts/", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@app.route("/prompts/<path:subpath>", methods=["GET", "POST", "PUT", "DELETE", "PATCH"])
|
||||
@@ -269,9 +327,7 @@ def niches_business():
|
||||
|
||||
@app.route("/template_restaurant_json.html")
|
||||
def template_restaurant():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "template_restaurant_json.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_json.html")
|
||||
|
||||
|
||||
@app.route("/template_boulangerie_final.html")
|
||||
@@ -288,9 +344,7 @@ def template_artisan():
|
||||
|
||||
@app.route("/template_restaurant_final.html")
|
||||
def template_restaurant_final():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "template_restaurant_final.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "template_restaurant_final.html")
|
||||
|
||||
|
||||
@app.route("/template_complet.html")
|
||||
@@ -300,9 +354,7 @@ def template_complet():
|
||||
|
||||
@app.route("/boite_a_idees_dashboard")
|
||||
def boite_a_idees_dashboard():
|
||||
return send_from_directory(
|
||||
"/home/h3r7/turf_saas", "boite_a_idees_dashboard.html"
|
||||
)
|
||||
return send_from_directory("/home/h3r7/turf_saas", "boite_a_idees_dashboard.html")
|
||||
|
||||
|
||||
@app.route("/datagouv_explorer.html")
|
||||
@@ -345,13 +397,23 @@ def api_chat_workflows():
|
||||
return jsonify([dict(w) for w in workflows])
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/chat/nvidia-models", methods=["GET"])
|
||||
def api_nvidia_models():
|
||||
return jsonify([
|
||||
{"id": k, "name": v.split("/")[-1].replace("-instruct", "").replace("-", " ").title(), "full_id": v}
|
||||
for k, v in NVIDIA_MODELS.items()
|
||||
])
|
||||
|
||||
return jsonify(
|
||||
[
|
||||
{
|
||||
"id": k,
|
||||
"name": v.split("/")[-1]
|
||||
.replace("-instruct", "")
|
||||
.replace("-", " ")
|
||||
.title(),
|
||||
"full_id": v,
|
||||
}
|
||||
for k, v in NVIDIA_MODELS.items()
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@app.route("/api/chat/sessions", methods=["GET"])
|
||||
@@ -457,7 +519,9 @@ def api_chat_cleanup():
|
||||
|
||||
OPENCLAW_TOKEN = "szDd75yG15ZRADWEhGus3dfNoQve7ZhdxpGq9gudeEzoVhUqB0TomVJd90XcwkUz"
|
||||
OPENCLAW_CONTAINER = "openclaw-ow404080wwkkgkgc4oswwssc"
|
||||
NVIDIA_API_KEY = "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"
|
||||
NVIDIA_API_KEY = (
|
||||
"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"
|
||||
)
|
||||
NVIDIA_API_URL = "https://integrate.api.nvidia.com/v1/chat/completions"
|
||||
NVIDIA_MODEL = "meta/llama-3.1-8b-instruct" # Default model
|
||||
NVIDIA_MODELS = {
|
||||
@@ -476,7 +540,6 @@ NVIDIA_MODELS = {
|
||||
}
|
||||
|
||||
|
||||
|
||||
@app.route("/webhook/telegram", methods=["POST"])
|
||||
def telegram_webhook():
|
||||
try:
|
||||
@@ -542,25 +605,25 @@ def webhook_proxy(workflow_slug):
|
||||
model_key = request.json.get("model", "llama-3.1-8b")
|
||||
model_id = NVIDIA_MODELS.get(model_key, NVIDIA_MODEL)
|
||||
resp = requests.post(
|
||||
NVIDIA_API_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": user_message}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
NVIDIA_API_URL,
|
||||
headers={
|
||||
"Authorization": f"Bearer {NVIDIA_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
json={
|
||||
"model": model_id,
|
||||
"messages": [{"role": "user", "content": user_message}],
|
||||
"max_tokens": 1024,
|
||||
"temperature": 0.7,
|
||||
},
|
||||
timeout=60,
|
||||
)
|
||||
data = resp.json()
|
||||
ai_response = (
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", str(data))
|
||||
)
|
||||
data.get("choices", [{}])[0]
|
||||
.get("message", {})
|
||||
.get("content", str(data))
|
||||
)
|
||||
else:
|
||||
# Proxy vers webhook n8n
|
||||
resp = requests.post(
|
||||
@@ -680,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}"
|
||||
@@ -702,12 +775,23 @@ def api_proxy(api_path=""):
|
||||
url = f"{DASHBOARD_API_URL}/turf/api"
|
||||
try:
|
||||
fwd_method = request.method
|
||||
fwd_json = request.get_json(silent=True) if fwd_method in ("POST", "PUT", "PATCH") else None
|
||||
fwd_json = (
|
||||
request.get_json(silent=True)
|
||||
if fwd_method in ("POST", "PUT", "PATCH")
|
||||
else None
|
||||
)
|
||||
# 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")
|
||||
resp = requests.request(method=fwd_method, url=url, json=fwd_json, timeout=30,
|
||||
headers=fwd_headers)
|
||||
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,
|
||||
)
|
||||
return resp.content, resp.status_code, {"Content-Type": "application/json"}
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e), "url": url}), 500
|
||||
@@ -744,23 +828,26 @@ def opencode_api():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
|
||||
@app.route("/candidatures/")
|
||||
def candidatures_index():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "crm_candidatures.html")
|
||||
|
||||
|
||||
@app.route("/candidatures/<path:filename>")
|
||||
def candidatures_static(filename):
|
||||
return send_from_directory("/home/h3r7/turf_saas", filename)
|
||||
|
||||
|
||||
@app.route("/map")
|
||||
def map_visual():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "map_visual.html")
|
||||
|
||||
|
||||
@app.route("/architecture.json")
|
||||
def architecture_json():
|
||||
return send_from_directory("/home/h3r7/turf_saas", "architecture.json")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.run(host="0.0.0.0", port=8792, debug=False)
|
||||
|
||||
@@ -827,5 +914,3 @@ def proxy_prompts_test():
|
||||
return response
|
||||
except Exception as e:
|
||||
return f"Erreur proxy prompts: {e}", 502
|
||||
|
||||
|
||||
|
||||
@@ -10,3 +10,4 @@ markers =
|
||||
load: Tests de charge Locust
|
||||
security: Tests de sécurité
|
||||
smoke: Tests rapides de smoke
|
||||
integration: Tests d'intégration DB et pipeline ML
|
||||
|
||||
271
register.html
Normal file
271
register.html
Normal file
@@ -0,0 +1,271 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Inscription — Turf IA</title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root {
|
||||
--green: #00c853; --green-d: #009624;
|
||||
--dark: #0d1117; --dark2: #161b22; --dark3: #21262d;
|
||||
--text: #e6edf3; --muted: #8b949e; --border: #30363d;
|
||||
--error: #f85149; --radius: 10px;
|
||||
}
|
||||
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--dark); color: var(--text); min-height: 100vh; display: flex; flex-direction: column; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
nav { display: flex; align-items: center; justify-content: space-between; padding: 16px 5%; border-bottom: 1px solid var(--border); }
|
||||
.nav-logo { font-weight: 700; font-size: 1.1rem; }
|
||||
.nav-link { color: var(--muted); font-size: .9rem; }
|
||||
.nav-link:hover { color: var(--text); }
|
||||
main { flex: 1; display: flex; align-items: flex-start; justify-content: center; padding: 40px 20px; gap: 40px; flex-wrap: wrap; }
|
||||
.auth-card { width: 100%; max-width: 440px; background: var(--dark2); border: 1px solid var(--border); border-radius: 14px; padding: 40px; }
|
||||
.plan-preview { width: 100%; max-width: 300px; }
|
||||
.auth-title { font-size: 1.5rem; font-weight: 800; margin-bottom: 6px; }
|
||||
.auth-subtitle { color: var(--muted); font-size: .9rem; margin-bottom: 28px; }
|
||||
.form-group { margin-bottom: 16px; }
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
|
||||
label { display: block; font-size: .85rem; font-weight: 600; color: var(--muted); margin-bottom: 6px; }
|
||||
input, select {
|
||||
width: 100%; padding: 11px 14px; background: var(--dark3);
|
||||
border: 1px solid var(--border); border-radius: var(--radius);
|
||||
color: var(--text); font-size: .95rem; outline: none; transition: border-color .2s;
|
||||
}
|
||||
input:focus, select:focus { border-color: var(--green); }
|
||||
input.error { border-color: var(--error); }
|
||||
select option { background: var(--dark3); }
|
||||
.field-error { color: var(--error); font-size: .8rem; margin-top: 4px; display: none; }
|
||||
.field-error.show { display: block; }
|
||||
.btn { width: 100%; padding: 12px; border: none; border-radius: var(--radius); font-size: 1rem; font-weight: 700; cursor: pointer; transition: all .2s; margin-top: 4px; }
|
||||
.btn-primary { background: var(--green); color: #000; }
|
||||
.btn-primary:hover { background: var(--green-d); }
|
||||
.btn-primary:disabled { opacity: .6; cursor: not-allowed; }
|
||||
.divider { display: flex; align-items: center; gap: 12px; margin: 20px 0; color: var(--muted); font-size: .82rem; }
|
||||
.divider::before, .divider::after { content: ''; flex: 1; height: 1px; background: var(--border); }
|
||||
.auth-footer { text-align: center; margin-top: 20px; color: var(--muted); font-size: .9rem; }
|
||||
.auth-footer a { color: var(--green); font-weight: 600; }
|
||||
.alert { padding: 12px 16px; border-radius: var(--radius); font-size: .88rem; margin-bottom: 18px; display: none; }
|
||||
.alert.show { display: block; }
|
||||
.alert-error { background: rgba(248,81,73,.12); border: 1px solid rgba(248,81,73,.3); color: #f85149; }
|
||||
.loader { display: inline-block; width: 16px; height: 16px; border: 2px solid rgba(0,0,0,.3); border-top-color: #000; border-radius: 50%; animation: spin .7s linear infinite; vertical-align: middle; margin-right: 6px; }
|
||||
@keyframes spin { to { transform: rotate(360deg); } }
|
||||
.terms { font-size: .8rem; color: var(--muted); line-height: 1.6; }
|
||||
.terms a { color: var(--green); }
|
||||
.password-strength { height: 4px; border-radius: 2px; margin-top: 6px; background: var(--border); overflow: hidden; }
|
||||
.strength-bar { height: 100%; width: 0; transition: width .3s, background .3s; }
|
||||
|
||||
/* Plan preview sidebar */
|
||||
.plan-card {
|
||||
background: var(--dark2); border: 1px solid var(--border);
|
||||
border-radius: 14px; padding: 24px; margin-bottom: 16px;
|
||||
transition: border-color .3s;
|
||||
}
|
||||
.plan-card.selected { border-color: var(--green); }
|
||||
.plan-card-name { font-weight: 700; font-size: 1rem; display: flex; justify-content: space-between; align-items: center; }
|
||||
.plan-card-price { font-size: 1.5rem; font-weight: 800; margin: 8px 0; }
|
||||
.plan-card-features { list-style: none; margin-top: 12px; }
|
||||
.plan-card-features li { font-size: .83rem; color: var(--muted); padding: 4px 0; }
|
||||
.plan-card-features li::before { content: "✓ "; color: var(--green); }
|
||||
.plan-badge { background: var(--green); color: #000; padding: 2px 8px; border-radius: 10px; font-size: .7rem; font-weight: 700; }
|
||||
|
||||
footer { text-align: center; padding: 20px; color: var(--muted); font-size: .8rem; border-top: 1px solid var(--border); }
|
||||
@media (max-width: 768px) { .plan-preview { display: none; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav>
|
||||
<a href="/" class="nav-logo">🏇 Turf IA</a>
|
||||
<a href="/login" class="nav-link">Déjà un compte ? Se connecter →</a>
|
||||
</nav>
|
||||
<main>
|
||||
<div class="auth-card">
|
||||
<h1 class="auth-title">Créer votre compte</h1>
|
||||
<p class="auth-subtitle">Commencez gratuitement, sans carte bancaire.</p>
|
||||
|
||||
<div class="alert alert-error" id="alert-error"></div>
|
||||
|
||||
<form id="register-form" novalidate>
|
||||
<div class="form-row">
|
||||
<div class="form-group">
|
||||
<label for="firstname">Prénom</label>
|
||||
<input type="text" id="firstname" name="firstname" placeholder="Jean" autocomplete="given-name" required>
|
||||
<div class="field-error" id="firstname-error">Requis.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="lastname">Nom</label>
|
||||
<input type="text" id="lastname" name="lastname" placeholder="Dupont" autocomplete="family-name" required>
|
||||
<div class="field-error" id="lastname-error">Requis.</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="email">Adresse email</label>
|
||||
<input type="email" id="email" name="email" placeholder="vous@exemple.fr" autocomplete="email" required>
|
||||
<div class="field-error" id="email-error">Email invalide.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="password">Mot de passe</label>
|
||||
<input type="password" id="password" name="password" placeholder="8 caractères minimum" autocomplete="new-password" required>
|
||||
<div class="password-strength"><div class="strength-bar" id="strength-bar"></div></div>
|
||||
<div class="field-error" id="password-error">8 caractères minimum requis.</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="plan">Plan de départ</label>
|
||||
<select id="plan" name="plan">
|
||||
<option value="free">Free — Gratuit</option>
|
||||
<option value="premium">Premium — 9,90€/mois</option>
|
||||
<option value="pro">Pro — 24,90€/mois</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<p class="terms">En vous inscrivant, vous acceptez nos <a href="/legal/cgu">Conditions Générales d'Utilisation</a> et notre <a href="/legal/privacy">Politique de Confidentialité</a>. Vous devez avoir 18 ans ou plus.</p>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" id="submit-btn">Créer mon compte gratuit</button>
|
||||
</form>
|
||||
|
||||
<div class="auth-footer">
|
||||
Déjà un compte ? <a href="/login">Se connecter</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Plan preview sidebar -->
|
||||
<div class="plan-preview">
|
||||
<div id="plan-card-free" class="plan-card selected">
|
||||
<div class="plan-card-name">Free <span>—</span></div>
|
||||
<div class="plan-card-price">0€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Aperçu Top-3 du jour</li>
|
||||
<li>1 course complète/jour</li>
|
||||
<li>Statistiques basiques</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="plan-card-premium" class="plan-card" style="display:none">
|
||||
<div class="plan-card-name">Premium <span class="plan-badge">⭐</span></div>
|
||||
<div class="plan-card-price">9,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Toutes les courses</li>
|
||||
<li>Value bets identifiés</li>
|
||||
<li>Alertes Telegram</li>
|
||||
<li>Historique 90 jours</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div id="plan-card-pro" class="plan-card" style="display:none">
|
||||
<div class="plan-card-name">Pro <span></span></div>
|
||||
<div class="plan-card-price">24,90€<span style="font-size:.9rem;font-weight:400;color:var(--muted)">/mois</span></div>
|
||||
<ul class="plan-card-features">
|
||||
<li>Tout du Premium</li>
|
||||
<li>Export CSV illimité</li>
|
||||
<li>API REST documentée</li>
|
||||
<li>Backtest personnalisé</li>
|
||||
<li>Support prioritaire</li>
|
||||
</ul>
|
||||
</div>
|
||||
<p style="font-size:.78rem;color:var(--muted);text-align:center;">⚡ Pas de carte bancaire requise pour le plan Free</p>
|
||||
</div>
|
||||
</main>
|
||||
<footer>© 2026 Turf IA — H3R7 Tech. Jouez de façon responsable. 18+</footer>
|
||||
|
||||
<script>
|
||||
const API = '/api/v1';
|
||||
const form = document.getElementById('register-form');
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
const alertBox = document.getElementById('alert-error');
|
||||
const planSelect = document.getElementById('plan');
|
||||
|
||||
// Pre-select plan from URL param
|
||||
const urlPlan = new URLSearchParams(location.search).get('plan');
|
||||
if (urlPlan && ['free','premium','pro'].includes(urlPlan)) planSelect.value = urlPlan;
|
||||
|
||||
function updatePlanPreview() {
|
||||
['free','premium','pro'].forEach(p => {
|
||||
const el = document.getElementById(`plan-card-${p}`);
|
||||
if (!el) return;
|
||||
if (planSelect.value === p) {
|
||||
el.style.display = '';
|
||||
el.classList.add('selected');
|
||||
} else {
|
||||
el.style.display = 'none';
|
||||
el.classList.remove('selected');
|
||||
}
|
||||
});
|
||||
}
|
||||
planSelect.addEventListener('change', updatePlanPreview);
|
||||
updatePlanPreview();
|
||||
|
||||
function showError(msg) { alertBox.textContent = msg; alertBox.classList.add('show'); }
|
||||
function hideError() { alertBox.classList.remove('show'); }
|
||||
function setLoading(on) {
|
||||
submitBtn.disabled = on;
|
||||
submitBtn.innerHTML = on ? '<span class="loader"></span>Création…' : 'Créer mon compte gratuit';
|
||||
}
|
||||
function validateField(input, errId, condition, msg) {
|
||||
const err = document.getElementById(errId);
|
||||
if (condition) { input.classList.add('error'); err.textContent = msg; err.classList.add('show'); return false; }
|
||||
input.classList.remove('error'); err.classList.remove('show'); return true;
|
||||
}
|
||||
|
||||
// Password strength
|
||||
const passInput = document.getElementById('password');
|
||||
const bar = document.getElementById('strength-bar');
|
||||
passInput.addEventListener('input', () => {
|
||||
const v = passInput.value;
|
||||
let score = 0;
|
||||
if (v.length >= 8) score++;
|
||||
if (/[A-Z]/.test(v)) score++;
|
||||
if (/[0-9]/.test(v)) score++;
|
||||
if (/[^A-Za-z0-9]/.test(v)) score++;
|
||||
const colors = ['#f85149','#e3b341','#58a6ff','#00c853'];
|
||||
bar.style.width = (score * 25) + '%';
|
||||
bar.style.background = colors[score - 1] || '#30363d';
|
||||
});
|
||||
|
||||
form.addEventListener('submit', async e => {
|
||||
e.preventDefault();
|
||||
hideError();
|
||||
const firstname = document.getElementById('firstname').value.trim();
|
||||
const lastname = document.getElementById('lastname').value.trim();
|
||||
const email = document.getElementById('email').value.trim();
|
||||
const password = passInput.value;
|
||||
const plan = planSelect.value;
|
||||
|
||||
let ok = true;
|
||||
ok = validateField(document.getElementById('firstname'), 'firstname-error', !firstname, 'Prénom requis.') && ok;
|
||||
ok = validateField(document.getElementById('lastname'), 'lastname-error', !lastname, 'Nom requis.') && ok;
|
||||
ok = validateField(document.getElementById('email'), 'email-error', !email || !/^[^@]+@[^@]+\.[^@]+$/.test(email), 'Email invalide.') && ok;
|
||||
ok = validateField(passInput, 'password-error', password.length < 8, '8 caractères minimum.') && ok;
|
||||
if (!ok) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch(`${API}/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ firstname, lastname, email, password, plan })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok) {
|
||||
showError(data.error || 'Erreur lors de la création du compte.');
|
||||
} else {
|
||||
localStorage.setItem('turf_token', data.token);
|
||||
localStorage.setItem('turf_user', JSON.stringify(data.user));
|
||||
localStorage.setItem('turf_onboarding', 'pending');
|
||||
location.href = '/onboarding';
|
||||
}
|
||||
} catch(_) {
|
||||
showError('Erreur de connexion. Vérifiez votre réseau.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
document.querySelectorAll('input').forEach(el => el.addEventListener('input', () => {
|
||||
el.classList.remove('error');
|
||||
document.getElementById(el.id + '-error')?.classList.remove('show');
|
||||
hideError();
|
||||
}));
|
||||
|
||||
(function() {
|
||||
if (localStorage.getItem('turf_token')) location.href = '/dashboard';
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
33
requirements.txt
Normal file
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)
|
||||
300
saas_api_v1.py
Normal file
300
saas_api_v1.py
Normal file
@@ -0,0 +1,300 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SaaS API v1 Blueprint — /api/v1/*
|
||||
Stats, prédictions, résumés pour le dashboard SaaS.
|
||||
Sprint 4-5 — HRT-30
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
import sqlite3
|
||||
import os
|
||||
from datetime import datetime
|
||||
from saas_auth import require_auth
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def plan_allows(user_plan: str, required: str) -> bool:
|
||||
order = {"free": 0, "premium": 1, "pro": 2}
|
||||
return order.get(user_plan, 0) >= order.get(required, 0)
|
||||
|
||||
|
||||
# ─── Stats ────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/stats/summary", methods=["GET"])
|
||||
@require_auth
|
||||
def stats_summary():
|
||||
"""GET /api/v1/stats/summary — résumé dashboard."""
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
|
||||
try:
|
||||
# Courses today
|
||||
courses_today = (
|
||||
conn.execute(
|
||||
"SELECT COUNT(DISTINCT num_reunion||'-'||num_course) FROM ml_predictions_cache WHERE date=?",
|
||||
(today,),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
|
||||
# Value bets today
|
||||
value_bets_today = (
|
||||
conn.execute(
|
||||
"SELECT COUNT(*) FROM ml_predictions_cache WHERE date=? AND is_value_bet=1",
|
||||
(today,),
|
||||
).fetchone()[0]
|
||||
or 0
|
||||
)
|
||||
|
||||
# Accuracy top3 (30 days)
|
||||
acc_row = conn.execute("""
|
||||
SELECT
|
||||
CAST(SUM(CASE WHEN p.ordre_arrivee BETWEEN 1 AND 3 AND m.recommendation='top3' THEN 1 ELSE 0 END) AS FLOAT)
|
||||
/ NULLIF(COUNT(CASE WHEN m.recommendation='top3' THEN 1 END), 0) * 100 AS acc
|
||||
FROM ml_predictions_cache m
|
||||
JOIN pmu_partants p ON m.horse_name=p.nom AND m.date=p.date_programme
|
||||
WHERE m.date >= date('now', '-30 days')
|
||||
""").fetchone()
|
||||
accuracy_top3 = round(acc_row[0], 1) if acc_row and acc_row[0] else None
|
||||
|
||||
# Next race
|
||||
next_race = conn.execute(
|
||||
"SELECT heure, hippodrome FROM ml_predictions_cache WHERE date=? AND heure IS NOT NULL ORDER BY heure LIMIT 1",
|
||||
(today,),
|
||||
).fetchone()
|
||||
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{
|
||||
"courses_today": courses_today,
|
||||
"value_bets_today": value_bets_today,
|
||||
"accuracy_top3": accuracy_top3,
|
||||
"next_race_time": next_race["heure"] if next_race else None,
|
||||
"next_race_hippodrome": next_race["hippodrome"] if next_race else None,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify(
|
||||
{"error": str(e), "courses_today": 0, "value_bets_today": 0}
|
||||
), 200
|
||||
|
||||
|
||||
# ─── Predictions ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/predictions/today", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_today():
|
||||
"""GET /api/v1/predictions/today — prédictions du jour selon le plan."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
|
||||
try:
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT horse_name, horse_number, odds, prob_top1, prob_top3,
|
||||
ml_score, recommendation, is_value_bet, is_outlier,
|
||||
race_label, race_name, hippodrome, discipline, distance,
|
||||
heure, risque_label, risque_score, num_reunion, num_course
|
||||
FROM ml_predictions_cache
|
||||
WHERE date=?
|
||||
ORDER BY num_reunion, num_course, ml_score DESC
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
predictions = [dict(r) for r in rows]
|
||||
|
||||
# Free plan: return only 1 race
|
||||
if plan == "free":
|
||||
if predictions:
|
||||
first = predictions[0]
|
||||
first_key = (first["num_reunion"], first["num_course"])
|
||||
predictions = [
|
||||
p
|
||||
for p in predictions
|
||||
if (p["num_reunion"], p["num_course"]) == first_key
|
||||
]
|
||||
# Mask value bet flag in free
|
||||
for p in predictions:
|
||||
p["is_value_bet"] = 0
|
||||
|
||||
# Premium/Pro: full predictions
|
||||
return jsonify(
|
||||
{
|
||||
"date": today,
|
||||
"plan": plan,
|
||||
"count": len(predictions),
|
||||
"predictions": predictions,
|
||||
}
|
||||
), 200
|
||||
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return jsonify({"error": str(e), "predictions": []}), 200
|
||||
|
||||
|
||||
@api_v1_bp.route("/predictions/race/<race_label>", methods=["GET"])
|
||||
@require_auth
|
||||
def predictions_race(race_label):
|
||||
"""GET /api/v1/predictions/race/<label> — prédictions d'une course."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Parse label like R1C3 → num_reunion=1, num_course=3
|
||||
import re
|
||||
|
||||
m = re.match(r"R(\d+)C(\d+)", race_label.upper())
|
||||
if not m:
|
||||
return jsonify({"error": "Format invalide, attendu: R{n}C{n}"}), 400
|
||||
nr, nc = int(m.group(1)), int(m.group(2))
|
||||
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT * FROM ml_predictions_cache
|
||||
WHERE date=? AND num_reunion=? AND num_course=?
|
||||
ORDER BY ml_score DESC
|
||||
""",
|
||||
(today, nr, nc),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
predictions = [dict(r) for r in rows]
|
||||
if plan == "free" and predictions:
|
||||
# Only show first race
|
||||
pass # allowed in detail view if they know the race label
|
||||
|
||||
return jsonify({"predictions": predictions, "count": len(predictions)}), 200
|
||||
|
||||
|
||||
# ─── Value Bets ───────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/value-bets/today", methods=["GET"])
|
||||
@require_auth
|
||||
def value_bets_today():
|
||||
"""GET /api/v1/value-bets/today — value bets (Premium+)."""
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
if not plan_allows(plan, "premium"):
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Cette fonctionnalité requiert un plan Premium ou Pro.",
|
||||
"upgrade_required": True,
|
||||
}
|
||||
), 403
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"""
|
||||
SELECT horse_name, race_label, race_name, hippodrome, odds,
|
||||
prob_top3, ml_score, risque_label, heure
|
||||
FROM ml_predictions_cache
|
||||
WHERE date=? AND is_value_bet=1
|
||||
ORDER BY ml_score DESC
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
return jsonify({"value_bets": [dict(r) for r in rows], "count": len(rows)}), 200
|
||||
|
||||
|
||||
# ─── Export ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@api_v1_bp.route("/export/csv", methods=["GET"])
|
||||
@require_auth
|
||||
def export_csv():
|
||||
"""GET /api/v1/export/csv — export CSV (Pro only)."""
|
||||
from flask import Response
|
||||
import csv, io
|
||||
|
||||
user = request.current_user
|
||||
plan = user.get("plan", "free")
|
||||
if not plan_allows(plan, "pro"):
|
||||
return jsonify(
|
||||
{"error": "L'export CSV requiert un plan Pro.", "upgrade_required": True}
|
||||
), 403
|
||||
|
||||
date_param = request.args.get("date", datetime.now().strftime("%Y-%m-%d"))
|
||||
conn = get_db()
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM ml_predictions_cache WHERE date=? ORDER BY num_reunion, num_course, ml_score DESC",
|
||||
(date_param,),
|
||||
).fetchall()
|
||||
conn.close()
|
||||
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=rows[0].keys())
|
||||
writer.writeheader()
|
||||
writer.writerows([dict(r) for r in rows])
|
||||
|
||||
return Response(
|
||||
output.getvalue(),
|
||||
mimetype="text/csv",
|
||||
headers={
|
||||
"Content-Disposition": f"attachment; filename=turf_ia_{date_param}.csv"
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ─── Billing Blueprint (Stripe) + JWT init — HRT-49 ─────────────────────────
|
||||
# Registers /api/v1/billing/* routes via nested Blueprint (Flask 2.0+)
|
||||
# Also initializes JWTManager on the Flask app (required for jwt_required_middleware)
|
||||
try:
|
||||
from flask_jwt_extended import JWTManager
|
||||
from api_v1.routes.billing import billing_bp
|
||||
|
||||
# Initialize JWTManager on the Flask app when api_v1_bp is registered
|
||||
@api_v1_bp.record_once
|
||||
def _init_jwt(state):
|
||||
app = state.app
|
||||
if not app.config.get("JWT_SECRET_KEY"):
|
||||
import os
|
||||
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
|
||||
)
|
||||
if "flask_jwt_extended" not in app.extensions:
|
||||
JWTManager(app)
|
||||
|
||||
# Register billing blueprint with url_prefix='/billing'
|
||||
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
||||
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
|
||||
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
|
||||
except Exception as _billing_err:
|
||||
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
|
||||
|
||||
|
||||
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||
try:
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_org_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||
except Exception as _org_err:
|
||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||
545
saas_auth.py
Normal file
545
saas_auth.py
Normal file
@@ -0,0 +1,545 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
SaaS Auth Blueprint — /api/v1/auth/*
|
||||
Gestion des utilisateurs, JWT, plans, préférences.
|
||||
Sprint 4-5 — HRT-30
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
import sqlite3
|
||||
import hashlib
|
||||
import 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)
|
||||
) # persist in env for prod
|
||||
TOKEN_TTL = int(os.environ.get("JWT_TTL_SECONDS", 30 * 24 * 3600)) # 30 days
|
||||
|
||||
auth_bp = Blueprint("auth_v1", __name__, url_prefix="/api/v1/auth")
|
||||
|
||||
|
||||
# ─── DB helpers ───────────────────────────────────────────────────────────────
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def init_users_table():
|
||||
"""Ensure users table exists."""
|
||||
conn = get_db()
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS saas_users (
|
||||
id TEXT PRIMARY KEY,
|
||||
email TEXT UNIQUE NOT NULL,
|
||||
firstname TEXT DEFAULT '',
|
||||
lastname TEXT DEFAULT '',
|
||||
password_hash TEXT NOT NULL,
|
||||
plan TEXT DEFAULT 'free',
|
||||
telegram_chat_id TEXT DEFAULT NULL,
|
||||
alert_value_bets INTEGER DEFAULT 1,
|
||||
alert_top1 INTEGER DEFAULT 1,
|
||||
alert_quinte_only INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS saas_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
created_at TEXT DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
try:
|
||||
init_users_table()
|
||||
except Exception as e:
|
||||
print(f"[auth_bp] DB init warning: {e}")
|
||||
|
||||
|
||||
# ─── Token helpers ────────────────────────────────────────────────────────────
|
||||
def generate_token(user_id: str) -> str:
|
||||
token = secrets.token_urlsafe(48)
|
||||
expires = int(time.time()) + TOKEN_TTL
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"INSERT INTO saas_tokens (token, user_id, expires_at) VALUES (?,?,?)",
|
||||
(token, user_id, expires),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return token
|
||||
|
||||
|
||||
def validate_token(token: str):
|
||||
"""Returns user row dict or None."""
|
||||
if not token:
|
||||
return None
|
||||
conn = get_db()
|
||||
now = int(time.time())
|
||||
row = conn.execute(
|
||||
"SELECT t.user_id, u.* FROM saas_tokens t JOIN saas_users u ON t.user_id=u.id "
|
||||
"WHERE t.token=? AND t.expires_at>?",
|
||||
(token, now),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
return dict(row) if row else None
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return hashlib.sha256(password.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def 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.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) -> dict:
|
||||
if isinstance(user, sqlite3.Row):
|
||||
user = dict(user)
|
||||
return {
|
||||
"id": user.get("id"),
|
||||
"email": user.get("email"),
|
||||
"firstname": user.get("firstname", ""),
|
||||
"lastname": user.get("lastname", ""),
|
||||
"plan": user.get("plan", "free"),
|
||||
"telegram_chat_id": user.get("telegram_chat_id"),
|
||||
"alert_value_bets": bool(user.get("alert_value_bets", 1)),
|
||||
"alert_top1": bool(user.get("alert_top1", 1)),
|
||||
"alert_quinte_only": bool(user.get("alert_quinte_only", 0)),
|
||||
"created_at": user.get("created_at"),
|
||||
}
|
||||
|
||||
|
||||
# ─── Routes ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@auth_bp.route("/register", methods=["POST"])
|
||||
def register():
|
||||
"""POST /api/v1/auth/register"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
firstname = (data.get("firstname") or "").strip()
|
||||
lastname = (data.get("lastname") or "").strip()
|
||||
plan = data.get("plan", "free")
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Adresse email invalide."}), 400
|
||||
pwd_error = validate_password_strength(password)
|
||||
if pwd_error:
|
||||
return jsonify({"error": pwd_error}), 400
|
||||
if plan not in ("free", "premium", "pro"):
|
||||
plan = "free"
|
||||
|
||||
uid = secrets.token_hex(16)
|
||||
pw_hash = hash_password(password)
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT INTO saas_users (id, email, firstname, lastname, password_hash, plan) VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, firstname, lastname, pw_hash, plan),
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return jsonify({"error": "Cette adresse email est déjà utilisée."}), 409
|
||||
conn.close()
|
||||
|
||||
token = generate_token(uid)
|
||||
user_row = validate_token(token)
|
||||
return jsonify({"token": token, "user": user_to_dict(user_row)}), 201
|
||||
|
||||
|
||||
@auth_bp.route("/login", methods=["POST"])
|
||||
def login():
|
||||
"""POST /api/v1/auth/login"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
password = data.get("password") or ""
|
||||
|
||||
if not email or not password:
|
||||
return jsonify({"error": "Email et mot de passe requis."}), 400
|
||||
|
||||
# ── 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)
|
||||
).fetchone()
|
||||
conn.close()
|
||||
|
||||
if not user:
|
||||
return jsonify({"error": "Identifiants incorrects."}), 401
|
||||
|
||||
token = generate_token(user["id"])
|
||||
return jsonify({"token": token, "user": user_to_dict(user)}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/me", methods=["GET"])
|
||||
@require_auth
|
||||
def me():
|
||||
"""GET /api/v1/auth/me"""
|
||||
return jsonify({"user": user_to_dict(request.current_user)}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-profile", methods=["POST"])
|
||||
@require_auth
|
||||
def update_profile():
|
||||
"""POST /api/v1/auth/update-profile"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
fields = {}
|
||||
if "firstname" in data:
|
||||
fields["firstname"] = data["firstname"].strip()
|
||||
if "lastname" in data:
|
||||
fields["lastname"] = data["lastname"].strip()
|
||||
if "email" in data:
|
||||
email = data["email"].strip().lower()
|
||||
if "@" not in email:
|
||||
return jsonify({"error": "Email invalide."}), 400
|
||||
fields["email"] = email
|
||||
|
||||
if not fields:
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
|
||||
conn = get_db()
|
||||
try:
|
||||
conn.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
conn.commit()
|
||||
except sqlite3.IntegrityError:
|
||||
conn.close()
|
||||
return jsonify({"error": "Cet email est déjà utilisé."}), 409
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/change-password", methods=["POST"])
|
||||
@require_auth
|
||||
def change_password():
|
||||
"""POST /api/v1/auth/change-password"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
cur_pwd = data.get("current_password") or ""
|
||||
new_pwd = data.get("new_password") or ""
|
||||
|
||||
pwd_error = validate_password_strength(new_pwd)
|
||||
if pwd_error:
|
||||
return jsonify({"error": pwd_error}), 400
|
||||
|
||||
conn = get_db()
|
||||
user = conn.execute(
|
||||
"SELECT * FROM saas_users WHERE id=? AND password_hash=?",
|
||||
(uid, hash_password(cur_pwd)),
|
||||
).fetchone()
|
||||
if not user:
|
||||
conn.close()
|
||||
return jsonify({"error": "Mot de passe actuel incorrect."}), 401
|
||||
|
||||
conn.execute(
|
||||
"UPDATE saas_users SET password_hash=?, updated_at=? WHERE id=?",
|
||||
(hash_password(new_pwd), datetime.utcnow().isoformat(), uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-plan", methods=["POST"])
|
||||
@require_auth
|
||||
def update_plan():
|
||||
"""POST /api/v1/auth/update-plan"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
plan = data.get("plan", "free")
|
||||
if plan not in ("free", "premium", "pro"):
|
||||
return jsonify({"error": "Plan invalide."}), 400
|
||||
uid = request.current_user["id"]
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
"UPDATE saas_users SET plan=?, updated_at=? WHERE id=?",
|
||||
(plan, datetime.utcnow().isoformat(), uid),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True, "plan": plan}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/update-preferences", methods=["POST"])
|
||||
@require_auth
|
||||
def update_preferences():
|
||||
"""POST /api/v1/auth/update-preferences"""
|
||||
data = request.get_json(silent=True) or {}
|
||||
uid = request.current_user["id"]
|
||||
fields = {}
|
||||
if "telegram_chat_id" in data:
|
||||
fields["telegram_chat_id"] = data["telegram_chat_id"] or None
|
||||
if "alert_value_bets" in data:
|
||||
fields["alert_value_bets"] = 1 if data["alert_value_bets"] else 0
|
||||
if "alert_top1" in data:
|
||||
fields["alert_top1"] = 1 if data["alert_top1"] else 0
|
||||
if "alert_quinte_only" in data:
|
||||
fields["alert_quinte_only"] = 1 if data["alert_quinte_only"] else 0
|
||||
|
||||
if fields:
|
||||
set_clause = ", ".join(f"{k}=?" for k in fields)
|
||||
values = list(fields.values()) + [datetime.utcnow().isoformat(), uid]
|
||||
conn = get_db()
|
||||
conn.execute(
|
||||
f"UPDATE saas_users SET {set_clause}, updated_at=? WHERE id=?", values
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/logout", methods=["POST"])
|
||||
@require_auth
|
||||
def logout():
|
||||
"""POST /api/v1/auth/logout"""
|
||||
auth = request.headers.get("Authorization", "")
|
||||
token = auth.removeprefix("Bearer ").strip()
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM saas_tokens WHERE token=?", (token,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
|
||||
|
||||
@auth_bp.route("/delete-account", methods=["DELETE"])
|
||||
@require_auth
|
||||
def delete_account():
|
||||
"""DELETE /api/v1/auth/delete-account"""
|
||||
uid = request.current_user["id"]
|
||||
conn = get_db()
|
||||
conn.execute("DELETE FROM saas_tokens WHERE user_id=?", (uid,))
|
||||
conn.execute("DELETE FROM saas_users WHERE id=?", (uid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return jsonify({"ok": True}), 200
|
||||
481
scoring_v2.py
481
scoring_v2.py
@@ -10,30 +10,35 @@ import json
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
DB_PATH = "/home/h3r7/turf_scraper/turf.db"
|
||||
HEADERS = {'User-Agent': 'Mozilla/5.0', 'Accept': 'application/json'}
|
||||
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):
|
||||
"""Recupere la cote depuis la table predictions (plus recente et non nulle)"""
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
c = conn.execute("""
|
||||
c = conn.execute(
|
||||
"""
|
||||
SELECT odds FROM predictions
|
||||
WHERE date=? AND horse_name LIKE ? AND odds > 0
|
||||
ORDER BY created_at DESC LIMIT 1
|
||||
""", (date_course, f"%{horse_name}%"))
|
||||
""",
|
||||
(date_course, f"%{horse_name}%"),
|
||||
)
|
||||
r = c.fetchone()
|
||||
conn.close()
|
||||
return r['odds'] if r else 0
|
||||
return r["odds"] if r else 0
|
||||
|
||||
|
||||
def parse_musique(musique):
|
||||
if not musique:
|
||||
return {}
|
||||
clean = re.sub(r'\(\d+\)', '', musique)
|
||||
resultats = re.findall(r'(\d+|D|0)([amphsc]?)', clean)
|
||||
clean = re.sub(r"\(\d+\)", "", musique)
|
||||
resultats = re.findall(r"(\d+|D|0)([amphsc]?)", clean)
|
||||
positions = []
|
||||
for pos, disc in resultats[:10]:
|
||||
positions.append(99 if pos == 'D' else int(pos))
|
||||
positions.append(99 if pos == "D" else int(pos))
|
||||
if not positions:
|
||||
return {}
|
||||
nb_courses = len(positions)
|
||||
@@ -41,222 +46,385 @@ def parse_musique(musique):
|
||||
nb_places = sum(1 for p in positions if 1 <= p <= 3)
|
||||
recentes = [p for p in positions[:3] if p != 99]
|
||||
forme_recente = sum(recentes) / len(recentes) if recentes else 99
|
||||
tendance = (sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
tendance = (
|
||||
(sum(positions[-4:]) / 4 - sum(positions[:4]) / 4) if len(positions) >= 4 else 0
|
||||
)
|
||||
return {
|
||||
'forme_recente': round(forme_recente, 1),
|
||||
'tendance': round(tendance, 1),
|
||||
'tx_victoire': round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
'tx_place': round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"forme_recente": round(forme_recente, 1),
|
||||
"tendance": round(tendance, 1),
|
||||
"tx_victoire": round(nb_victoires / nb_courses * 100, 1) if nb_courses else 0,
|
||||
"tx_place": round(nb_places / nb_courses * 100, 1) if nb_courses else 0,
|
||||
}
|
||||
|
||||
def score_cheval_v2(p, all_participants, today):
|
||||
|
||||
def get_terrain_condition(penetrometre_intitule: str | None) -> str:
|
||||
"""Normalise le pénétromètre PMU en condition terrain standardisée."""
|
||||
if not penetrometre_intitule:
|
||||
return "inconnu"
|
||||
val = penetrometre_intitule.upper()
|
||||
if any(k in val for k in ("TRES BON", "TRÈS BON", "FERME", "FIRM")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("BON", "GOOD", "STANDARD")):
|
||||
return "bon"
|
||||
if any(k in val for k in ("SOUPLE", "YIELDING", "COLLANT")):
|
||||
return "souple"
|
||||
if any(k in val for k in ("LOURD", "HEAVY", "TRES SOUPLE", "TRÈS SOUPLE")):
|
||||
return "lourd"
|
||||
if any(k in val for k in ("SOFT", "MOU")):
|
||||
return "souple"
|
||||
return "inconnu"
|
||||
|
||||
|
||||
def compute_weather_impact(weather_data: dict | None, terrain_condition: str) -> float:
|
||||
"""
|
||||
Calcule un score d'impact météo/terrain sur [−5, +5].
|
||||
weather_data keys attendues : nebulositecode, temperature, force_vent
|
||||
terrain_condition : 'bon' | 'souple' | 'lourd' | 'inconnu'
|
||||
Retourne un delta de score ML (positif = favorable, négatif = défavorable).
|
||||
"""
|
||||
if not weather_data:
|
||||
return 0.0
|
||||
|
||||
delta = 0.0
|
||||
|
||||
# Terrain
|
||||
if terrain_condition == "lourd":
|
||||
delta -= 3.0
|
||||
elif terrain_condition == "souple":
|
||||
delta -= 1.5
|
||||
elif terrain_condition == "bon":
|
||||
delta += 1.0
|
||||
# inconnu → 0
|
||||
|
||||
# Vent
|
||||
force_vent = weather_data.get("force_vent") or 0
|
||||
try:
|
||||
force_vent = float(force_vent)
|
||||
except (TypeError, ValueError):
|
||||
force_vent = 0.0
|
||||
if force_vent >= 50:
|
||||
delta -= 2.0
|
||||
elif force_vent >= 30:
|
||||
delta -= 1.0
|
||||
|
||||
# Températures extrêmes
|
||||
temperature = weather_data.get("temperature")
|
||||
try:
|
||||
temperature = float(temperature) if temperature is not None else None
|
||||
except (TypeError, ValueError):
|
||||
temperature = None
|
||||
if temperature is not None:
|
||||
if temperature <= 0:
|
||||
delta -= 1.0
|
||||
elif temperature >= 35:
|
||||
delta -= 1.0
|
||||
|
||||
return round(max(-5.0, min(5.0, delta)), 2)
|
||||
|
||||
|
||||
def score_cheval_v2(p, all_participants, today, weather_data=None):
|
||||
"""
|
||||
Score un cheval pour le modèle V2.
|
||||
weather_data (optionnel) : dict issu de pmu_meteo pour cette réunion.
|
||||
Backward-compatible : weather_data=None → comportement identique à avant HRT-83.
|
||||
"""
|
||||
score = 0
|
||||
details = {}
|
||||
|
||||
|
||||
# 1. COTE - Essaye PMU API, sinon DB
|
||||
horse_name = p.get('nom', '')
|
||||
horse_name = p.get("nom", "")
|
||||
cote = 0
|
||||
|
||||
|
||||
# Essayer d'abord depuis l'API PMU
|
||||
rapport = p.get('dernierRapportDirect', {})
|
||||
rapport = p.get("dernierRapportDirect", {})
|
||||
if rapport:
|
||||
cote = rapport.get('rapport', 0)
|
||||
cote = rapport.get("rapport", 0)
|
||||
if not cote:
|
||||
rapport_ref = p.get('dernierRapportReference', {})
|
||||
cote = rapport_ref.get('rapport', 0) if rapport_ref else 0
|
||||
|
||||
rapport_ref = p.get("dernierRapportReference", {})
|
||||
cote = rapport_ref.get("rapport", 0) if rapport_ref else 0
|
||||
|
||||
# Fallback: aller chercher dans la DB
|
||||
if not cote or cote == 0:
|
||||
cote = get_cote_from_db(horse_name, today)
|
||||
|
||||
|
||||
# Si toujours pas de cote, utiliser 99 comme valeur par defaut
|
||||
if not cote or cote == 0:
|
||||
cote = 99.0
|
||||
|
||||
|
||||
score_cote = max(2, min(10, 20 / (1 + cote * 0.15))) if cote > 0 else 2
|
||||
score += score_cote
|
||||
details['cote'] = round(cote, 1)
|
||||
details['score_cote'] = round(score_cote, 1)
|
||||
|
||||
details["cote"] = round(cote, 1)
|
||||
details["score_cote"] = round(score_cote, 1)
|
||||
|
||||
# 2. FORME - AUGMENTE a 30 pts
|
||||
musique_stats = parse_musique(p.get('musique', ''))
|
||||
forme = musique_stats.get('forme_recente', 99)
|
||||
score_forme = 30 if forme <= 1 else 25 if forme <= 2 else 20 if forme <= 3 else 15 if forme <= 5 else 8 if forme <= 8 else 0
|
||||
musique_stats = parse_musique(p.get("musique", ""))
|
||||
forme = musique_stats.get("forme_recente", 99)
|
||||
score_forme = (
|
||||
30
|
||||
if forme <= 1
|
||||
else 25
|
||||
if forme <= 2
|
||||
else 20
|
||||
if forme <= 3
|
||||
else 15
|
||||
if forme <= 5
|
||||
else 8
|
||||
if forme <= 8
|
||||
else 0
|
||||
)
|
||||
score += score_forme
|
||||
details['forme_recente'] = forme
|
||||
details['score_forme'] = score_forme
|
||||
|
||||
details["forme_recente"] = forme
|
||||
details["score_forme"] = score_forme
|
||||
|
||||
# 3. TAUX VICTOIRE (15 pts)
|
||||
nb_courses_total = p.get('nombreCourses', 0)
|
||||
nb_victoires_total = p.get('nombreVictoires', 0)
|
||||
nb_courses_total = p.get("nombreCourses", 0)
|
||||
nb_victoires_total = p.get("nombreVictoires", 0)
|
||||
tx_vic = (nb_victoires_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_vic = min(15, tx_vic * 0.5)
|
||||
score += score_vic
|
||||
details['tx_victoire'] = round(tx_vic, 1)
|
||||
details['score_victoire'] = round(score_vic, 1)
|
||||
|
||||
details["tx_victoire"] = round(tx_vic, 1)
|
||||
details["score_victoire"] = round(score_vic, 1)
|
||||
|
||||
# 4. TAUX PLACE (15 pts)
|
||||
nb_places_total = p.get('nombrePlaces', 0)
|
||||
nb_places_total = p.get("nombrePlaces", 0)
|
||||
tx_place = (nb_places_total / nb_courses_total * 100) if nb_courses_total else 0
|
||||
score_place = min(15, tx_place * 0.2)
|
||||
score += score_place
|
||||
details['tx_place'] = round(tx_place, 1)
|
||||
details['score_place'] = round(score_place, 1)
|
||||
|
||||
details["tx_place"] = round(tx_place, 1)
|
||||
details["score_place"] = round(score_place, 1)
|
||||
|
||||
# 5. REDUCTION KM (10 pts)
|
||||
rk = p.get('reductionKilometrique', 0)
|
||||
all_rk = [x.get('reductionKilometrique', 0) for x in all_participants if x.get('reductionKilometrique', 0) > 0]
|
||||
rk = p.get("reductionKilometrique", 0)
|
||||
all_rk = [
|
||||
x.get("reductionKilometrique", 0)
|
||||
for x in all_participants
|
||||
if x.get("reductionKilometrique", 0) > 0
|
||||
]
|
||||
if rk > 0 and all_rk:
|
||||
score_rk = 10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk))) if max(all_rk) > min(all_rk) else 5
|
||||
score_rk = (
|
||||
10 * (1 - (rk - min(all_rk)) / (max(all_rk) - min(all_rk)))
|
||||
if max(all_rk) > min(all_rk)
|
||||
else 5
|
||||
)
|
||||
else:
|
||||
score_rk = 0
|
||||
score += score_rk
|
||||
details['rk'] = rk
|
||||
details['score_rk'] = round(score_rk, 1)
|
||||
|
||||
details["rk"] = rk
|
||||
details["score_rk"] = round(score_rk, 1)
|
||||
|
||||
# 6. TENDANCE (10 pts)
|
||||
tendance = musique_stats.get('tendance', 0)
|
||||
tendance = musique_stats.get("tendance", 0)
|
||||
score_tendance = min(10, max(0, 5 + tendance))
|
||||
score += score_tendance
|
||||
details['tendance'] = tendance
|
||||
details['score_tendance'] = round(score_tendance, 1)
|
||||
|
||||
details["tendance"] = tendance
|
||||
details["score_tendance"] = round(score_tendance, 1)
|
||||
|
||||
# 7. AVIS ENTRAINEUR (5 pts)
|
||||
avis = p.get('avisEntraineur', 'NEUTRE')
|
||||
score_avis = {'POSITIF': 5, 'TRES_POSITIF': 5, 'NEUTRE': 2, 'NEGATIF': 0, 'TRES_NEGATIF': 0}.get(avis, 2)
|
||||
avis = p.get("avisEntraineur", "NEUTRE")
|
||||
score_avis = {
|
||||
"POSITIF": 5,
|
||||
"TRES_POSITIF": 5,
|
||||
"NEUTRE": 2,
|
||||
"NEGATIF": 0,
|
||||
"TRES_NEGATIF": 0,
|
||||
}.get(avis, 2)
|
||||
score += score_avis
|
||||
details['avis_entraineur'] = avis
|
||||
details['score_avis'] = score_avis
|
||||
|
||||
details["avis_entraineur"] = avis
|
||||
details["score_avis"] = score_avis
|
||||
|
||||
# 8. BONUS OUTSIDER (5 pts)
|
||||
bonus_outsider = 5 if forme <= 3 and cote >= 10 else 0
|
||||
score += bonus_outsider
|
||||
details['bonus_outsider'] = bonus_outsider
|
||||
|
||||
details["bonus_outsider"] = bonus_outsider
|
||||
|
||||
# Driver change penalty
|
||||
if p.get('driverChange', False):
|
||||
if p.get("driverChange", False):
|
||||
score -= 3
|
||||
details['driver_change'] = True
|
||||
|
||||
details['score_total'] = round(score, 1)
|
||||
details['musique'] = p.get('musique', '')
|
||||
details['nb_victoires'] = nb_victoires_total
|
||||
details['nb_places'] = nb_places_total
|
||||
details['nb_courses'] = nb_courses_total
|
||||
|
||||
details["driver_change"] = True
|
||||
|
||||
# 9. METEO & TERRAIN (HRT-83) — premium feature, weather_data=None → skip
|
||||
penetrometre = p.get("penetrometre_intitule", "") or ""
|
||||
terrain_condition = (
|
||||
get_terrain_condition(penetrometre) if penetrometre else "inconnu"
|
||||
)
|
||||
weather_impact = 0.0
|
||||
if weather_data is not None:
|
||||
weather_impact = compute_weather_impact(weather_data, terrain_condition)
|
||||
score += weather_impact
|
||||
details["terrain_condition"] = terrain_condition
|
||||
details["weather_impact"] = weather_impact
|
||||
|
||||
details["score_total"] = round(score, 1)
|
||||
details["musique"] = p.get("musique", "")
|
||||
details["nb_victoires"] = nb_victoires_total
|
||||
details["nb_places"] = nb_places_total
|
||||
details["nb_courses"] = nb_courses_total
|
||||
|
||||
return round(score, 1), details
|
||||
|
||||
|
||||
def get_ze2sur4_combinaisons(top4):
|
||||
combinaisons = []
|
||||
for i in range(4):
|
||||
for j in range(i+1, 4):
|
||||
for j in range(i + 1, 4):
|
||||
c1 = top4[i]
|
||||
c2 = top4[j]
|
||||
combinaisons.append({
|
||||
'cheval1': c1['nom'],
|
||||
'numero1': c1['numero'],
|
||||
'cheval2': c2['nom'],
|
||||
'numero2': c2['numero'],
|
||||
'mise': 1.0,
|
||||
})
|
||||
combinaisons.append(
|
||||
{
|
||||
"cheval1": c1["nom"],
|
||||
"numero1": c1["numero"],
|
||||
"cheval2": c2["nom"],
|
||||
"numero2": c2["numero"],
|
||||
"mise": 1.0,
|
||||
}
|
||||
)
|
||||
return combinaisons
|
||||
|
||||
|
||||
def build_recommendations_v2(scored_horses):
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
if len(ranked) < 4:
|
||||
return None
|
||||
|
||||
|
||||
top1, top2, top3, top4 = ranked[0], ranked[1], ranked[2], ranked[3]
|
||||
top4_list = ranked[:4]
|
||||
|
||||
|
||||
def confiance(s):
|
||||
return "FORTE" if s >= 55 else "BONNE" if s >= 45 else "MOYENNE" if s >= 35 else "FAIBLE"
|
||||
|
||||
return (
|
||||
"FORTE"
|
||||
if s >= 55
|
||||
else "BONNE"
|
||||
if s >= 45
|
||||
else "MOYENNE"
|
||||
if s >= 35
|
||||
else "FAIBLE"
|
||||
)
|
||||
|
||||
ze2_combinaisons = get_ze2sur4_combinaisons(top4_list)
|
||||
mise_ze2 = len(ze2_combinaisons) * 1.0
|
||||
|
||||
|
||||
return {
|
||||
'simple_gagnant': {
|
||||
'cheval': top1['nom'], 'numero': top1['numero'], 'cote': top1['details']['cote'],
|
||||
'score': top1['score'], 'confiance': confiance(top1['score']),
|
||||
'mise_suggeree': 2.0, 'gain_potentiel': round(2.0 * top1['details']['cote'], 2)
|
||||
"simple_gagnant": {
|
||||
"cheval": top1["nom"],
|
||||
"numero": top1["numero"],
|
||||
"cote": top1["details"]["cote"],
|
||||
"score": top1["score"],
|
||||
"confiance": confiance(top1["score"]),
|
||||
"mise_suggeree": 2.0,
|
||||
"gain_potentiel": round(2.0 * top1["details"]["cote"], 2),
|
||||
},
|
||||
'ze2_sur_4': {
|
||||
'top4': [{'nom': h['nom'], 'numero': h['numero']} for h in top4_list],
|
||||
'combinaisons': ze2_combinaisons,
|
||||
'mise_totale': mise_ze2,
|
||||
'nb_combinaisons': len(ze2_combinaisons),
|
||||
'confiance': confiance((top1['score'] + top2['score'] + top3['score'] + top4['score']) / 4),
|
||||
'explication': 'Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers'
|
||||
"ze2_sur_4": {
|
||||
"top4": [{"nom": h["nom"], "numero": h["numero"]} for h in top4_list],
|
||||
"combinaisons": ze2_combinaisons,
|
||||
"mise_totale": mise_ze2,
|
||||
"nb_combinaisons": len(ze2_combinaisons),
|
||||
"confiance": confiance(
|
||||
(top1["score"] + top2["score"] + top3["score"] + top4["score"]) / 4
|
||||
),
|
||||
"explication": "Jouer les 6 combinaisons de 2 chevaux parmi les 4 premiers",
|
||||
},
|
||||
'outsider': _find_outsider(ranked),
|
||||
'budget_total': 2.0 + mise_ze2,
|
||||
"outsider": _find_outsider(ranked),
|
||||
"budget_total": 2.0 + mise_ze2,
|
||||
}
|
||||
|
||||
|
||||
def _find_outsider(ranked):
|
||||
for h in ranked[3:7]:
|
||||
d = h['details']
|
||||
if d['cote'] >= 12 and d['forme_recente'] <= 4 and d['bonus_outsider'] == 5:
|
||||
d = h["details"]
|
||||
if d["cote"] >= 12 and d["forme_recente"] <= 4 and d["bonus_outsider"] == 5:
|
||||
return {
|
||||
'cheval': h['nom'], 'numero': h['numero'], 'cote': d['cote'],
|
||||
'mise_suggeree': 1.0, 'gain_potentiel': round(1.0 * d['cote'], 2)
|
||||
"cheval": h["nom"],
|
||||
"numero": h["numero"],
|
||||
"cote": d["cote"],
|
||||
"mise_suggeree": 1.0,
|
||||
"gain_potentiel": round(1.0 * d["cote"], 2),
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
def save_to_db(scored_horses, date_course, hippodrome, libelle):
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
cursor = conn.cursor()
|
||||
|
||||
|
||||
cursor.execute("DELETE FROM scoring WHERE date = ?", (date_course,))
|
||||
|
||||
|
||||
for i, h in enumerate(scored_horses, 1):
|
||||
d = h['details']
|
||||
cursor.execute("""
|
||||
d = h["details"]
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO scoring (date, race_name, horse_number, horse_name, score,
|
||||
score_cote, score_forme, score_victoire, score_place, score_rk,
|
||||
score_tendance, score_avis, cote, forme_recente, tx_victoire, tx_place,
|
||||
avis_entraineur, musique, rang_scoring, scoring_version)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'v2')
|
||||
""", (date_course, libelle, h['numero'], h['nom'], h['score'],
|
||||
d.get('score_cote', 0), d.get('score_forme', 0), d.get('score_victoire', 0),
|
||||
d.get('score_place', 0), d.get('score_rk', 0), d.get('score_tendance', 0),
|
||||
d.get('score_avis', 0), d.get('cote', 0), d.get('forme_recente', 0),
|
||||
d.get('tx_victoire', 0), d.get('tx_place', 0), d.get('avis_entraineur', ''),
|
||||
d.get('musique', ''), i))
|
||||
|
||||
""",
|
||||
(
|
||||
date_course,
|
||||
libelle,
|
||||
h["numero"],
|
||||
h["nom"],
|
||||
h["score"],
|
||||
d.get("score_cote", 0),
|
||||
d.get("score_forme", 0),
|
||||
d.get("score_victoire", 0),
|
||||
d.get("score_place", 0),
|
||||
d.get("score_rk", 0),
|
||||
d.get("score_tendance", 0),
|
||||
d.get("score_avis", 0),
|
||||
d.get("cote", 0),
|
||||
d.get("forme_recente", 0),
|
||||
d.get("tx_victoire", 0),
|
||||
d.get("tx_place", 0),
|
||||
d.get("avis_entraineur", ""),
|
||||
d.get("musique", ""),
|
||||
i,
|
||||
),
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
print(f"💾 {len(scored_horses)} scores enregistres en BDD pour {date_course}")
|
||||
|
||||
|
||||
def main():
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
date_pmu = datetime.now().strftime('%d%m%Y')
|
||||
print(f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ===")
|
||||
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
date_pmu = datetime.now().strftime("%d%m%Y")
|
||||
print(
|
||||
f"=== SCORING V2 - ZE2 SUR4 OPTIMISE === {datetime.now().strftime('%d/%m/%Y %H:%M')} ==="
|
||||
)
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/reunions"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
reunions = r.json().get('programme', {}).get('reunions', [])
|
||||
reunions = r.json().get("programme", {}).get("reunions", [])
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
quinte = None
|
||||
for reunion in reunions:
|
||||
for course in reunion.get('courses', []):
|
||||
for course in reunion.get("courses", []):
|
||||
paris_types = [p["typePari"] for p in course.get("paris", [])]
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get('libelle', ''):
|
||||
quinte = (reunion['numOfficiel'], course['numOrdre'], course.get('libelle', ''),
|
||||
reunion['hippodrome']['libelleCourt'], course.get('heureDepart', 0))
|
||||
if any("QUINTE" in p for p in paris_types) or "PARIS-TURF" in course.get(
|
||||
"libelle", ""
|
||||
):
|
||||
quinte = (
|
||||
reunion["numOfficiel"],
|
||||
course["numOrdre"],
|
||||
course.get("libelle", ""),
|
||||
reunion["hippodrome"]["libelleCourt"],
|
||||
course.get("heureDepart", 0),
|
||||
)
|
||||
break
|
||||
if quinte:
|
||||
break
|
||||
|
||||
|
||||
if not quinte:
|
||||
# Fallback: utiliser la premiere reunion francaise avec predictions
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
r = conn.execute("""
|
||||
r = conn.execute(
|
||||
"""
|
||||
SELECT r.num_reunion, r.hippodrome_court, c.num_course, c.libelle
|
||||
FROM pmu_courses c
|
||||
JOIN pmu_reunions r ON r.date_programme=c.date_programme AND r.num_reunion=c.num_reunion
|
||||
@@ -264,57 +432,82 @@ def main():
|
||||
AND EXISTS (SELECT 1 FROM predictions p WHERE p.date=? AND p.source='canalturf_partants'
|
||||
AND p.race_name LIKE '%' || c.libelle || '%')
|
||||
ORDER BY c.heure_depart_str ASC LIMIT 1
|
||||
""", (today, today)).fetchone()
|
||||
""",
|
||||
(today, today),
|
||||
).fetchone()
|
||||
conn.close()
|
||||
if r:
|
||||
quinte = (r['num_reunion'], r['num_course'], r['libelle'], r['hippodrome_court'], 0)
|
||||
quinte = (
|
||||
r["num_reunion"],
|
||||
r["num_course"],
|
||||
r["libelle"],
|
||||
r["hippodrome_court"],
|
||||
0,
|
||||
)
|
||||
else:
|
||||
print("Aucune course trouvee")
|
||||
return
|
||||
|
||||
|
||||
num_r, num_c, libelle, hippodrome, heure_ts = quinte
|
||||
heure = datetime.fromtimestamp(heure_ts/1000).strftime('%H:%M') if heure_ts else '13:55'
|
||||
heure = (
|
||||
datetime.fromtimestamp(heure_ts / 1000).strftime("%H:%M")
|
||||
if heure_ts
|
||||
else "13:55"
|
||||
)
|
||||
print(f"Course: {libelle} - {hippodrome} {heure}")
|
||||
|
||||
|
||||
try:
|
||||
url = f"https://turfinfo.api.pmu.fr/rest/client/1/programme/{date_pmu}/R{num_r}/C{num_c}/participants"
|
||||
r = requests.get(url, headers=HEADERS, timeout=15)
|
||||
participants = [p for p in r.json().get('participants', []) if p.get('statut') == 'PARTANT']
|
||||
participants = [
|
||||
p for p in r.json().get("participants", []) if p.get("statut") == "PARTANT"
|
||||
]
|
||||
except Exception as e:
|
||||
print(f"Erreur: {e}")
|
||||
return
|
||||
|
||||
|
||||
scored_horses = []
|
||||
for p in participants:
|
||||
score, details = score_cheval_v2(p, participants, today)
|
||||
scored_horses.append({'nom': p['nom'], 'numero': p['numPmu'], 'score': score, 'details': details})
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x['score'], reverse=True)
|
||||
scored_horses.append(
|
||||
{"nom": p["nom"], "numero": p["numPmu"], "score": score, "details": details}
|
||||
)
|
||||
|
||||
ranked = sorted(scored_horses, key=lambda x: x["score"], reverse=True)
|
||||
print(f"\n=== TOP 4 ===")
|
||||
for i, h in enumerate(ranked[:4], 1):
|
||||
d = h['details']
|
||||
print(f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}")
|
||||
|
||||
d = h["details"]
|
||||
print(
|
||||
f"{i}. #{h['numero']:>2} {h['nom']:<20} Score:{h['score']:.1f} Cote:{d['cote']:.1f}"
|
||||
)
|
||||
|
||||
save_to_db(ranked, today, hippodrome, libelle)
|
||||
|
||||
|
||||
reco = build_recommendations_v2(scored_horses)
|
||||
if reco:
|
||||
print(f"\n=== RECOMMANDATIONS ===")
|
||||
sg = reco['simple_gagnant']
|
||||
sg = reco["simple_gagnant"]
|
||||
print(f"\n🎯 SIMPLE GAGNANT:")
|
||||
print(f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)")
|
||||
|
||||
ze2 = reco['ze2_sur_4']
|
||||
print(
|
||||
f" #{sg['numero']} {sg['cheval']} @ {sg['cote']}/1 (mise {sg['mise_suggeree']}EUR)"
|
||||
)
|
||||
|
||||
ze2 = reco["ze2_sur_4"]
|
||||
print(f"\n🎰 ZE 2 SUR 4 (TOP 4: {', '.join([h['nom'] for h in ze2['top4']])}")
|
||||
print(f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)")
|
||||
print(
|
||||
f" Mise totale: {ze2['mise_totale']}EUR ({ze2['nb_combinaisons']} combis x 1EUR)"
|
||||
)
|
||||
print(f" Confiance: {ze2['confiance']}")
|
||||
print(f" Combinaisons:")
|
||||
for c in ze2['combinaisons']:
|
||||
print(f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}")
|
||||
|
||||
for c in ze2["combinaisons"]:
|
||||
print(
|
||||
f" {c['numero1']}-{c['cheval1']} + {c['numero2']}-{c['cheval2']}"
|
||||
)
|
||||
|
||||
print(f"\n💰 BUDGET TOTAL: {reco['budget_total']}EUR")
|
||||
print(f" - Simple Gagnant: 2EUR")
|
||||
print(f" - ZE 2 sur 4: {ze2['mise_totale']}EUR")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
284
telegram_alerts.py
Normal file
284
telegram_alerts.py
Normal file
@@ -0,0 +1,284 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Telegram Alerts — Service d'alertes pré-course pour les utilisateurs Premium/Pro
|
||||
HRT-79: Alertes Telegram configurables (Premium)
|
||||
|
||||
Fonctionnement :
|
||||
- 30 minutes avant chaque course détectée, envoie un message Telegram
|
||||
aux utilisateurs Premium/Pro ayant configuré leur chat_id.
|
||||
- Les préférences individuelles (value_bets, top1, quinte_only) sont respectées.
|
||||
- Requiert la variable d'environnement TELEGRAM_BOT_TOKEN.
|
||||
"""
|
||||
|
||||
import os
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
|
||||
import requests
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
BOT_TOKEN = os.environ.get("TELEGRAM_BOT_TOKEN", "")
|
||||
|
||||
TELEGRAM_API_BASE = "https://api.telegram.org/bot{token}/sendMessage"
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
|
||||
def send_telegram_message(chat_id: str, text: str) -> bool:
|
||||
"""
|
||||
Envoie un message Telegram à un chat_id donné.
|
||||
|
||||
Returns True si succès, False sinon.
|
||||
Ne lève pas d'exception pour ne pas crasher le scheduler.
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning("[TELEGRAM] TELEGRAM_BOT_TOKEN non configuré — envoi ignoré")
|
||||
return False
|
||||
|
||||
url = TELEGRAM_API_BASE.format(token=BOT_TOKEN)
|
||||
payload = {
|
||||
"chat_id": chat_id,
|
||||
"text": text,
|
||||
"parse_mode": "Markdown",
|
||||
"disable_web_page_preview": True,
|
||||
}
|
||||
try:
|
||||
resp = requests.post(url, json=payload, timeout=10)
|
||||
if resp.status_code == 200:
|
||||
return True
|
||||
logger.warning(
|
||||
"[TELEGRAM] Echec envoi chat_id=%s status=%d body=%s",
|
||||
chat_id,
|
||||
resp.status_code,
|
||||
resp.text[:200],
|
||||
)
|
||||
return False
|
||||
except requests.RequestException as exc:
|
||||
logger.error("[TELEGRAM] Exception HTTP chat_id=%s: %s", chat_id, exc)
|
||||
return False
|
||||
|
||||
|
||||
# ── Alert builder ─────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def build_race_alert(race_data: dict, predictions: list) -> str:
|
||||
"""
|
||||
Construit le message Markdown de l'alerte pré-course.
|
||||
|
||||
Args:
|
||||
race_data: dict avec les clés 'hippo', 'num_course', 'heure', 'type_course'
|
||||
predictions: liste de dicts {'num_cheval', 'nom_cheval', 'prob_top3', 'is_value_bet', 'ml_score'}
|
||||
|
||||
Returns: texte Markdown formaté
|
||||
"""
|
||||
hippo = race_data.get("hippo", "?")
|
||||
num_course = race_data.get("num_course", "?")
|
||||
heure = race_data.get("heure", "?")
|
||||
type_course = race_data.get("type_course", "")
|
||||
|
||||
lines = [
|
||||
f"🏇 *Alerte course — {hippo} R{num_course}*",
|
||||
f"⏰ Départ prévu : *{heure}*",
|
||||
]
|
||||
if type_course:
|
||||
lines.append(f"📋 Type : {type_course}")
|
||||
lines.append("")
|
||||
|
||||
top3 = [p for p in predictions if p.get("prob_top3", 0) > 0][:3]
|
||||
value_bets = [p for p in predictions if p.get("is_value_bet")]
|
||||
|
||||
if top3:
|
||||
lines.append("📊 *Top-3 ML :*")
|
||||
for i, p in enumerate(top3, 1):
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
prob = p.get("prob_top3", 0)
|
||||
lines.append(f" {i}. {nom} — {prob:.0%} prob top-3")
|
||||
lines.append("")
|
||||
|
||||
if value_bets:
|
||||
lines.append("💡 *Value bets :*")
|
||||
for p in value_bets[:3]:
|
||||
nom = p.get("nom_cheval", f"#{p.get('num_cheval', '?')}")
|
||||
score = p.get("ml_score", 0)
|
||||
lines.append(f" ✅ {nom} (score {score:.2f})")
|
||||
lines.append("")
|
||||
|
||||
lines.append("_Alerte automatique Turf SaaS — 30min avant départ_")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ── Main send function ────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def send_pre_race_alerts(minutes_before: int = 30) -> dict:
|
||||
"""
|
||||
Interroge la DB pour récupérer les courses du jour, puis envoie
|
||||
des alertes Telegram aux utilisateurs Premium/Pro éligibles.
|
||||
|
||||
Args:
|
||||
minutes_before: non utilisé directement (la planification est gérée
|
||||
par le scheduler), présent pour documentation.
|
||||
|
||||
Returns: dict {'sent': int, 'skipped': int, 'errors': int}
|
||||
"""
|
||||
if not BOT_TOKEN:
|
||||
logger.warning(
|
||||
"[TELEGRAM] TELEGRAM_BOT_TOKEN absent — send_pre_race_alerts ignoré"
|
||||
)
|
||||
return {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
stats = {"sent": 0, "skipped": 0, "errors": 0}
|
||||
|
||||
try:
|
||||
conn = _get_db()
|
||||
today = datetime.now().strftime("%Y-%m-%d")
|
||||
|
||||
# Récupère les courses du jour
|
||||
try:
|
||||
courses_rows = conn.execute(
|
||||
"""
|
||||
SELECT DISTINCT
|
||||
hippo, num_course, heure_depart, type_course
|
||||
FROM pmu_courses
|
||||
WHERE date_programme = ?
|
||||
AND heure_depart IS NOT NULL
|
||||
ORDER BY heure_depart ASC
|
||||
LIMIT 20
|
||||
""",
|
||||
(today,),
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning("[TELEGRAM] Table pmu_courses introuvable: %s", exc)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not courses_rows:
|
||||
logger.info("[TELEGRAM] Aucune course aujourd'hui — pas d'alerte")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
# Récupère les utilisateurs Premium/Pro avec chat_id configuré
|
||||
try:
|
||||
users = conn.execute(
|
||||
"""
|
||||
SELECT id, telegram_chat_id,
|
||||
alert_value_bets, alert_top1, alert_quinte_only
|
||||
FROM users
|
||||
WHERE plan IN ('premium', 'pro')
|
||||
AND is_active = 1
|
||||
AND telegram_chat_id IS NOT NULL
|
||||
AND telegram_chat_id != ''
|
||||
""",
|
||||
).fetchall()
|
||||
except sqlite3.OperationalError as exc:
|
||||
logger.warning(
|
||||
"[TELEGRAM] Colonnes Telegram absentes (migration non appliquée?): %s",
|
||||
exc,
|
||||
)
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
if not users:
|
||||
logger.info("[TELEGRAM] Aucun utilisateur avec chat_id configuré")
|
||||
conn.close()
|
||||
return stats
|
||||
|
||||
for course_row in courses_rows:
|
||||
hippo = course_row["hippo"] or "?"
|
||||
num_course = course_row["num_course"] or "?"
|
||||
heure_ts = course_row["heure_depart"]
|
||||
type_course = course_row["type_course"] or ""
|
||||
|
||||
try:
|
||||
dt = datetime.fromtimestamp(heure_ts / 1000)
|
||||
heure_str = dt.strftime("%H:%M")
|
||||
except Exception:
|
||||
heure_str = str(heure_ts)
|
||||
|
||||
race_data = {
|
||||
"hippo": hippo,
|
||||
"num_course": num_course,
|
||||
"heure": heure_str,
|
||||
"type_course": type_course,
|
||||
}
|
||||
|
||||
# Récupère les prédictions ML pour cette course
|
||||
predictions = []
|
||||
try:
|
||||
pred_rows = conn.execute(
|
||||
"""
|
||||
SELECT num_cheval, nom_cheval, prob_top3, is_value_bet, ml_score
|
||||
FROM ml_predictions_cache
|
||||
WHERE date = ?
|
||||
AND hippo = ?
|
||||
AND num_course = ?
|
||||
ORDER BY prob_top3 DESC
|
||||
LIMIT 10
|
||||
""",
|
||||
(today, hippo, num_course),
|
||||
).fetchall()
|
||||
predictions = [dict(r) for r in pred_rows]
|
||||
except sqlite3.OperationalError:
|
||||
pass # table absente, on envoie quand même avec données minimales
|
||||
|
||||
is_quinte = (
|
||||
"quinté" in type_course.lower() or "quinte" in type_course.lower()
|
||||
)
|
||||
|
||||
for user in users:
|
||||
chat_id = user["telegram_chat_id"]
|
||||
alert_quinte_only = bool(user["alert_quinte_only"])
|
||||
alert_top1 = bool(user["alert_top1"])
|
||||
alert_value_bets = bool(user["alert_value_bets"])
|
||||
|
||||
# Filtre quinte_only
|
||||
if alert_quinte_only and not is_quinte:
|
||||
stats["skipped"] += 1
|
||||
continue
|
||||
|
||||
# Construit le message selon préférences
|
||||
filtered_preds = []
|
||||
if predictions:
|
||||
for p in predictions:
|
||||
include = False
|
||||
if alert_top1 and p.get("prob_top3", 0) > 0:
|
||||
include = True
|
||||
if alert_value_bets and p.get("is_value_bet"):
|
||||
include = True
|
||||
if include:
|
||||
filtered_preds.append(p)
|
||||
|
||||
text = build_race_alert(race_data, filtered_preds)
|
||||
ok = send_telegram_message(chat_id, text)
|
||||
if ok:
|
||||
stats["sent"] += 1
|
||||
else:
|
||||
stats["errors"] += 1
|
||||
|
||||
conn.close()
|
||||
|
||||
except Exception as exc:
|
||||
logger.error("[TELEGRAM] Erreur inattendue dans send_pre_race_alerts: %s", exc)
|
||||
import traceback
|
||||
|
||||
traceback.print_exc()
|
||||
stats["errors"] += 1
|
||||
|
||||
logger.info(
|
||||
"[TELEGRAM] Alertes pré-course: %d envoyées, %d ignorées, %d erreurs",
|
||||
stats["sent"],
|
||||
stats["skipped"],
|
||||
stats["errors"],
|
||||
)
|
||||
return stats
|
||||
@@ -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}."
|
||||
)
|
||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Couvre :
|
||||
- Migration DB (tables organizations + org_members)
|
||||
- POST /api/v1/org
|
||||
- GET /api/v1/org
|
||||
- DELETE /api/v1/org
|
||||
- POST /api/v1/org/invite
|
||||
- GET /api/v1/org/members
|
||||
- DELETE /api/v1/org/members/<user_id>
|
||||
- Plan enforcement (plan != pro → 403)
|
||||
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# ─── App import (après configuration env) ────────────────────────────────────
|
||||
|
||||
import sqlite3
|
||||
from org_db import get_db, migrate_org_tables
|
||||
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_user(email: str, plan: str = "free") -> dict:
|
||||
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||
init_users_table()
|
||||
uid = secrets.token_hex(16)
|
||||
pw_hash = "hashed"
|
||||
conn = auth_get_db()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, "Test", "User", pw_hash, plan),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
token = generate_token(uid)
|
||||
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||
|
||||
|
||||
def _auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from saas_auth import auth_bp
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
application = Flask(__name__)
|
||||
CORS(application)
|
||||
application.config["TESTING"] = True
|
||||
|
||||
# S'assurer que la migration a tourné
|
||||
migrate_org_tables()
|
||||
|
||||
application.register_blueprint(auth_bp)
|
||||
application.register_blueprint(org_bp)
|
||||
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_owner(app):
|
||||
"""Un utilisateur Pro qui va créer une org."""
|
||||
with app.app_context():
|
||||
return _create_user("owner_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user2(app):
|
||||
"""Un 2e utilisateur Pro à inviter."""
|
||||
with app.app_context():
|
||||
return _create_user("member2_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user3(app):
|
||||
with app.app_context():
|
||||
return _create_user("member3_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user4(app):
|
||||
with app.app_context():
|
||||
return _create_user("member4_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user5(app):
|
||||
with app.app_context():
|
||||
return _create_user("member5_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user6(app):
|
||||
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||
with app.app_context():
|
||||
return _create_user("member6_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def free_user(app):
|
||||
with app.app_context():
|
||||
return _create_user("free_user@test.com", plan="free")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_pro_owner(app):
|
||||
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||
with app.app_context():
|
||||
return _create_user("other_owner@test.com", plan="pro")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests DB migration
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOrgDbMigration:
|
||||
def test_tables_exist(self):
|
||||
"""Les tables organizations et org_members doivent exister."""
|
||||
conn = get_db()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
assert "organizations" in tables, "Table organizations manquante"
|
||||
assert "org_members" in tables, "Table org_members manquante"
|
||||
|
||||
def test_migration_idempotent(self):
|
||||
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||
migrate_org_tables() # 2e appel — doit être silencieux
|
||||
self.test_tables_exist()
|
||||
|
||||
def test_org_members_unique_constraint(self):
|
||||
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||
conn = get_db()
|
||||
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||
conn.close()
|
||||
# Il doit y avoir un index d'unicité
|
||||
assert (
|
||||
any(
|
||||
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||
for idx in indexes
|
||||
)
|
||||
or True
|
||||
)
|
||||
# On vérifie via insertion en double
|
||||
conn = get_db()
|
||||
oid = "test_org_unique"
|
||||
uid = "test_uid_unique"
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||
(oid, uid, "TestOrg"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
# 2e insertion doit lever IntegrityError
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests plan enforcement
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPlanEnforcement:
|
||||
def test_create_org_free_plan_403(self, client, free_user):
|
||||
"""Un utilisateur free ne peut pas créer une org."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "FreePlanOrg"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert data["required"] == "pro"
|
||||
|
||||
def test_get_org_free_plan_403(self, client, free_user):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_free_plan_403(self, client, free_user):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "someone@test.com"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_free_plan_403(self, client, free_user):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_no_token_401(self, client):
|
||||
resp = client.get("/api/v1/org")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests création d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCreateOrg:
|
||||
def test_create_org_success(self, client, pro_owner):
|
||||
"""Un Pro peut créer une organisation."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "H3R7 Racing Club"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert "org" in data
|
||||
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["max_members"] == 5
|
||||
|
||||
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||
"""Un Pro ne peut pas créer 2 organisations."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "Second Org"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
data = resp.get_json()
|
||||
assert "org_id" in data
|
||||
|
||||
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||
"""Le nom est obligatoire."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": " "},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "x" * 101},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests lecture d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetOrg:
|
||||
def test_get_org_as_owner(self, client, pro_owner):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||
|
||||
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||
# other_pro_owner n'a pas encore d'org dans ce test
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||
assert resp.status_code in (200, 404)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests invitation de membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestInviteMember:
|
||||
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["member"]["user_id"] == pro_user2["id"]
|
||||
assert data["member"]["role"] == "member"
|
||||
|
||||
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||
"""Inviter 2x le même utilisateur → 409."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "nobody@nowhere.com"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "not-an-email"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invite_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas inviter."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "anyone@test.com"},
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_fill_to_max(
|
||||
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||
):
|
||||
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||
for u in (pro_user3, pro_user4, pro_user5):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": u["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201, (
|
||||
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||
)
|
||||
|
||||
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||
"""Le 6e membre doit être refusé (max 5)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user6["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests liste des membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
def test_list_members_as_owner(self, client, pro_owner):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "members" in data
|
||||
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||
assert data["max_members"] == 5
|
||||
|
||||
def test_list_members_as_member(self, client, pro_user2):
|
||||
"""Un membre peut aussi consulter la liste."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["count"] >= 1
|
||||
|
||||
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
data = resp.get_json()
|
||||
emails = [m["email"] for m in data["members"]]
|
||||
assert pro_user2["email"] in emails
|
||||
|
||||
def test_list_members_no_org_404(self, client, pro_user6):
|
||||
"""Un Pro sans org reçoit 404."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression de membre
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRemoveMember:
|
||||
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user5['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["removed_user_id"] == pro_user5["id"]
|
||||
|
||||
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||
"""L'owner ne peut pas se retirer lui-même."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_owner['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||
resp = client.delete(
|
||||
"/api/v1/org/members/nonexistent-id-xyz",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user3['id']}",
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user5["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDeleteOrg:
|
||||
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas supprimer l'org."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_delete_org_success(self, client, pro_owner):
|
||||
"""L'owner peut supprimer l'organisation."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||
"""Après suppression, GET /org renvoie 404."""
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||
"""Supprimer une org qui n'existe plus → 403."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_cascade_deleted(self, client, pro_user2):
|
||||
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
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
|
||||
@@ -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