Mise un place d'un système DIY de migrations DB

This commit is contained in:
Colin Maudry
2026-06-25 22:15:31 +02:00
parent 6e0722e2d7
commit 1bd15f627f
5 changed files with 78 additions and 6 deletions
+18
View File
@@ -92,6 +92,24 @@ Tests require a running Chrome/Chromium browser. They use `DashComposite` from `
- `DEVELOPMENT=true` enables debug logging and is set automatically during tests - `DEVELOPMENT=true` enables debug logging and is set automatically during tests
- `.env` file is required at runtime (copy from `template.env`) - `.env` file is required at runtime (copy from `template.env`)
### Migrations de schéma SQLite
Les migrations sont gérées dans `src/migrations.py` via une liste `_MIGRATIONS` de tuples `(id, sql)`. Elles sont appliquées automatiquement au démarrage de l'app (via `init_subscriptions`).
Pour ajouter une migration :
```python
# src/migrations.py
_MIGRATIONS = [
("0001_add_prix_ht_to_subscriptions", "ALTER TABLE subscriptions ADD COLUMN prix_ht REAL"),
("0002_ma_nouvelle_migration", "ALTER TABLE ... "), # ajouter ici
]
```
- L'ID doit être unique et croissant (convention `NNNN_description`)
- Les migrations appliquées sont tracées dans la table `schema_migrations`
- `apply_pending()` est idempotent : sans effet si la migration est déjà enregistrée, et tolère le cas où la colonne existe déjà dans le schéma (DB fraîche)
### Deployment ### Deployment
- `main` branch → manual deploy to decp.info via GitHub Actions - `main` branch → manual deploy to decp.info via GitHub Actions
+45
View File
@@ -0,0 +1,45 @@
"""
Migrations de schéma SQLite.
Ajouter une migration : append un tuple (id, sql) à _MIGRATIONS.
L'id doit être unique et croissant (convention : NNNN_description).
apply_pending() est idempotent ; elle peut être appelée à chaque démarrage.
"""
import sqlite3
from datetime import datetime, timezone
from src.auth.db import get_conn
_MIGRATIONS: list[tuple[str, str]] = [
(
"0001_add_prix_ht_to_subscriptions",
"ALTER TABLE subscriptions ADD COLUMN prix_ht REAL",
),
]
def apply_pending() -> None:
conn = get_conn()
conn.execute(
"CREATE TABLE IF NOT EXISTS schema_migrations "
"(id TEXT PRIMARY KEY, applied_at TEXT NOT NULL)"
)
applied = {
row[0] for row in conn.execute("SELECT id FROM schema_migrations").fetchall()
}
now = datetime.now(timezone.utc).isoformat()
for migration_id, sql in _MIGRATIONS:
if migration_id not in applied:
try:
conn.execute(sql)
except sqlite3.OperationalError as exc:
# SQLite ne supporte pas ALTER TABLE … ADD COLUMN IF NOT EXISTS.
# Sur une DB fraîche (schéma déjà à jour), on ignore l'erreur.
if "duplicate column name" not in str(exc):
raise
conn.execute(
"INSERT INTO schema_migrations (id, applied_at) VALUES (?, ?)",
(migration_id, now),
)
conn.commit()
+9 -5
View File
@@ -9,6 +9,7 @@ CREATE TABLE IF NOT EXISTS subscriptions (
frisbii_customer_handle TEXT, frisbii_customer_handle TEXT,
frisbii_subscription_handle TEXT, frisbii_subscription_handle TEXT,
plan TEXT, plan TEXT,
prix_ht REAL,
status TEXT, status TEXT,
current_period_end TEXT, current_period_end TEXT,
trial_used INTEGER NOT NULL DEFAULT 0, trial_used INTEGER NOT NULL DEFAULT 0,
@@ -31,16 +32,19 @@ def init_schema() -> None:
get_conn().executescript(SUBSCRIPTIONS_SCHEMA) get_conn().executescript(SUBSCRIPTIONS_SCHEMA)
def create_pending(user_id: int, customer_handle: str, plan: str) -> None: def create_pending(
user_id: int, customer_handle: str, plan: str, prix_ht: float | None = None
) -> None:
now = _now() now = _now()
get_conn().execute( get_conn().execute(
"INSERT INTO subscriptions " "INSERT INTO subscriptions "
"(user_id, frisbii_customer_handle, plan, status, created_at, updated_at) " "(user_id, frisbii_customer_handle, plan, prix_ht, status, created_at, updated_at) "
"VALUES (?, ?, ?, 'pending', ?, ?) " "VALUES (?, ?, ?, ?, 'pending', ?, ?) "
"ON CONFLICT(user_id) DO UPDATE SET " "ON CONFLICT(user_id) DO UPDATE SET "
"frisbii_customer_handle=excluded.frisbii_customer_handle, " "frisbii_customer_handle=excluded.frisbii_customer_handle, "
"plan=excluded.plan, status='pending', updated_at=excluded.updated_at", "plan=excluded.plan, prix_ht=excluded.prix_ht, "
(user_id, customer_handle, plan, now, now), "status='pending', updated_at=excluded.updated_at",
(user_id, customer_handle, plan, prix_ht, now, now),
) )
+4 -1
View File
@@ -27,8 +27,11 @@ def subscribe():
cust = _customer_handle(current_user.id) cust = _customer_handle(current_user.id)
try: try:
meta = plans.plan_meta(plan_key)
client.get_or_create_customer(cust, current_user.email) client.get_or_create_customer(cust, current_user.email)
db.create_pending(current_user.id, cust, plan_key) db.create_pending(
current_user.id, cust, plan_key, meta["prix_ht"] if meta else None
)
# Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un. # Anti-abus : pas de nouvel essai si l'utilisateur en a déjà consommé un.
no_trial = db.has_used_trial(current_user.id) no_trial = db.has_used_trial(current_user.id)
url = client.create_subscription_session( url = client.create_subscription_session(
+2
View File
@@ -2,6 +2,7 @@ import os
from flask import Flask from flask import Flask
from src import migrations
from src.subscriptions import db from src.subscriptions import db
from src.utils import logger from src.utils import logger
@@ -15,6 +16,7 @@ _REQUIRED_ENV = (
def init_subscriptions(app: Flask) -> None: def init_subscriptions(app: Flask) -> None:
db.init_schema() db.init_schema()
migrations.apply_pending()
from src.subscriptions.routes import subscriptions_bp, webhook from src.subscriptions.routes import subscriptions_bp, webhook