- 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>
346 lines
15 KiB
Python
346 lines
15 KiB
Python
"""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")
|