Compare commits

...

2 Commits

Author SHA1 Message Date
DevOps Engineer
793ee82c29 fix(qa): add /health endpoints to Flask apps for Docker healthchecks
Docker compose healthchecks target /health on combined-api, dashboard-api
and portal, but these endpoints did not exist (returned 404). This caused
all dependent services (condition: service_healthy) to fail startup.

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

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

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

Branch: feature/devops-cicd

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

68
.dockerignore Normal file
View File

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

82
.env.example Normal file
View File

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

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

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

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

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

28
.gitignore vendored
View File

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

68
Dockerfile Normal file
View File

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

383
account.html Normal file
View File

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

48
alembic.ini Normal file
View File

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

View File

@@ -131,6 +131,24 @@ def get_db():
return conn
@app.route("/health")
@app.route("/turf/health")
def health():
"""Health check endpoint for Docker/load balancer. Returns 200 if app is running."""
import sqlite3 as _sqlite3
db_ok = True
try:
conn = _sqlite3.connect(DB_PATH, timeout=2)
conn.execute("SELECT 1")
conn.close()
except Exception:
db_ok = False
status = "ok" if db_ok else "degraded"
http_code = 200 if db_ok else 503
return {"status": status, "service": "combined-api", "db": db_ok}, http_code
@app.route("/")
def index():
return send_file("/home/h3r7/turf_saas/dashboard.html")
@@ -3519,7 +3537,6 @@ def brave_search():
return jsonify({"error": str(e)}), 500
@app.route("/turf/api/predictions_analysis", methods=["GET"])
def api_predictions_analysis():
"""Analyse des predictions vs resultats reels"""
@@ -3533,13 +3550,25 @@ def api_predictions_analysis():
cursor = conn.cursor()
stats = {
"canalturf": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
"scoring": {"total": 0, "top1_pct": 0, "top3_pct": 0, "top5_pct": 0, "ze2_pct": 0},
"canalturf": {
"total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
"scoring": {
"total": 0,
"top1_pct": 0,
"top3_pct": 0,
"top5_pct": 0,
"ze2_pct": 0,
},
}
for source in ["canalturf", "scoring"]:
pred_table = "predictions" if source == "canalturf" else "scoring"
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
pred_col = "predicted_1" if source == "canalturf" else "horse_number"
try:
cursor.execute(
f"""
@@ -3566,16 +3595,16 @@ def api_predictions_analysis():
top1_hit = top3_hit = 0
total = len(races)
for race, data in races.items():
actual = set(data["actual"][:3])
pred_top1 = data["predicted"][0] if data["predicted"] else None
actual_top1 = data["actual"][0] if data["actual"] else None
actual = set(data["actual"][:3])
pred_top1 = data["predicted"][0] if data["predicted"] else None
actual_top1 = data["actual"][0] if data["actual"] else None
if pred_top1 and actual_top1 and pred_top1 == actual_top1:
top1_hit += 1
if len(set(data["predicted"][:3]) & actual) >= 1:
top3_hit += 1
if total > 0:
stats[source]["total"] = total
stats[source]["total"] = total
stats[source]["top1_pct"] = round(top1_hit / total * 100, 1)
stats[source]["top3_pct"] = round(top3_hit / total * 100, 1)
except Exception as e:

View File

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

441
dashboard_saas.html Normal file
View File

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

250
docker-compose.yml Normal file
View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

View File

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

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

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

467
landing.html Normal file
View File

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

112
log_config.py Normal file
View File

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

182
login.html Normal file
View File

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

255
metrics.py Normal file
View File

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

1
migrations/README Normal file
View File

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

68
migrations/env.py Normal file
View File

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

View File

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

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

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

View File

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

335
onboarding.html Normal file
View File

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

View File

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

271
register.html Normal file
View File

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

33
requirements.txt Normal file
View File

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

257
saas_api_v1.py Normal file
View File

@@ -0,0 +1,257 @@
#!/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"
},
)

344
saas_auth.py Normal file
View File

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