Enhance schema migration with new column checks and backfills

This commit updates the schema migration logic to include checks for the existence of columns in a database-agnostic manner, supporting both SQLite and Postgres. It introduces new columns, ensures proper synchronization between old and new column names, adds unique indexes, and backfills missing data. This improves database compatibility and ensures data consistency for evolving schemas.
This commit is contained in:
2026-02-06 11:37:50 +01:00
parent 4669d1f8c4
commit 8e5a2426e7

View File

@@ -32,76 +32,217 @@ app.include_router(auth_router)
app.include_router(admin_router) app.include_router(admin_router)
app.include_router(games_router) app.include_router(games_router)
def _rand_join_code(n: int = 6) -> str: def _rand_join_code(n: int = 6) -> str:
# digits only (kahoot style) # digits only (kahoot style)
return "".join(random.choice(string.digits) for _ in range(n)) return "".join(random.choice(string.digits) for _ in range(n))
def _has_column(db: Session, table: str, col: str) -> bool:
"""
SQLite + Postgres friendly check.
We use a pragma first (SQLite), fallback to information_schema.
"""
try:
rows = db.execute(text(f"PRAGMA table_info({table})")).all()
return any(r[1] == col for r in rows) # pragma: column name is at index 1
except Exception:
db.rollback()
try:
rows = db.execute(
text(
"""
SELECT column_name
FROM information_schema.columns
WHERE table_name = :t AND column_name = :c
"""
),
{"t": table, "c": col},
).all()
return len(rows) > 0
except Exception:
db.rollback()
return False
def _auto_migrate(db: Session): def _auto_migrate(db: Session):
""" """
Very small, pragmatic auto-migration (no alembic). Very small, pragmatic auto-migration (no alembic).
- creates missing tables via create_all - creates missing tables via create_all
- adds missing columns via ALTER TABLE (best effort) - adds missing columns via ALTER TABLE (best effort)
""" - supports old schema (join_code/chip_code) and new schema (code/chip)
# Users.theme_key """
# --- users.theme_key ---
if not _has_column(db, "users", "theme_key"):
try: try:
db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'")) db.execute(text("ALTER TABLE users ADD COLUMN theme_key VARCHAR DEFAULT 'default'"))
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
# Games.join_code + winner_user_id # --- games: code / join_code + winner_user_id + host_user_id (optional) ---
# We support both column names:
# old: join_code
# new: code
has_join_code = _has_column(db, "games", "join_code")
has_code = _has_column(db, "games", "code")
# If neither exists, create "code" (new preferred)
if not has_join_code and not has_code:
try: try:
db.execute(text("ALTER TABLE games ADD COLUMN join_code VARCHAR")) db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR"))
db.commit() db.commit()
has_code = True
except Exception: except Exception:
db.rollback() db.rollback()
# If only join_code exists but your code now expects "code",
# add "code" too and later mirror values.
if has_join_code and not has_code:
try:
db.execute(text("ALTER TABLE games ADD COLUMN code VARCHAR"))
db.commit()
has_code = True
except Exception:
db.rollback()
# winner_user_id
if not _has_column(db, "games", "winner_user_id"):
try: try:
db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR")) db.execute(text("ALTER TABLE games ADD COLUMN winner_user_id VARCHAR"))
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
# SheetState.chip_code # host_user_id (nice to have for "only host can set winner")
if not _has_column(db, "games", "host_user_id"):
try: try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip_code VARCHAR")) db.execute(text("ALTER TABLE games ADD COLUMN host_user_id VARCHAR"))
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
# Ensure unique index for join_code (best effort) # --- sheet_state chip / chip_code ---
has_chip_code = _has_column(db, "sheet_state", "chip_code")
has_chip = _has_column(db, "sheet_state", "chip")
if not has_chip_code and not has_chip:
# prefer "chip"
try: try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR"))
db.commit()
has_chip = True
except Exception:
db.rollback()
# if old chip_code exists but new expects chip -> add chip and mirror later
if has_chip_code and not has_chip:
try:
db.execute(text("ALTER TABLE sheet_state ADD COLUMN chip VARCHAR"))
db.commit()
has_chip = True
except Exception:
db.rollback()
# --- indexes for game code ---
# We create unique index for the column(s) that exist.
try:
if has_join_code:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)")) db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_join_code ON games (join_code)"))
if has_code:
db.execute(text("CREATE UNIQUE INDEX IF NOT EXISTS ix_games_code ON games (code)"))
db.commit() db.commit()
except Exception: except Exception:
db.rollback() db.rollback()
# Backfill join_code for existing games # --- backfill code values ---
games = db.query(Game).filter((Game.join_code == None) | (Game.join_code == "")).all() # noqa: E711 # 1) if join_code exists and code exists, ensure code mirrors join_code where missing
if games: if has_join_code and has_code:
used = set([r[0] for r in db.execute(text("SELECT join_code FROM games WHERE join_code IS NOT NULL")).all() if r[0]]) try:
for g in games: db.execute(text("UPDATE games SET code = join_code WHERE (code IS NULL OR code = '') AND join_code IS NOT NULL AND join_code <> ''"))
db.commit()
except Exception:
db.rollback()
# 2) generate missing codes in whichever column we have
# Prefer writing into "code" (new), but also keep join_code in sync if present.
code_col = "code" if has_code else "join_code" if has_join_code else None
if code_col:
try:
missing = db.execute(
text(f"SELECT id FROM games WHERE {code_col} IS NULL OR {code_col} = ''")
).all()
except Exception:
db.rollback()
missing = []
if missing:
try:
used_rows = db.execute(text(f"SELECT {code_col} FROM games WHERE {code_col} IS NOT NULL")).all()
used = set([r[0] for r in used_rows if r and r[0]])
except Exception:
db.rollback()
used = set()
for (gid,) in missing:
code = _rand_join_code() code = _rand_join_code()
while code in used: while code in used:
code = _rand_join_code() code = _rand_join_code()
g.join_code = code
used.add(code) used.add(code)
db.commit()
# Backfill membership: ensure owner is member try:
# write into main col
db.execute(text(f"UPDATE games SET {code_col} = :c WHERE id = :id"), {"c": code, "id": gid})
# keep both in sync if both exist
if has_join_code and code_col == "code":
db.execute(text("UPDATE games SET join_code = :c WHERE id = :id AND (join_code IS NULL OR join_code = '')"), {"c": code, "id": gid})
if has_code and code_col == "join_code":
db.execute(text("UPDATE games SET code = :c WHERE id = :id AND (code IS NULL OR code = '')"), {"c": code, "id": gid})
db.commit()
except Exception:
db.rollback()
# --- backfill host_user_id: default to owner_user_id ---
try:
if _has_column(db, "games", "host_user_id"):
db.execute(text("UPDATE games SET host_user_id = owner_user_id WHERE host_user_id IS NULL OR host_user_id = ''"))
db.commit()
except Exception:
db.rollback()
# --- backfill membership: ensure owner is member ---
# uses ORM; only relies on existing table GameMember (create_all already ran)
try:
all_games = db.query(Game).all() all_games = db.query(Game).all()
for g in all_games: for g in all_games:
exists = db.query(GameMember).filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id).first() exists = (
db.query(GameMember)
.filter(GameMember.game_id == g.id, GameMember.user_id == g.owner_user_id)
.first()
)
if not exists: if not exists:
db.add(GameMember(game_id=g.id, user_id=g.owner_user_id)) db.add(GameMember(game_id=g.id, user_id=g.owner_user_id))
db.commit() db.commit()
except Exception:
db.rollback()
# --- mirror chip_code -> chip if both exist and chip empty ---
if has_chip_code and has_chip:
try:
db.execute(text("UPDATE sheet_state SET chip = chip_code WHERE (chip IS NULL OR chip = '') AND chip_code IS NOT NULL AND chip_code <> ''"))
db.commit()
except Exception:
db.rollback()
def seed_entries(db: Session): def seed_entries(db: Session):
if db.query(Entry).count() > 0: if db.query(Entry).count() > 0:
return return
suspects = ["Draco Malfoy","Crabbe & Goyle","Lucius Malfoy","Dolores Umbridge","Peter Pettigrew","Bellatrix Lestrange"] suspects = ["Draco Malfoy", "Crabbe & Goyle", "Lucius Malfoy", "Dolores Umbridge", "Peter Pettigrew", "Bellatrix Lestrange"]
items = ["Schlaftrunk","Verschwindekabinett","Portschlüssel","Impedimenta","Petrificus Totalus","Alraune"] items = ["Schlaftrunk", "Verschwindekabinett", "Portschlüssel", "Impedimenta", "Petrificus Totalus", "Alraune"]
locations = ["Große Halle","Krankenflügel","Raum der Wünsche","Klassenzimmer für Zaubertränke","Pokalszimmer","Klassenzimmer für Wahrsagen","Eulerei","Bibliothek","Verteidigung gegen die dunklen Künste"] locations = ["Große Halle", "Krankenflügel", "Raum der Wünsche", "Klassenzimmer für Zaubertränke", "Pokalszimmer", "Klassenzimmer für Wahrsagen", "Eulerei", "Bibliothek", "Verteidigung gegen die dunklen Künste"]
for s in suspects: for s in suspects:
db.add(Entry(category=Category.suspect.value, label=s)) db.add(Entry(category=Category.suspect.value, label=s))
@@ -111,6 +252,7 @@ def seed_entries(db: Session):
db.add(Entry(category=Category.location.value, label=l)) db.add(Entry(category=Category.location.value, label=l))
db.commit() db.commit()
def ensure_admin(db: Session): def ensure_admin(db: Session):
admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip() admin_email = os.environ.get("ADMIN_EMAIL", "admin@local").lower().strip()
admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!") admin_pw = os.environ.get("ADMIN_PASSWORD", "ChangeMeNow123!")
@@ -126,6 +268,7 @@ def ensure_admin(db: Session):
) )
db.commit() db.commit()
@app.on_event("startup") @app.on_event("startup")
def on_startup(): def on_startup():
# create new tables # create new tables