add_multi_user.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225
  1. """
  2. Migration script to add multi-user support.
  3. This script:
  4. 1. Creates the User table
  5. 2. Creates a default admin user from environment variables
  6. 3. Migrates existing ListeningSession and Recommendation data to the default user
  7. """
  8. import os
  9. import sys
  10. import asyncio
  11. from datetime import datetime
  12. from sqlalchemy import text, inspect
  13. from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
  14. from sqlalchemy.orm import sessionmaker
  15. # Add parent directory to path for imports
  16. sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
  17. from models import Base, User
  18. from config import get_settings
  19. async def run_migration():
  20. """Run the multi-user migration."""
  21. settings = get_settings()
  22. # Convert sqlite:/// to sqlite+aiosqlite:///
  23. db_url = settings.database_url.replace("sqlite:///", "sqlite+aiosqlite:///")
  24. engine = create_async_engine(db_url, echo=True)
  25. async_session = sessionmaker(engine, class_=AsyncSession, expire_on_commit=False)
  26. async with engine.begin() as conn:
  27. # Check if migration has already been run
  28. result = await conn.execute(text(
  29. "SELECT name FROM sqlite_master WHERE type='table' AND name='users'"
  30. ))
  31. if result.fetchone():
  32. print("Migration already run - users table exists")
  33. return
  34. print("Starting multi-user migration...")
  35. # Step 1: Backup existing data
  36. print("Backing up existing listening_sessions...")
  37. sessions_backup = await conn.execute(text(
  38. "SELECT * FROM listening_sessions"
  39. ))
  40. sessions_data = sessions_backup.fetchall()
  41. sessions_columns = sessions_backup.keys()
  42. print("Backing up existing recommendations...")
  43. recs_backup = await conn.execute(text(
  44. "SELECT * FROM recommendations"
  45. ))
  46. recs_data = recs_backup.fetchall()
  47. recs_columns = recs_backup.keys()
  48. # Step 2: Create User table
  49. print("Creating users table...")
  50. await conn.execute(text("""
  51. CREATE TABLE users (
  52. id INTEGER PRIMARY KEY AUTOINCREMENT,
  53. username VARCHAR NOT NULL UNIQUE,
  54. email VARCHAR NOT NULL UNIQUE,
  55. hashed_password VARCHAR NOT NULL,
  56. abs_url VARCHAR NOT NULL,
  57. abs_api_token VARCHAR NOT NULL,
  58. display_name VARCHAR,
  59. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  60. last_login DATETIME,
  61. is_active BOOLEAN DEFAULT 1
  62. )
  63. """))
  64. await conn.execute(text(
  65. "CREATE INDEX ix_users_username ON users (username)"
  66. ))
  67. await conn.execute(text(
  68. "CREATE INDEX ix_users_email ON users (email)"
  69. ))
  70. # Step 3: Create default admin user
  71. print("Creating default admin user...")
  72. # Get credentials from environment
  73. abs_url = settings.abs_url if hasattr(settings, 'abs_url') else os.getenv('ABS_URL', '')
  74. abs_token = settings.abs_api_token if hasattr(settings, 'abs_api_token') else os.getenv('ABS_API_TOKEN', '')
  75. if not abs_url or not abs_token:
  76. print("WARNING: No ABS_URL or ABS_API_TOKEN found in environment")
  77. print("Creating placeholder admin user - update credentials in settings")
  78. abs_url = "http://localhost:13378"
  79. abs_token = "PLACEHOLDER_TOKEN"
  80. # For now, use a simple hash - will be replaced with proper bcrypt later
  81. # Password is "admin123" - user should change this immediately
  82. from passlib.hash import bcrypt
  83. default_password = bcrypt.hash("admin123")
  84. await conn.execute(text("""
  85. INSERT INTO users
  86. (username, email, hashed_password, abs_url, abs_api_token, display_name, created_at, is_active)
  87. VALUES
  88. (:username, :email, :password, :abs_url, :abs_token, :display_name, :created_at, :is_active)
  89. """), {
  90. "username": "admin",
  91. "email": "admin@localhost",
  92. "password": default_password,
  93. "abs_url": abs_url,
  94. "abs_token": abs_token,
  95. "display_name": "Admin User",
  96. "created_at": datetime.now(),
  97. "is_active": True
  98. })
  99. admin_id = (await conn.execute(text("SELECT last_insert_rowid()"))).scalar()
  100. print(f"Created admin user with ID: {admin_id}")
  101. # Step 4: Drop and recreate listening_sessions table
  102. print("Recreating listening_sessions table with user_id...")
  103. await conn.execute(text("DROP TABLE listening_sessions"))
  104. await conn.execute(text("""
  105. CREATE TABLE listening_sessions (
  106. id INTEGER PRIMARY KEY AUTOINCREMENT,
  107. user_id INTEGER NOT NULL,
  108. book_id VARCHAR NOT NULL,
  109. progress FLOAT DEFAULT 0.0,
  110. current_time FLOAT DEFAULT 0.0,
  111. is_finished BOOLEAN DEFAULT 0,
  112. started_at DATETIME,
  113. finished_at DATETIME,
  114. last_update DATETIME DEFAULT CURRENT_TIMESTAMP,
  115. rating INTEGER,
  116. FOREIGN KEY (user_id) REFERENCES users (id)
  117. )
  118. """))
  119. await conn.execute(text(
  120. "CREATE INDEX ix_listening_sessions_user_id ON listening_sessions (user_id)"
  121. ))
  122. # Step 5: Migrate listening_sessions data
  123. if sessions_data:
  124. print(f"Migrating {len(sessions_data)} listening sessions to admin user...")
  125. for row in sessions_data:
  126. row_dict = dict(zip(sessions_columns, row))
  127. await conn.execute(text("""
  128. INSERT INTO listening_sessions
  129. (user_id, book_id, progress, current_time, is_finished,
  130. started_at, finished_at, last_update, rating)
  131. VALUES
  132. (:user_id, :book_id, :progress, :current_time, :is_finished,
  133. :started_at, :finished_at, :last_update, :rating)
  134. """), {
  135. "user_id": admin_id,
  136. "book_id": row_dict.get("book_id"),
  137. "progress": row_dict.get("progress", 0.0),
  138. "current_time": row_dict.get("current_time", 0.0),
  139. "is_finished": row_dict.get("is_finished", False),
  140. "started_at": row_dict.get("started_at"),
  141. "finished_at": row_dict.get("finished_at"),
  142. "last_update": row_dict.get("last_update"),
  143. "rating": row_dict.get("rating")
  144. })
  145. # Step 6: Drop and recreate recommendations table
  146. print("Recreating recommendations table with user_id...")
  147. await conn.execute(text("DROP TABLE recommendations"))
  148. await conn.execute(text("""
  149. CREATE TABLE recommendations (
  150. id INTEGER PRIMARY KEY AUTOINCREMENT,
  151. user_id INTEGER NOT NULL,
  152. title VARCHAR NOT NULL,
  153. author VARCHAR,
  154. description TEXT,
  155. reason TEXT,
  156. genres VARCHAR,
  157. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  158. dismissed BOOLEAN DEFAULT 0,
  159. FOREIGN KEY (user_id) REFERENCES users (id)
  160. )
  161. """))
  162. await conn.execute(text(
  163. "CREATE INDEX ix_recommendations_user_id ON recommendations (user_id)"
  164. ))
  165. # Step 7: Migrate recommendations data
  166. if recs_data:
  167. print(f"Migrating {len(recs_data)} recommendations to admin user...")
  168. for row in recs_data:
  169. row_dict = dict(zip(recs_columns, row))
  170. await conn.execute(text("""
  171. INSERT INTO recommendations
  172. (user_id, title, author, description, reason, genres, created_at, dismissed)
  173. VALUES
  174. (:user_id, :title, :author, :description, :reason, :genres, :created_at, :dismissed)
  175. """), {
  176. "user_id": admin_id,
  177. "title": row_dict.get("title"),
  178. "author": row_dict.get("author"),
  179. "description": row_dict.get("description"),
  180. "reason": row_dict.get("reason"),
  181. "genres": row_dict.get("genres"),
  182. "created_at": row_dict.get("created_at"),
  183. "dismissed": row_dict.get("dismissed", False)
  184. })
  185. print("Migration completed successfully!")
  186. print("\nDEFAULT ADMIN CREDENTIALS:")
  187. print(" Username: admin")
  188. print(" Password: admin123")
  189. print(" Email: admin@localhost")
  190. print("\nIMPORTANT: Change the admin password after first login!")
  191. await engine.dispose()
  192. if __name__ == "__main__":
  193. asyncio.run(run_migration())