Mise un place d'un système DIY de migrations DB
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,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),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user