Add versioned DB migration system with automatic backup

migrations.py
- schema_migrations table tracks applied versions (version, description, applied_at)
- MIGRATIONS list is append-only; each entry is (version, description, sql)
- backup() dumps all user-data tables to a timestamped JSON file in backups/
  before any schema changes so data can be recovered if something goes wrong
- run_migrations() is idempotent: already-applied versions are skipped

Integration
- app.py calls _run_startup_migrations() at module load so every restart
  applies any pending migrations (no-op if schema is current)
- setup_routes.py calls run_migrations() after the initial setup form is
  submitted so all tables exist before the user hits the main page for the
  first time
- notes.py and admin.py: removed all per-request CREATE TABLE DDL; schema is
  now owned entirely by the migration system

Docker
- docker-compose.dev.yml: add backups-data volume so JSON backups survive
  container restarts and rebuilds
- Dockerfile: pre-create /app/backend/logs and /app/backend/backups so the
  directories exist even before volumes are mounted

Adding future schema changes
- Append a new (version, description, sql) tuple to MIGRATIONS in migrations.py
- The next restart will detect it as pending, back up first, then apply it

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Nirodan
2026-05-06 10:27:11 +02:00
parent 20cdbd69eb
commit 9db922703b
7 changed files with 202 additions and 75 deletions
-40
View File
@@ -8,10 +8,6 @@ admin_bp = Blueprint("admin", __name__)
_VALID_ROLES = {"user", "admin"}
# Module-level flag: DDL runs at most once per process lifetime.
# Resets automatically on worker restart, which re-triggers the check.
_tables_initialized = False
def _require_admin():
user = verify_token()
@@ -23,33 +19,6 @@ def _require_admin():
return user, None
def _ensure_tables(cur):
global _tables_initialized
if _tables_initialized:
return
cur.execute(
"""
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(255) NOT NULL,
role VARCHAR(20) NOT NULL
)
"""
)
cur.execute(
"""
CREATE TABLE IF NOT EXISTS websites (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(100) NOT NULL,
url VARCHAR(255) NOT NULL,
description VARCHAR(255) DEFAULT ''
)
"""
)
_tables_initialized = True
@admin_bp.route("/api/admin/users", methods=["GET"])
def list_users():
_, err = _require_admin()
@@ -59,7 +28,6 @@ def list_users():
conn = get_connection()
try:
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, username, role FROM users ORDER BY username ASC")
users = cur.fetchall()
cur.close()
@@ -88,7 +56,6 @@ def create_user():
conn = get_connection()
try:
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id FROM users WHERE username=%s", (username,))
if cur.fetchone():
cur.close()
@@ -125,7 +92,6 @@ def update_user(user_id):
conn = get_connection()
try:
cur = conn.cursor()
_ensure_tables(cur)
if role:
cur.execute("UPDATE users SET role=%s WHERE id=%s", (role, user_id))
if password:
@@ -153,7 +119,6 @@ def delete_user(user_id):
conn = get_connection()
try:
cur = conn.cursor()
_ensure_tables(cur)
cur.execute("SELECT username FROM users WHERE id=%s", (user_id,))
row = cur.fetchone()
if not row:
@@ -186,7 +151,6 @@ def list_websites_admin():
conn = get_connection()
try:
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()
@@ -213,7 +177,6 @@ def create_website():
conn = get_connection()
try:
cur = conn.cursor()
_ensure_tables(cur)
cur.execute(
"INSERT INTO websites (name, url, description) VALUES (%s, %s, %s)",
(name, url, description),
@@ -244,7 +207,6 @@ def update_website(item_id):
conn = get_connection()
try:
cur = conn.cursor()
_ensure_tables(cur)
cur.execute(
"UPDATE websites SET name=%s, url=%s, description=%s WHERE id=%s",
(name, url, description, item_id),
@@ -271,7 +233,6 @@ def delete_website(item_id):
conn = get_connection()
try:
cur = conn.cursor()
_ensure_tables(cur)
cur.execute("DELETE FROM websites WHERE id=%s", (item_id,))
conn.commit()
affected = cur.rowcount
@@ -297,7 +258,6 @@ def list_websites_public():
conn = get_connection()
try:
cur = conn.cursor(dictionary=True)
_ensure_tables(cur)
cur.execute("SELECT id, name, url, description FROM websites ORDER BY name ASC")
rows = cur.fetchall()
cur.close()