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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user