database.py 50 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272
  1. from sqlalchemy.exc import OperationalError
  2. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  3. from sqlalchemy.orm import DeclarativeBase
  4. from backend.app.core.config import settings
  5. engine = create_async_engine(
  6. settings.database_url,
  7. echo=settings.debug,
  8. )
  9. async_session = async_sessionmaker(
  10. engine,
  11. class_=AsyncSession,
  12. expire_on_commit=False,
  13. )
  14. async def close_all_connections():
  15. """Close all database connections for backup/restore operations."""
  16. global engine
  17. await engine.dispose()
  18. async def reinitialize_database():
  19. """Reinitialize database connection after restore."""
  20. global engine, async_session
  21. engine = create_async_engine(
  22. settings.database_url,
  23. echo=settings.debug,
  24. )
  25. async_session = async_sessionmaker(
  26. engine,
  27. class_=AsyncSession,
  28. expire_on_commit=False,
  29. )
  30. class Base(DeclarativeBase):
  31. pass
  32. async def get_db() -> AsyncSession:
  33. async with async_session() as session:
  34. try:
  35. yield session
  36. await session.commit()
  37. except Exception:
  38. await session.rollback()
  39. raise
  40. finally:
  41. await session.close()
  42. async def init_db():
  43. # Import models to register them with SQLAlchemy
  44. from backend.app.models import ( # noqa: F401
  45. active_print_spoolman,
  46. ams_history,
  47. api_key,
  48. archive,
  49. external_link,
  50. filament,
  51. github_backup,
  52. group,
  53. kprofile_note,
  54. library,
  55. maintenance,
  56. notification,
  57. notification_template,
  58. pending_upload,
  59. print_queue,
  60. printer,
  61. project,
  62. project_bom,
  63. settings,
  64. slot_preset,
  65. smart_plug,
  66. user,
  67. )
  68. async with engine.begin() as conn:
  69. await conn.run_sync(Base.metadata.create_all)
  70. # Run migrations for new columns (SQLite doesn't auto-add columns)
  71. await run_migrations(conn)
  72. # Seed default notification templates
  73. await seed_notification_templates()
  74. # Seed default groups and migrate existing users
  75. await seed_default_groups()
  76. async def run_migrations(conn):
  77. """Add new columns to existing tables if they don't exist."""
  78. from sqlalchemy import text
  79. # Migration: Add is_favorite column to print_archives
  80. try:
  81. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0"))
  82. except OperationalError:
  83. # Column already exists
  84. pass
  85. # Migration: Add content_hash column to print_archives for duplicate detection
  86. try:
  87. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)"))
  88. except OperationalError:
  89. # Column already exists
  90. pass
  91. # Migration: Add auto_off_executed column to smart_plugs
  92. try:
  93. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0"))
  94. except OperationalError:
  95. # Column already exists
  96. pass
  97. # Migration: Add on_print_stopped column to notification_providers
  98. try:
  99. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1"))
  100. except OperationalError:
  101. # Column already exists
  102. pass
  103. # Migration: Add source_3mf_path column to print_archives
  104. try:
  105. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)"))
  106. except OperationalError:
  107. # Column already exists
  108. pass
  109. # Migration: Add f3d_path column to print_archives for Fusion 360 design files
  110. try:
  111. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)"))
  112. except OperationalError:
  113. # Column already exists
  114. pass
  115. # Migration: Add on_maintenance_due column to notification_providers
  116. try:
  117. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0"))
  118. except OperationalError:
  119. # Column already exists
  120. pass
  121. # Migration: Add location column to printers for grouping
  122. try:
  123. await conn.execute(text("ALTER TABLE printers ADD COLUMN location VARCHAR(100)"))
  124. except OperationalError:
  125. # Column already exists
  126. pass
  127. # Migration: Add interval_type column to maintenance_types
  128. try:
  129. await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'"))
  130. except OperationalError:
  131. # Column already exists
  132. pass
  133. # Migration: Add custom_interval_type column to printer_maintenance
  134. try:
  135. await conn.execute(text("ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)"))
  136. except OperationalError:
  137. # Column already exists
  138. pass
  139. # Migration: Add power alert columns to smart_plugs
  140. try:
  141. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0"))
  142. except OperationalError:
  143. pass
  144. try:
  145. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL"))
  146. except OperationalError:
  147. pass
  148. try:
  149. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL"))
  150. except OperationalError:
  151. pass
  152. try:
  153. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME"))
  154. except OperationalError:
  155. pass
  156. # Migration: Add schedule columns to smart_plugs
  157. try:
  158. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0"))
  159. except OperationalError:
  160. pass
  161. try:
  162. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)"))
  163. except OperationalError:
  164. pass
  165. try:
  166. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)"))
  167. except OperationalError:
  168. pass
  169. # Migration: Add daily digest columns to notification_providers
  170. try:
  171. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0"))
  172. except OperationalError:
  173. pass
  174. try:
  175. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)"))
  176. except OperationalError:
  177. pass
  178. # Migration: Add project_id column to print_archives
  179. try:
  180. await conn.execute(
  181. text("ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  182. )
  183. except OperationalError:
  184. pass
  185. # Migration: Add project_id column to print_queue
  186. try:
  187. await conn.execute(
  188. text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  189. )
  190. except OperationalError:
  191. pass
  192. # Migration: Create FTS5 virtual table for archive full-text search
  193. try:
  194. await conn.execute(
  195. text("""
  196. CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
  197. print_name,
  198. filename,
  199. tags,
  200. notes,
  201. designer,
  202. filament_type,
  203. content='print_archives',
  204. content_rowid='id'
  205. )
  206. """)
  207. )
  208. except OperationalError:
  209. pass
  210. # Migration: Create triggers to keep FTS index in sync
  211. try:
  212. await conn.execute(
  213. text("""
  214. CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
  215. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  216. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  217. END
  218. """)
  219. )
  220. except OperationalError:
  221. pass
  222. try:
  223. await conn.execute(
  224. text("""
  225. CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
  226. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  227. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  228. END
  229. """)
  230. )
  231. except OperationalError:
  232. pass
  233. try:
  234. await conn.execute(
  235. text("""
  236. CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
  237. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  238. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  239. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  240. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  241. END
  242. """)
  243. )
  244. except OperationalError:
  245. pass
  246. # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
  247. try:
  248. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0"))
  249. except OperationalError:
  250. pass
  251. try:
  252. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME"))
  253. except OperationalError:
  254. pass
  255. # Migration: Add AMS alarm notification columns to notification_providers
  256. try:
  257. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0"))
  258. except OperationalError:
  259. pass
  260. try:
  261. await conn.execute(
  262. text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
  263. )
  264. except OperationalError:
  265. pass
  266. # Migration: Add AMS-HT alarm notification columns to notification_providers
  267. try:
  268. await conn.execute(
  269. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
  270. )
  271. except OperationalError:
  272. pass
  273. try:
  274. await conn.execute(
  275. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
  276. )
  277. except OperationalError:
  278. pass
  279. # Migration: Add plate not empty notification column to notification_providers
  280. try:
  281. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1"))
  282. except OperationalError:
  283. pass
  284. # Migration: Add notes column to projects (Phase 2)
  285. try:
  286. await conn.execute(text("ALTER TABLE projects ADD COLUMN notes TEXT"))
  287. except OperationalError:
  288. pass
  289. # Migration: Add attachments column to projects (Phase 3)
  290. try:
  291. await conn.execute(text("ALTER TABLE projects ADD COLUMN attachments JSON"))
  292. except OperationalError:
  293. pass
  294. # Migration: Add tags column to projects (Phase 4)
  295. try:
  296. await conn.execute(text("ALTER TABLE projects ADD COLUMN tags TEXT"))
  297. except OperationalError:
  298. pass
  299. # Migration: Add due_date column to projects (Phase 5)
  300. try:
  301. await conn.execute(text("ALTER TABLE projects ADD COLUMN due_date DATETIME"))
  302. except OperationalError:
  303. pass
  304. # Migration: Add priority column to projects (Phase 5)
  305. try:
  306. await conn.execute(text("ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'"))
  307. except OperationalError:
  308. pass
  309. # Migration: Add budget column to projects (Phase 6)
  310. try:
  311. await conn.execute(text("ALTER TABLE projects ADD COLUMN budget REAL"))
  312. except OperationalError:
  313. pass
  314. # Migration: Add is_template column to projects (Phase 8)
  315. try:
  316. await conn.execute(text("ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0"))
  317. except OperationalError:
  318. pass
  319. # Migration: Add template_source_id column to projects (Phase 8)
  320. try:
  321. await conn.execute(text("ALTER TABLE projects ADD COLUMN template_source_id INTEGER"))
  322. except OperationalError:
  323. pass
  324. # Migration: Add parent_id column to projects (Phase 10)
  325. try:
  326. await conn.execute(
  327. text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  328. )
  329. except OperationalError:
  330. pass
  331. # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
  332. try:
  333. await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired"))
  334. except OperationalError:
  335. pass
  336. # Migration: Add unit_price column to project_bom_items
  337. try:
  338. await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN unit_price REAL"))
  339. except OperationalError:
  340. pass
  341. # Migration: Add sourcing_url column to project_bom_items
  342. try:
  343. await conn.execute(text("ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)"))
  344. except OperationalError:
  345. pass
  346. # Migration: Rename notes to remarks in project_bom_items
  347. try:
  348. await conn.execute(text("ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks"))
  349. except OperationalError:
  350. pass
  351. # Migration: Add show_in_switchbar column to smart_plugs
  352. try:
  353. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0"))
  354. except OperationalError:
  355. pass
  356. # Migration: Add runtime tracking columns to printers
  357. try:
  358. await conn.execute(text("ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0"))
  359. except OperationalError:
  360. pass
  361. try:
  362. await conn.execute(text("ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME"))
  363. except OperationalError:
  364. pass
  365. # Migration: Add quantity column to print_archives for tracking item count
  366. try:
  367. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1"))
  368. except OperationalError:
  369. pass
  370. # Migration: Add manual_start column to print_queue for staged prints
  371. try:
  372. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0"))
  373. except OperationalError:
  374. pass
  375. # Migration: Add wiki_url column to maintenance_types for documentation links
  376. try:
  377. await conn.execute(text("ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)"))
  378. except OperationalError:
  379. pass
  380. # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
  381. try:
  382. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT"))
  383. except OperationalError:
  384. pass
  385. # Migration: Add target_parts_count column to projects for tracking total parts needed
  386. try:
  387. await conn.execute(text("ALTER TABLE projects ADD COLUMN target_parts_count INTEGER"))
  388. except OperationalError:
  389. pass
  390. # Migration: Make printer_id nullable in print_queue for unassigned queue items
  391. # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
  392. try:
  393. # Check if printer_id is already nullable by trying to insert NULL
  394. # This is a safe check that won't affect existing data
  395. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  396. row = result.fetchone()
  397. if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
  398. # Need to migrate - printer_id is currently NOT NULL
  399. await conn.execute(
  400. text("""
  401. CREATE TABLE print_queue_new (
  402. id INTEGER PRIMARY KEY,
  403. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  404. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  405. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  406. position INTEGER DEFAULT 0,
  407. scheduled_time DATETIME,
  408. manual_start BOOLEAN DEFAULT 0,
  409. require_previous_success BOOLEAN DEFAULT 0,
  410. auto_off_after BOOLEAN DEFAULT 0,
  411. ams_mapping TEXT,
  412. status VARCHAR(20) DEFAULT 'pending',
  413. started_at DATETIME,
  414. completed_at DATETIME,
  415. error_message TEXT,
  416. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  417. )
  418. """)
  419. )
  420. await conn.execute(
  421. text("""
  422. INSERT INTO print_queue_new
  423. SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
  424. manual_start, require_previous_success, auto_off_after, ams_mapping,
  425. status, started_at, completed_at, error_message, created_at
  426. FROM print_queue
  427. """)
  428. )
  429. await conn.execute(text("DROP TABLE print_queue"))
  430. await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
  431. except OperationalError:
  432. pass
  433. # Migration: Add plug_type column to smart_plugs for HA integration
  434. try:
  435. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'"))
  436. except OperationalError:
  437. pass
  438. # Migration: Add ha_entity_id column to smart_plugs for HA integration
  439. try:
  440. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)"))
  441. except OperationalError:
  442. pass
  443. # Migration: Add project_id column to library_folders for linking folders to projects
  444. try:
  445. await conn.execute(
  446. text("ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  447. )
  448. except OperationalError:
  449. pass
  450. # Migration: Add archive_id column to library_folders for linking folders to archives
  451. try:
  452. await conn.execute(
  453. text(
  454. "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
  455. )
  456. )
  457. except OperationalError:
  458. pass
  459. # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
  460. try:
  461. # Check if ip_address is currently NOT NULL
  462. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  463. row = result.fetchone()
  464. if row and "ip_address VARCHAR(45) NOT NULL" in (row[0] or ""):
  465. # Need to migrate - ip_address is currently NOT NULL
  466. await conn.execute(
  467. text("""
  468. CREATE TABLE smart_plugs_new (
  469. id INTEGER PRIMARY KEY,
  470. name VARCHAR(100) NOT NULL,
  471. ip_address VARCHAR(45),
  472. plug_type VARCHAR(20) DEFAULT 'tasmota',
  473. ha_entity_id VARCHAR(100),
  474. printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,
  475. enabled BOOLEAN NOT NULL DEFAULT 1,
  476. auto_on BOOLEAN NOT NULL DEFAULT 1,
  477. auto_off BOOLEAN NOT NULL DEFAULT 1,
  478. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  479. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  480. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  481. username VARCHAR(50),
  482. password VARCHAR(100),
  483. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  484. power_alert_high FLOAT,
  485. power_alert_low FLOAT,
  486. power_alert_last_triggered DATETIME,
  487. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  488. schedule_on_time VARCHAR(5),
  489. schedule_off_time VARCHAR(5),
  490. show_in_switchbar BOOLEAN DEFAULT 0,
  491. last_state VARCHAR(10),
  492. last_checked DATETIME,
  493. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  494. auto_off_pending BOOLEAN DEFAULT 0,
  495. auto_off_pending_since DATETIME,
  496. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  497. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  498. )
  499. """)
  500. )
  501. await conn.execute(
  502. text("""
  503. INSERT INTO smart_plugs_new
  504. SELECT id, name, ip_address,
  505. COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
  506. enabled, auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
  507. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  508. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  509. COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
  510. COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at
  511. FROM smart_plugs
  512. """)
  513. )
  514. await conn.execute(text("DROP TABLE smart_plugs"))
  515. await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
  516. except OperationalError:
  517. pass
  518. # Migration: Add plate_id column to print_queue for multi-plate 3MF support
  519. try:
  520. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN plate_id INTEGER"))
  521. except OperationalError:
  522. pass
  523. # Migration: Add print options columns to print_queue
  524. try:
  525. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1"))
  526. except OperationalError:
  527. pass
  528. try:
  529. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0"))
  530. except OperationalError:
  531. pass
  532. try:
  533. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1"))
  534. except OperationalError:
  535. pass
  536. try:
  537. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0"))
  538. except OperationalError:
  539. pass
  540. try:
  541. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0"))
  542. except OperationalError:
  543. pass
  544. try:
  545. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1"))
  546. except OperationalError:
  547. pass
  548. # Migration: Add library_file_id column to print_queue and make archive_id nullable
  549. # This allows queue items to reference library files directly (archive created at print start)
  550. try:
  551. await conn.execute(
  552. text(
  553. "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
  554. )
  555. )
  556. except OperationalError:
  557. pass
  558. # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
  559. try:
  560. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  561. row = result.fetchone()
  562. if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
  563. # Need to migrate - archive_id is currently NOT NULL
  564. await conn.execute(
  565. text("""
  566. CREATE TABLE print_queue_new2 (
  567. id INTEGER PRIMARY KEY,
  568. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  569. archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
  570. library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
  571. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  572. position INTEGER DEFAULT 0,
  573. scheduled_time DATETIME,
  574. manual_start BOOLEAN DEFAULT 0,
  575. require_previous_success BOOLEAN DEFAULT 0,
  576. auto_off_after BOOLEAN DEFAULT 0,
  577. ams_mapping TEXT,
  578. plate_id INTEGER,
  579. bed_levelling BOOLEAN DEFAULT 1,
  580. flow_cali BOOLEAN DEFAULT 0,
  581. vibration_cali BOOLEAN DEFAULT 1,
  582. layer_inspect BOOLEAN DEFAULT 0,
  583. timelapse BOOLEAN DEFAULT 0,
  584. use_ams BOOLEAN DEFAULT 1,
  585. status VARCHAR(20) DEFAULT 'pending',
  586. started_at DATETIME,
  587. completed_at DATETIME,
  588. error_message TEXT,
  589. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  590. )
  591. """)
  592. )
  593. await conn.execute(
  594. text("""
  595. INSERT INTO print_queue_new2
  596. SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
  597. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  598. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  599. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  600. status, started_at, completed_at, error_message, created_at
  601. FROM print_queue
  602. """)
  603. )
  604. await conn.execute(text("DROP TABLE print_queue"))
  605. await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
  606. except OperationalError:
  607. pass
  608. # Migration: Add HA energy sensor entity columns to smart_plugs
  609. try:
  610. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)"))
  611. except OperationalError:
  612. pass
  613. try:
  614. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)"))
  615. except OperationalError:
  616. pass
  617. try:
  618. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)"))
  619. except OperationalError:
  620. pass
  621. # Migration: Create users table for authentication
  622. try:
  623. await conn.execute(
  624. text("""
  625. CREATE TABLE IF NOT EXISTS users (
  626. id INTEGER PRIMARY KEY,
  627. username VARCHAR(100) NOT NULL UNIQUE,
  628. password_hash VARCHAR(255) NOT NULL,
  629. role VARCHAR(20) NOT NULL DEFAULT 'user',
  630. is_active BOOLEAN NOT NULL DEFAULT 1,
  631. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  632. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  633. )
  634. """)
  635. )
  636. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
  637. except OperationalError:
  638. pass
  639. # Migration: Add external camera columns to printers
  640. try:
  641. await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)"))
  642. except OperationalError:
  643. pass
  644. try:
  645. await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)"))
  646. except OperationalError:
  647. pass
  648. try:
  649. await conn.execute(text("ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0"))
  650. except OperationalError:
  651. pass
  652. # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
  653. try:
  654. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)"))
  655. except OperationalError:
  656. pass
  657. # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
  658. try:
  659. await conn.execute(text("ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)"))
  660. except OperationalError:
  661. pass
  662. # Migration: Add is_external column to library_files for external cloud files
  663. try:
  664. await conn.execute(text("ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0"))
  665. except OperationalError:
  666. pass
  667. # Migration: Add project_id column to library_files
  668. try:
  669. await conn.execute(
  670. text("ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  671. )
  672. except OperationalError:
  673. pass
  674. # Migration: Add is_external column to library_folders for external cloud folders
  675. try:
  676. await conn.execute(text("ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0"))
  677. except OperationalError:
  678. pass
  679. # Migration: Add external folder settings columns to library_folders
  680. try:
  681. await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0"))
  682. except OperationalError:
  683. pass
  684. try:
  685. await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0"))
  686. except OperationalError:
  687. pass
  688. try:
  689. await conn.execute(text("ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)"))
  690. except OperationalError:
  691. pass
  692. # Migration: Add plate_detection_enabled column to printers
  693. try:
  694. await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0"))
  695. except OperationalError:
  696. pass
  697. # Migration: Add plate detection ROI columns to printers
  698. try:
  699. await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL"))
  700. except OperationalError:
  701. pass
  702. try:
  703. await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL"))
  704. except OperationalError:
  705. pass
  706. try:
  707. await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL"))
  708. except OperationalError:
  709. pass
  710. try:
  711. await conn.execute(text("ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL"))
  712. except OperationalError:
  713. pass
  714. # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
  715. # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
  716. # SQLite requires table recreation to drop constraints
  717. try:
  718. # Check if we need to migrate (if UNIQUE constraint exists)
  719. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  720. row = result.fetchone()
  721. if row and "printer_id INTEGER UNIQUE" in (row[0] or ""):
  722. # Create new table without UNIQUE constraint on printer_id
  723. await conn.execute(
  724. text("""
  725. CREATE TABLE smart_plugs_temp (
  726. id INTEGER PRIMARY KEY,
  727. name VARCHAR(100) NOT NULL,
  728. ip_address VARCHAR(45),
  729. plug_type VARCHAR(20) DEFAULT 'tasmota',
  730. ha_entity_id VARCHAR(100),
  731. ha_power_entity VARCHAR(100),
  732. ha_energy_today_entity VARCHAR(100),
  733. ha_energy_total_entity VARCHAR(100),
  734. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  735. enabled BOOLEAN NOT NULL DEFAULT 1,
  736. auto_on BOOLEAN NOT NULL DEFAULT 1,
  737. auto_off BOOLEAN NOT NULL DEFAULT 1,
  738. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  739. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  740. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  741. username VARCHAR(50),
  742. password VARCHAR(100),
  743. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  744. power_alert_high FLOAT,
  745. power_alert_low FLOAT,
  746. power_alert_last_triggered DATETIME,
  747. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  748. schedule_on_time VARCHAR(5),
  749. schedule_off_time VARCHAR(5),
  750. show_in_switchbar BOOLEAN DEFAULT 0,
  751. last_state VARCHAR(10),
  752. last_checked DATETIME,
  753. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  754. auto_off_pending BOOLEAN DEFAULT 0,
  755. auto_off_pending_since DATETIME,
  756. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  757. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  758. )
  759. """)
  760. )
  761. # Copy data
  762. await conn.execute(
  763. text("""
  764. INSERT INTO smart_plugs_temp
  765. SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
  766. ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
  767. auto_on, auto_off, off_delay_mode, off_delay_minutes, off_temp_threshold,
  768. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  769. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  770. show_in_switchbar, last_state, last_checked, auto_off_executed,
  771. auto_off_pending, auto_off_pending_since, created_at, updated_at
  772. FROM smart_plugs
  773. """)
  774. )
  775. # Drop old table and rename new one
  776. await conn.execute(text("DROP TABLE smart_plugs"))
  777. await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
  778. except OperationalError:
  779. pass
  780. # Migration: Add show_on_printer_card column to smart_plugs
  781. try:
  782. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1"))
  783. except OperationalError:
  784. pass
  785. # Migration: Add MQTT smart plug fields (legacy)
  786. try:
  787. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)"))
  788. except OperationalError:
  789. pass
  790. try:
  791. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)"))
  792. except OperationalError:
  793. pass
  794. try:
  795. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)"))
  796. except OperationalError:
  797. pass
  798. try:
  799. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)"))
  800. except OperationalError:
  801. pass
  802. try:
  803. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0"))
  804. except OperationalError:
  805. pass
  806. # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
  807. try:
  808. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)"))
  809. except OperationalError:
  810. pass
  811. try:
  812. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0"))
  813. except OperationalError:
  814. pass
  815. try:
  816. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)"))
  817. except OperationalError:
  818. pass
  819. try:
  820. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0"))
  821. except OperationalError:
  822. pass
  823. try:
  824. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)"))
  825. except OperationalError:
  826. pass
  827. try:
  828. await conn.execute(text("ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)"))
  829. except OperationalError:
  830. pass
  831. # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
  832. try:
  833. await conn.execute(
  834. text("""
  835. UPDATE smart_plugs
  836. SET mqtt_power_topic = mqtt_topic,
  837. mqtt_power_multiplier = mqtt_multiplier
  838. WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
  839. """)
  840. )
  841. except OperationalError:
  842. pass
  843. # Migration: Create groups table for permission-based access control
  844. try:
  845. await conn.execute(
  846. text("""
  847. CREATE TABLE IF NOT EXISTS groups (
  848. id INTEGER PRIMARY KEY,
  849. name VARCHAR(100) NOT NULL UNIQUE,
  850. description VARCHAR(500),
  851. permissions JSON,
  852. is_system BOOLEAN NOT NULL DEFAULT 0,
  853. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  854. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  855. )
  856. """)
  857. )
  858. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
  859. except OperationalError:
  860. pass
  861. # Migration: Create user_groups association table
  862. try:
  863. await conn.execute(
  864. text("""
  865. CREATE TABLE IF NOT EXISTS user_groups (
  866. user_id INTEGER NOT NULL,
  867. group_id INTEGER NOT NULL,
  868. PRIMARY KEY (user_id, group_id),
  869. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  870. FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
  871. )
  872. """)
  873. )
  874. except OperationalError:
  875. pass
  876. # Migration: Add model-based queue assignment columns to print_queue
  877. try:
  878. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)"))
  879. except OperationalError:
  880. pass
  881. try:
  882. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT"))
  883. except OperationalError:
  884. pass
  885. try:
  886. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT"))
  887. except OperationalError:
  888. pass
  889. # Migration: Add nozzle_count column to printers (for dual-extruder detection)
  890. try:
  891. await conn.execute(text("ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1"))
  892. except OperationalError:
  893. pass
  894. # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
  895. try:
  896. await conn.execute(text("ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0"))
  897. except OperationalError:
  898. pass
  899. # Migration: Add queue notification event columns to notification_providers
  900. try:
  901. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0"))
  902. except OperationalError:
  903. pass
  904. try:
  905. await conn.execute(
  906. text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
  907. )
  908. except OperationalError:
  909. pass
  910. try:
  911. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0"))
  912. except OperationalError:
  913. pass
  914. try:
  915. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1"))
  916. except OperationalError:
  917. pass
  918. try:
  919. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1"))
  920. except OperationalError:
  921. pass
  922. try:
  923. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1"))
  924. except OperationalError:
  925. pass
  926. try:
  927. await conn.execute(text("ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0"))
  928. except OperationalError:
  929. pass
  930. # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
  931. try:
  932. await conn.execute(
  933. text("ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  934. )
  935. except OperationalError:
  936. pass
  937. # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
  938. try:
  939. await conn.execute(
  940. text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  941. )
  942. except OperationalError:
  943. pass
  944. # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
  945. try:
  946. await conn.execute(
  947. text("ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  948. )
  949. except OperationalError:
  950. pass
  951. # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
  952. try:
  953. await conn.execute(text("ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)"))
  954. except OperationalError:
  955. pass
  956. # Migration: Convert absolute paths to relative paths in library_files table
  957. # This ensures backup/restore portability across different installations
  958. try:
  959. base_dir_str = str(settings.base_dir)
  960. # Ensure we have a trailing slash for clean replacement
  961. if not base_dir_str.endswith("/"):
  962. base_dir_str += "/"
  963. # Update file_path - remove base_dir prefix from absolute paths
  964. await conn.execute(
  965. text("""
  966. UPDATE library_files
  967. SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
  968. WHERE file_path LIKE :pattern
  969. """),
  970. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  971. )
  972. # Update thumbnail_path - remove base_dir prefix from absolute paths
  973. await conn.execute(
  974. text("""
  975. UPDATE library_files
  976. SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
  977. WHERE thumbnail_path LIKE :pattern
  978. """),
  979. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  980. )
  981. except OperationalError:
  982. pass
  983. # Create active_print_spoolman table for Spoolman per-filament tracking
  984. try:
  985. await conn.execute(
  986. text("""
  987. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  988. id INTEGER PRIMARY KEY AUTOINCREMENT,
  989. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  990. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  991. filament_usage TEXT NOT NULL,
  992. ams_trays TEXT NOT NULL,
  993. slot_to_tray TEXT,
  994. layer_usage TEXT,
  995. filament_properties TEXT,
  996. UNIQUE(printer_id, archive_id)
  997. )
  998. """)
  999. )
  1000. except OperationalError:
  1001. pass
  1002. async def seed_notification_templates():
  1003. """Seed default notification templates if they don't exist."""
  1004. from sqlalchemy import select
  1005. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  1006. async with async_session() as session:
  1007. # Get existing template event types
  1008. result = await session.execute(select(NotificationTemplate.event_type))
  1009. existing_types = {row[0] for row in result.fetchall()}
  1010. if not existing_types:
  1011. # No templates exist - insert all defaults
  1012. for template_data in DEFAULT_TEMPLATES:
  1013. template = NotificationTemplate(
  1014. event_type=template_data["event_type"],
  1015. name=template_data["name"],
  1016. title_template=template_data["title_template"],
  1017. body_template=template_data["body_template"],
  1018. is_default=True,
  1019. )
  1020. session.add(template)
  1021. else:
  1022. # Templates exist - only add missing ones
  1023. for template_data in DEFAULT_TEMPLATES:
  1024. if template_data["event_type"] not in existing_types:
  1025. template = NotificationTemplate(
  1026. event_type=template_data["event_type"],
  1027. name=template_data["name"],
  1028. title_template=template_data["title_template"],
  1029. body_template=template_data["body_template"],
  1030. is_default=True,
  1031. )
  1032. session.add(template)
  1033. await session.commit()
  1034. async def seed_default_groups():
  1035. """Seed default groups and migrate existing users to appropriate groups.
  1036. Creates the default system groups (Administrators, Operators, Viewers) if they
  1037. don't exist, then migrates existing users:
  1038. - Users with role='admin' -> Administrators group
  1039. - Users with role='user' -> Operators group
  1040. Also migrates old permissions to new ownership-based permissions (Issue #205).
  1041. """
  1042. import logging
  1043. from sqlalchemy import select
  1044. from backend.app.core.permissions import DEFAULT_GROUPS
  1045. from backend.app.models.group import Group
  1046. from backend.app.models.user import User
  1047. logger = logging.getLogger(__name__)
  1048. # Map old permissions to new ones for migration
  1049. # Administrators get *_all permissions, Operators get *_own permissions
  1050. PERMISSION_MIGRATION_ALL = {
  1051. "queue:update": "queue:update_all",
  1052. "queue:delete": "queue:delete_all",
  1053. "archives:update": "archives:update_all",
  1054. "archives:delete": "archives:delete_all",
  1055. "archives:reprint": "archives:reprint_all",
  1056. "library:update": "library:update_all",
  1057. "library:delete": "library:delete_all",
  1058. }
  1059. PERMISSION_MIGRATION_OWN = {
  1060. "queue:update": "queue:update_own",
  1061. "queue:delete": "queue:delete_own",
  1062. "archives:update": "archives:update_own",
  1063. "archives:delete": "archives:delete_own",
  1064. "archives:reprint": "archives:reprint_own",
  1065. "library:update": "library:update_own",
  1066. "library:delete": "library:delete_own",
  1067. }
  1068. async with async_session() as session:
  1069. # Get existing groups
  1070. result = await session.execute(select(Group))
  1071. existing_groups = {group.name: group for group in result.scalars().all()}
  1072. # Create default groups if they don't exist
  1073. groups_created = []
  1074. for group_name, group_config in DEFAULT_GROUPS.items():
  1075. if group_name not in existing_groups:
  1076. group = Group(
  1077. name=group_name,
  1078. description=group_config["description"],
  1079. permissions=group_config["permissions"],
  1080. is_system=group_config["is_system"],
  1081. )
  1082. session.add(group)
  1083. groups_created.append(group_name)
  1084. logger.info("Created default group: %s", group_name)
  1085. else:
  1086. # Migrate existing group's permissions from old to new format
  1087. group = existing_groups[group_name]
  1088. if group.permissions:
  1089. updated = False
  1090. new_permissions = list(group.permissions)
  1091. # Determine which migration map to use based on group
  1092. migration_map = (
  1093. PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
  1094. )
  1095. for old_perm, new_perm in migration_map.items():
  1096. if old_perm in new_permissions:
  1097. new_permissions.remove(old_perm)
  1098. if new_perm not in new_permissions:
  1099. new_permissions.append(new_perm)
  1100. updated = True
  1101. logger.info(
  1102. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  1103. )
  1104. # For Administrators, also ensure they get *_all permissions if they have any new *_own
  1105. if group_name == "Administrators":
  1106. for _own_perm, all_perm in [
  1107. ("queue:update_own", "queue:update_all"),
  1108. ("queue:delete_own", "queue:delete_all"),
  1109. ("archives:update_own", "archives:update_all"),
  1110. ("archives:delete_own", "archives:delete_all"),
  1111. ("archives:reprint_own", "archives:reprint_all"),
  1112. ("library:update_own", "library:update_all"),
  1113. ("library:delete_own", "library:delete_all"),
  1114. ]:
  1115. # Add *_all if not present
  1116. if all_perm not in new_permissions:
  1117. new_permissions.append(all_perm)
  1118. updated = True
  1119. if updated:
  1120. group.permissions = new_permissions
  1121. await session.commit()
  1122. # Migrate existing users to groups if they're not already in any group
  1123. if groups_created:
  1124. # Refresh to get newly created groups
  1125. admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
  1126. admin_group = admin_result.scalar_one_or_none()
  1127. operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
  1128. operators_group = operators_result.scalar_one_or_none()
  1129. # Get all users
  1130. users_result = await session.execute(select(User))
  1131. users = users_result.scalars().all()
  1132. for user in users:
  1133. # Skip if user already has groups
  1134. if user.groups:
  1135. continue
  1136. if user.role == "admin" and admin_group:
  1137. user.groups.append(admin_group)
  1138. logger.info("Migrated admin user '%s' to Administrators group", user.username)
  1139. elif operators_group:
  1140. user.groups.append(operators_group)
  1141. logger.info("Migrated user '%s' to Operators group", user.username)
  1142. await session.commit()