database.py 134 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854185518561857185818591860186118621863186418651866186718681869187018711872187318741875187618771878187918801881188218831884188518861887188818891890189118921893189418951896189718981899190019011902190319041905190619071908190919101911191219131914191519161917191819191920192119221923192419251926192719281929193019311932193319341935193619371938193919401941194219431944194519461947194819491950195119521953195419551956195719581959196019611962196319641965196619671968196919701971197219731974197519761977197819791980198119821983198419851986198719881989199019911992199319941995199619971998199920002001200220032004200520062007200820092010201120122013201420152016201720182019202020212022202320242025202620272028202920302031203220332034203520362037203820392040204120422043204420452046204720482049205020512052205320542055205620572058205920602061206220632064206520662067206820692070207120722073207420752076207720782079208020812082208320842085208620872088208920902091209220932094209520962097209820992100210121022103210421052106210721082109211021112112211321142115211621172118211921202121212221232124212521262127212821292130213121322133213421352136213721382139214021412142214321442145214621472148214921502151215221532154215521562157215821592160216121622163216421652166216721682169217021712172217321742175217621772178217921802181218221832184218521862187218821892190219121922193219421952196219721982199220022012202220322042205220622072208220922102211221222132214221522162217221822192220222122222223222422252226222722282229223022312232223322342235223622372238223922402241224222432244224522462247224822492250225122522253225422552256225722582259226022612262226322642265226622672268226922702271227222732274227522762277227822792280228122822283228422852286228722882289229022912292229322942295229622972298229923002301230223032304230523062307230823092310231123122313231423152316231723182319232023212322232323242325232623272328232923302331233223332334233523362337233823392340234123422343234423452346234723482349235023512352235323542355235623572358235923602361236223632364236523662367236823692370237123722373237423752376237723782379238023812382238323842385238623872388238923902391239223932394239523962397239823992400240124022403240424052406240724082409241024112412241324142415241624172418241924202421242224232424242524262427242824292430243124322433243424352436243724382439244024412442244324442445244624472448244924502451245224532454245524562457245824592460246124622463246424652466246724682469247024712472247324742475247624772478247924802481248224832484248524862487248824892490249124922493249424952496249724982499250025012502250325042505250625072508250925102511251225132514251525162517251825192520252125222523252425252526252725282529253025312532253325342535253625372538253925402541254225432544254525462547254825492550255125522553255425552556255725582559256025612562256325642565256625672568256925702571257225732574257525762577257825792580258125822583258425852586258725882589259025912592259325942595259625972598259926002601260226032604260526062607260826092610261126122613261426152616261726182619262026212622262326242625262626272628262926302631263226332634263526362637263826392640264126422643264426452646264726482649265026512652265326542655265626572658265926602661266226632664266526662667266826692670267126722673267426752676267726782679268026812682268326842685268626872688268926902691269226932694269526962697269826992700270127022703270427052706270727082709271027112712271327142715271627172718271927202721272227232724272527262727272827292730273127322733273427352736273727382739274027412742274327442745274627472748274927502751275227532754275527562757275827592760276127622763276427652766276727682769277027712772277327742775277627772778277927802781
  1. import asyncio
  2. import logging
  3. from sqlalchemy import event
  4. from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
  5. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  6. from sqlalchemy.orm import DeclarativeBase
  7. from backend.app.core.config import settings
  8. from backend.app.core.db_dialect import is_sqlite
  9. logger = logging.getLogger(__name__)
  10. def _set_sqlite_pragmas(dbapi_conn, connection_record):
  11. """Set SQLite pragmas on each new connection for concurrency and performance."""
  12. cursor = dbapi_conn.cursor()
  13. # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
  14. cursor.execute("PRAGMA journal_mode = WAL")
  15. # Wait up to 15 seconds when the database is locked instead of failing immediately
  16. cursor.execute("PRAGMA busy_timeout = 15000")
  17. cursor.execute("PRAGMA synchronous = NORMAL")
  18. cursor.close()
  19. def _create_engine():
  20. """Create the async engine with dialect-appropriate settings."""
  21. if is_sqlite():
  22. kwargs = {"pool_size": 20, "max_overflow": 200}
  23. else:
  24. kwargs = {"pool_size": 10, "max_overflow": 20}
  25. eng = create_async_engine(
  26. settings.database_url,
  27. echo=settings.debug,
  28. **kwargs,
  29. )
  30. if is_sqlite():
  31. event.listen(eng.sync_engine, "connect", _set_sqlite_pragmas)
  32. else:
  33. # Strip timezone info from aware datetimes before they reach asyncpg.
  34. # asyncpg rejects timezone-aware values for TIMESTAMP WITHOUT TIME ZONE columns.
  35. # The codebase uses datetime.now(timezone.utc) in many places — this makes
  36. # Postgres behave like SQLite which ignores timezone info entirely.
  37. @event.listens_for(eng.sync_engine, "before_cursor_execute", retval=True)
  38. def _strip_tz_from_params(conn, cursor, statement, parameters, context, executemany):
  39. import datetime
  40. if parameters is None:
  41. return statement, parameters
  42. # Recursive strip that walks any nesting of dict/list/tuple. Needed
  43. # because SQLAlchemy passes parameters in several shapes depending
  44. # on the path: a dict for named binds, a tuple for positional, a
  45. # list of dicts/tuples for executemany, and for insertmanyvalues
  46. # sometimes a list of tuples inside an outer list. The simplest
  47. # correct answer is "strip datetimes at any depth".
  48. def _strip(val):
  49. if isinstance(val, datetime.datetime) and val.tzinfo is not None:
  50. return val.replace(tzinfo=None)
  51. if isinstance(val, dict):
  52. return {k: _strip(v) for k, v in val.items()}
  53. if isinstance(val, list):
  54. return [_strip(v) for v in val]
  55. if isinstance(val, tuple):
  56. return tuple(_strip(v) for v in val)
  57. return val
  58. return statement, _strip(parameters)
  59. return eng
  60. engine = _create_engine()
  61. async_session = async_sessionmaker(
  62. engine,
  63. class_=AsyncSession,
  64. expire_on_commit=False,
  65. )
  66. async def run_with_retry(fn, *, max_attempts: int = 3, label: str = ""):
  67. """Run an async DB operation with retry for SQLite 'database is locked' errors.
  68. ``fn`` is an async callable that receives an ``AsyncSession`` and performs
  69. the full query-mutate-commit cycle. On each retry a fresh session is used
  70. so there are no stale-object / expired-attribute issues after rollback.
  71. On PostgreSQL this calls ``fn`` once with no retry (Postgres uses row-level
  72. locking and doesn't suffer from single-writer contention).
  73. """
  74. if not is_sqlite():
  75. async with async_session() as db:
  76. return await fn(db)
  77. last_exc: OperationalError | None = None
  78. for attempt in range(1, max_attempts + 1):
  79. try:
  80. async with async_session() as db:
  81. return await fn(db)
  82. except OperationalError as exc:
  83. last_exc = exc
  84. if "database is locked" not in str(exc) or attempt == max_attempts:
  85. raise
  86. delay = 0.5 * attempt # 0.5s, 1.0s
  87. logger.warning(
  88. "SQLite locked%s (attempt %d/%d), retrying in %.1fs: %s",
  89. f" ({label})" if label else "",
  90. attempt,
  91. max_attempts,
  92. delay,
  93. exc,
  94. )
  95. await asyncio.sleep(delay)
  96. raise last_exc # unreachable, but keeps type checkers happy
  97. async def close_all_connections():
  98. """Close all database connections for backup/restore operations."""
  99. global engine
  100. await engine.dispose()
  101. async def reinitialize_database():
  102. """Reinitialize database connection after restore."""
  103. global engine, async_session
  104. engine = _create_engine()
  105. async_session = async_sessionmaker(
  106. engine,
  107. class_=AsyncSession,
  108. expire_on_commit=False,
  109. )
  110. class Base(DeclarativeBase):
  111. pass
  112. async def get_db() -> AsyncSession:
  113. async with async_session() as session:
  114. try:
  115. yield session
  116. await session.commit()
  117. except BaseException:
  118. # Catch BaseException (not just Exception) so CancelledError —
  119. # raised when Starlette's BaseHTTPMiddleware cancels the inner
  120. # task scope on client disconnect — also triggers rollback.
  121. # `asyncio.shield` keeps the rollback running to completion
  122. # even when the await itself gets cancelled, so the SQLite
  123. # write lock is released promptly instead of being held until
  124. # the connection is GC'd ages later (which was producing the
  125. # "database is locked" cascade in #1112's support package).
  126. try:
  127. await asyncio.shield(session.rollback())
  128. except BaseException: # noqa: BLE001 — rollback failure must not mask the original
  129. pass
  130. raise
  131. finally:
  132. try:
  133. await asyncio.shield(session.close())
  134. except BaseException: # noqa: BLE001 — close failure must not mask the original
  135. pass
  136. async def init_db():
  137. # Import models to register them with SQLAlchemy
  138. from backend.app.models import ( # noqa: F401
  139. active_print_spoolman,
  140. ams_history,
  141. ams_label,
  142. api_key,
  143. archive,
  144. auth_ephemeral,
  145. bug_report,
  146. color_catalog,
  147. external_link,
  148. filament,
  149. filament_sku_settings,
  150. github_backup,
  151. group,
  152. kprofile_note,
  153. library,
  154. local_preset,
  155. long_lived_token,
  156. maintenance,
  157. notification,
  158. notification_template,
  159. oidc_provider,
  160. orca_base_cache,
  161. pending_upload,
  162. print_batch,
  163. print_log,
  164. print_queue,
  165. printer,
  166. project,
  167. project_bom,
  168. settings,
  169. shopping_list,
  170. slot_preset,
  171. smart_plug,
  172. smart_plug_energy_snapshot,
  173. spool,
  174. spool_assignment,
  175. spool_catalog,
  176. spool_k_profile,
  177. spool_usage_history,
  178. spoolbuddy_device,
  179. spoolman_k_profile,
  180. spoolman_slot_assignment,
  181. user,
  182. user_email_pref,
  183. user_otp_code,
  184. user_totp,
  185. virtual_printer,
  186. )
  187. async with engine.begin() as conn:
  188. await conn.run_sync(Base.metadata.create_all)
  189. # Run migrations for new columns (SQLite doesn't auto-add columns)
  190. await run_migrations(conn)
  191. # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
  192. # that exist from before the encryption key was configured.
  193. # Runs on a fresh AsyncSession (NOT the run_migrations() connection) so it
  194. # doesn't share a transaction with the schema-DDL block above — required to
  195. # avoid SQLite "database is locked" contention on the WAL writer.
  196. await _migrate_encrypt_legacy_secrets()
  197. # Seed default notification templates
  198. await seed_notification_templates()
  199. # Seed default groups and migrate existing users
  200. await seed_default_groups()
  201. # Seed default catalog entries
  202. await seed_spool_catalog()
  203. await seed_color_catalog()
  204. # B2: Module-level counter exposing the number of rows skipped during the last
  205. # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
  206. # (migration_error_count) so operators can spot poison rows that need attention.
  207. _migration_error_count: int = 0
  208. def get_migration_error_count() -> int:
  209. """Return the number of rows that failed to re-encrypt during the last
  210. _migrate_encrypt_legacy_secrets() run."""
  211. return _migration_error_count
  212. async def _migrate_encrypt_legacy_secrets() -> None:
  213. """Re-encrypt OIDC ``client_secret`` and TOTP ``secret`` rows that are still
  214. stored as plaintext (no ``fernet:`` prefix).
  215. Called from :func:`init_db` after :func:`run_migrations` finishes. No-ops
  216. when no encryption key is configured (so plaintext storage stays the
  217. legacy behaviour for installs without a key).
  218. B2: per-row strategy — each row is committed in its own AsyncSession so a
  219. single corrupt row does NOT block other successful re-encryptions on every
  220. startup forever. The skipped-row count is exposed via
  221. :func:`get_migration_error_count` and surfaced on /encryption-status.
  222. B3: unexpected (non-row) failures during the read phase are re-raised so
  223. operators see the problem instead of silent data corruption — startup
  224. fails loudly rather than running with half-migrated rows.
  225. Idempotent: rows that already start with ``fernet:`` are skipped, and the
  226. write-phase re-checks the prefix before encrypting (guards against double
  227. encryption from concurrent workers).
  228. """
  229. from sqlalchemy import not_, select
  230. from backend.app.core.encryption import is_encryption_active
  231. from backend.app.models.oidc_provider import OIDCProvider
  232. from backend.app.models.user_totp import UserTOTP
  233. global _migration_error_count
  234. if not is_encryption_active():
  235. # Reset stale counter from a previous active-key run — we no longer
  236. # have any rows to migrate, so the count must not leak across runs.
  237. _migration_error_count = 0
  238. return
  239. # Phase 1 (read): collect (id, stored_value) tuples for plaintext rows.
  240. # Read phase failures are startup-fatal — re-raise (B3).
  241. try:
  242. async with async_session() as ro:
  243. oidc_rows = await ro.execute(
  244. select(OIDCProvider.id, OIDCProvider._client_secret_enc).where(
  245. not_(OIDCProvider._client_secret_enc.like("fernet:%"))
  246. )
  247. )
  248. oidc_candidates = [(r[0], r[1]) for r in oidc_rows.all()]
  249. totp_rows = await ro.execute(
  250. select(UserTOTP.id, UserTOTP._secret_enc).where(not_(UserTOTP._secret_enc.like("fernet:%")))
  251. )
  252. totp_candidates = [(r[0], r[1]) for r in totp_rows.all()]
  253. except Exception:
  254. logger.error("_migrate_encrypt_legacy_secrets: phase 1 read failed", exc_info=True)
  255. raise # B3
  256. oidc_count = totp_count = error_count = 0
  257. # Phase 2 (write): each row in its own AsyncSession + transaction.
  258. # Failure of one row does NOT block the others.
  259. for oidc_id, stored in oidc_candidates:
  260. if not stored:
  261. continue # defensive: skip empty strings
  262. try:
  263. async with async_session() as wr:
  264. provider = await wr.get(OIDCProvider, oidc_id)
  265. if provider is None:
  266. continue # row deleted between phase 1 and phase 2
  267. # Idempotent guard: re-check inside the write session in case a
  268. # concurrent worker beat us to it.
  269. if not provider._client_secret_enc.startswith("fernet:"):
  270. provider.client_secret = stored # setter -> mfa_encrypt
  271. await wr.commit()
  272. oidc_count += 1
  273. except Exception:
  274. logger.error(
  275. "Failed to re-encrypt OIDCProvider id=%s — skipping",
  276. oidc_id,
  277. exc_info=True,
  278. )
  279. error_count += 1
  280. for totp_id, stored in totp_candidates:
  281. if not stored:
  282. continue
  283. try:
  284. async with async_session() as wr:
  285. totp = await wr.get(UserTOTP, totp_id)
  286. if totp is None:
  287. continue
  288. if not totp._secret_enc.startswith("fernet:"):
  289. totp.secret = stored
  290. await wr.commit()
  291. totp_count += 1
  292. except Exception:
  293. logger.error(
  294. "Failed to re-encrypt UserTOTP id=%s — skipping",
  295. totp_id,
  296. exc_info=True,
  297. )
  298. error_count += 1
  299. _migration_error_count = error_count
  300. if oidc_count or totp_count:
  301. logger.info(
  302. "Re-encrypted legacy plaintext secrets: %d OIDC client_secret(s), %d TOTP secret(s)",
  303. oidc_count,
  304. totp_count,
  305. )
  306. elif error_count == 0:
  307. logger.debug("_migrate_encrypt_legacy_secrets: no rows needed re-encryption")
  308. if error_count:
  309. logger.error(
  310. "_migrate_encrypt_legacy_secrets: %d row(s) skipped due to errors. "
  311. "See /api/v1/auth/encryption-status (migration_error_count).",
  312. error_count,
  313. )
  314. async def _safe_execute(conn, sql):
  315. """Execute a DDL migration statement, silently ignoring idempotency errors.
  316. 'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
  317. (SQLite RENAME COLUMN), 'duplicate key', and the compound
  318. 'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
  319. so that re-running DDL migrations is safe. The compound check additionally
  320. requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
  321. from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
  322. idempotency) are never silently swallowed.
  323. Any other error is logged and re-raised — callers must not assume silent
  324. recovery, as a failure will abort the migration sequence and prevent
  325. application startup.
  326. Only use for DDL statements (ALTER TABLE, CREATE INDEX, etc.).
  327. For DML backfills (UPDATE, DELETE) use conn.execute() directly inside
  328. async with conn.begin_nested() so failures are never silently swallowed.
  329. Uses a savepoint so that a failed statement doesn't poison the surrounding
  330. transaction (required for PostgreSQL).
  331. """
  332. from sqlalchemy import text
  333. try:
  334. async with conn.begin_nested():
  335. await conn.execute(text(sql))
  336. except (OperationalError, ProgrammingError) as exc:
  337. msg = str(exc).lower()
  338. # Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
  339. # or CREATE INDEX where it would indicate schema corruption, not idempotency.
  340. column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
  341. if (
  342. not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
  343. and not column_not_exists
  344. ):
  345. logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
  346. raise
  347. async def _migrate_normalize_printer_ids(conn) -> None:
  348. from sqlalchemy import text
  349. async with conn.begin_nested():
  350. if is_sqlite():
  351. await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'"))
  352. else:
  353. await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
  354. async def _migrate_update_auto_link_constraint(conn) -> None:
  355. """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
  356. Old formula: auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')
  357. New formula: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
  358. Only Fall B (email_claim='email' + require_ev=False) remains blocked.
  359. Fall C (custom claim, e.g. Azure preferred_username/upn) is now allowed.
  360. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute (idempotent).
  361. SQLite: table recreation when old formula is detected in sqlite_master (idempotent).
  362. """
  363. from sqlalchemy import text
  364. _NEW_FORMULA = "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE"
  365. _CONSTRAINT_NAME = "ck_auto_link_requires_verified_email_claim"
  366. if not is_sqlite():
  367. await _safe_execute(conn, f"ALTER TABLE oidc_providers DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}")
  368. await _safe_execute(
  369. conn,
  370. f"ALTER TABLE oidc_providers ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  371. )
  372. else:
  373. row = (
  374. await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
  375. ).fetchone()
  376. # Only recreate if the old (more restrictive) formula is still present.
  377. # Fresh installs created with the new __table_args__ already have the correct formula.
  378. # Installs without any constraint (pre-SEC-1 upgrades) are skipped — app-level guards suffice.
  379. if row and "require_email_verified = TRUE AND email_claim = 'email'" in row[0]:
  380. try:
  381. async with conn.begin_nested():
  382. await conn.execute(text("DROP TABLE IF EXISTS oidc_providers_v2"))
  383. await conn.execute(
  384. text(
  385. "CREATE TABLE oidc_providers_v2 ("
  386. "id INTEGER NOT NULL, "
  387. "name VARCHAR(100) NOT NULL, "
  388. "issuer_url VARCHAR(500) NOT NULL, "
  389. "client_id VARCHAR(255) NOT NULL, "
  390. "client_secret VARCHAR(512) NOT NULL, "
  391. "scopes VARCHAR(500), "
  392. "is_enabled BOOLEAN, "
  393. "auto_create_users BOOLEAN, "
  394. "auto_link_existing_accounts BOOLEAN DEFAULT 0, "
  395. "email_claim VARCHAR(64) DEFAULT 'email', "
  396. "require_email_verified BOOLEAN DEFAULT 1, "
  397. "icon_url TEXT, "
  398. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  399. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  400. "PRIMARY KEY (id), "
  401. f"UNIQUE (name), "
  402. f"CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})"
  403. ")"
  404. )
  405. )
  406. await conn.execute(
  407. text(
  408. "INSERT INTO oidc_providers_v2 "
  409. "(id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  410. "auto_create_users, auto_link_existing_accounts, email_claim, "
  411. "require_email_verified, icon_url, created_at, updated_at) "
  412. "SELECT id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  413. "auto_create_users, auto_link_existing_accounts, email_claim, "
  414. "require_email_verified, icon_url, created_at, updated_at "
  415. "FROM oidc_providers"
  416. )
  417. )
  418. original = (await conn.execute(text("SELECT count(*) FROM oidc_providers"))).scalar_one()
  419. copied = (await conn.execute(text("SELECT count(*) FROM oidc_providers_v2"))).scalar_one()
  420. if copied != original:
  421. raise RuntimeError(
  422. f"auto_link constraint migration: row count mismatch after copy "
  423. f"({original} in source, {copied} in copy)"
  424. )
  425. await conn.execute(text("DROP TABLE oidc_providers"))
  426. await conn.execute(text("ALTER TABLE oidc_providers_v2 RENAME TO oidc_providers"))
  427. except Exception as exc:
  428. logger.error(
  429. "auto_link constraint update (SQLite table recreation) FAILED: %s",
  430. exc,
  431. exc_info=True,
  432. )
  433. raise
  434. async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
  435. """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
  436. Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
  437. New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
  438. The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
  439. AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
  440. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
  441. SQLite: table recreation when the old (narrower) formula is detected in
  442. sqlite_master. Fresh installs already have the widened constraint from
  443. the CREATE TABLE migration above.
  444. """
  445. from sqlalchemy import text
  446. _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
  447. _CONSTRAINT_NAME = "ck_ams_id_range"
  448. if not is_sqlite():
  449. await _safe_execute(
  450. conn,
  451. f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
  452. )
  453. await _safe_execute(
  454. conn,
  455. f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  456. )
  457. return
  458. row = (
  459. await conn.execute(
  460. text("SELECT sql FROM sqlite_master WHERE type='table' AND name='spoolman_slot_assignments'")
  461. )
  462. ).fetchone()
  463. if not row:
  464. return
  465. sql = row[0] or ""
  466. # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
  467. if "ams_id >= 128" in sql:
  468. return
  469. # Pre-migration table without any CHECK constraint at all → leave alone;
  470. # the app-level validation handles correctness and we don't risk a
  471. # destructive table rebuild for a constraint that isn't blocking anyone.
  472. if "ck_ams_id_range" not in sql and "ams_id <= 7" not in sql:
  473. return
  474. try:
  475. async with conn.begin_nested():
  476. await conn.execute(text("DROP TABLE IF EXISTS spoolman_slot_assignments_v2"))
  477. await conn.execute(
  478. text(
  479. "CREATE TABLE spoolman_slot_assignments_v2 ("
  480. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  481. "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
  482. f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
  483. "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
  484. "spoolman_spool_id INTEGER NOT NULL, "
  485. "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  486. "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
  487. ")"
  488. )
  489. )
  490. await conn.execute(
  491. text(
  492. "INSERT INTO spoolman_slot_assignments_v2 "
  493. "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
  494. "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
  495. "FROM spoolman_slot_assignments"
  496. )
  497. )
  498. original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
  499. copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
  500. if copied != original:
  501. raise RuntimeError(
  502. f"spoolman_slot_assignments migration: row count mismatch after copy "
  503. f"({original} in source, {copied} in copy)"
  504. )
  505. await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
  506. await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
  507. # The index sits on the renamed table; recreate it idempotently
  508. # to handle older sqlite versions that don't auto-rename indexes.
  509. await conn.execute(
  510. text(
  511. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool "
  512. "ON spoolman_slot_assignments (spoolman_spool_id)"
  513. )
  514. )
  515. except Exception as exc:
  516. logger.error(
  517. "spoolman_slot_assignments ck_ams_id_range widening (SQLite table recreation) FAILED: %s",
  518. exc,
  519. exc_info=True,
  520. )
  521. raise
  522. async def run_migrations(conn):
  523. """Run all schema migrations and data backfills on startup.
  524. Includes ALTER TABLE (add columns, rename columns, add constraints),
  525. CREATE INDEX, CREATE TRIGGER, data UPDATE backfills, and table recreations
  526. for complex SQLite schema changes that ALTER TABLE cannot handle.
  527. DDL statements are wrapped in _safe_execute for idempotency.
  528. DML backfills (UPDATE/DELETE) are executed directly via conn.execute()
  529. inside begin_nested() so any failure is always fatal and never silently
  530. swallowed.
  531. """
  532. from sqlalchemy import text
  533. # Migration: Add is_favorite column to print_archives
  534. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
  535. # Migration: Add content_hash column to print_archives for duplicate detection
  536. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
  537. # Migration: Add auto_off_executed column to smart_plugs
  538. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0")
  539. # Migration: Add on_print_stopped column to notification_providers
  540. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1")
  541. # Migration: Add source_3mf_path column to print_archives
  542. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)")
  543. # Migration: Add f3d_path column to print_archives for Fusion 360 design files
  544. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
  545. # Migration: Add on_maintenance_due column to notification_providers
  546. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
  547. # Migration: Add location column to printers for grouping
  548. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN location VARCHAR(100)")
  549. # Migration: Add interval_type column to maintenance_types
  550. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'")
  551. # Migration: Add is_deleted column to maintenance_types for soft-deletes
  552. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
  553. # Migration: Add custom_interval_type column to printer_maintenance
  554. await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
  555. # Migration: Add power alert columns to smart_plugs
  556. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0")
  557. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL")
  558. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL")
  559. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME")
  560. # Migration: Add schedule columns to smart_plugs
  561. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0")
  562. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)")
  563. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)")
  564. # Migration: Add daily digest columns to notification_providers
  565. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")
  566. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
  567. # Migration: Add missing-spool-assignment print-start notification toggle
  568. try:
  569. async with conn.begin_nested():
  570. await conn.execute(
  571. text(
  572. "ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0"
  573. )
  574. )
  575. except (OperationalError, ProgrammingError):
  576. pass # Already applied
  577. # Migration: Add project_id column to print_archives
  578. try:
  579. async with conn.begin_nested():
  580. await conn.execute(
  581. text(
  582. "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  583. )
  584. )
  585. except (OperationalError, ProgrammingError):
  586. pass # Already applied
  587. # Migration: Add project_id column to print_queue
  588. try:
  589. async with conn.begin_nested():
  590. await conn.execute(
  591. text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  592. )
  593. except (OperationalError, ProgrammingError):
  594. pass # Already applied
  595. # Migration: Enforce uniqueness on user_oidc_links for existing rows.
  596. # create_all() is idempotent and does not add constraints to existing tables,
  597. # so we create covering unique indexes explicitly here.
  598. await _safe_execute(
  599. conn,
  600. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub"
  601. " ON user_oidc_links (provider_id, provider_user_id)",
  602. )
  603. await _safe_execute(
  604. conn,
  605. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
  606. )
  607. # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
  608. # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
  609. if is_sqlite():
  610. try:
  611. await conn.execute(
  612. text("""
  613. CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
  614. print_name,
  615. filename,
  616. tags,
  617. notes,
  618. designer,
  619. filament_type,
  620. content='print_archives',
  621. content_rowid='id'
  622. )
  623. """)
  624. )
  625. except (OperationalError, ProgrammingError):
  626. pass # Already applied
  627. # Migration: Create triggers to keep FTS index in sync
  628. try:
  629. await conn.execute(
  630. text("""
  631. CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
  632. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  633. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  634. END
  635. """)
  636. )
  637. except (OperationalError, ProgrammingError):
  638. pass # Already applied
  639. try:
  640. await conn.execute(
  641. text("""
  642. CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
  643. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  644. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  645. END
  646. """)
  647. )
  648. except (OperationalError, ProgrammingError):
  649. pass # Already applied
  650. try:
  651. await conn.execute(
  652. text("""
  653. CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
  654. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  655. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  656. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  657. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  658. END
  659. """)
  660. )
  661. except (OperationalError, ProgrammingError):
  662. pass # Already applied
  663. # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
  664. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0")
  665. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME")
  666. # Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)
  667. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0")
  668. # Migration: Add AMS alarm notification columns to notification_providers
  669. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0")
  670. try:
  671. async with conn.begin_nested():
  672. await conn.execute(
  673. text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
  674. )
  675. except (OperationalError, ProgrammingError):
  676. pass # Already applied
  677. # Migration: Add AMS-HT alarm notification columns to notification_providers
  678. try:
  679. async with conn.begin_nested():
  680. await conn.execute(
  681. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
  682. )
  683. except (OperationalError, ProgrammingError):
  684. pass # Already applied
  685. try:
  686. async with conn.begin_nested():
  687. await conn.execute(
  688. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
  689. )
  690. except (OperationalError, ProgrammingError):
  691. pass # Already applied
  692. # Migration: Add plate not empty notification column to notification_providers
  693. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1")
  694. # Migration: Add notes column to projects (Phase 2)
  695. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN notes TEXT")
  696. # Migration: Add attachments column to projects (Phase 3)
  697. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN attachments JSON")
  698. # Migration: Add tags column to projects (Phase 4)
  699. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN tags TEXT")
  700. # Migration: Add due_date column to projects (Phase 5)
  701. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN due_date DATETIME")
  702. # Migration: Add priority column to projects (Phase 5)
  703. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'")
  704. # Migration: Add budget column to projects (Phase 6)
  705. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN budget REAL")
  706. # Migration: Add is_template column to projects (Phase 8)
  707. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0")
  708. # Migration: Add template_source_id column to projects (Phase 8)
  709. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN template_source_id INTEGER")
  710. # Migration: Add parent_id column to projects (Phase 10)
  711. try:
  712. async with conn.begin_nested():
  713. await conn.execute(
  714. text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  715. )
  716. except (OperationalError, ProgrammingError):
  717. pass # Already applied
  718. # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
  719. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired")
  720. # Migration: Add unit_price column to project_bom_items
  721. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN unit_price REAL")
  722. # Migration: Add sourcing_url column to project_bom_items
  723. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)")
  724. # Migration: Rename notes to remarks in project_bom_items
  725. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks")
  726. # Migration: Add show_in_switchbar column to smart_plugs
  727. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0")
  728. # Migration: Add runtime tracking columns to printers
  729. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0")
  730. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME")
  731. # Migration: Add quantity column to print_archives for tracking item count
  732. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1")
  733. # Migration: Add manual_start column to print_queue for staged prints
  734. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
  735. # Migration: Add wiki_url column to maintenance_types for documentation links
  736. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
  737. # Migration: Add tailscale_disabled column to virtual_printers. Opt-in: default TRUE so
  738. # the auto-detect + fallback noise only runs for users who explicitly enable it.
  739. # Postgres rejects `DEFAULT 1` for BOOLEAN (#1070 round-2 review).
  740. if is_sqlite():
  741. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 1")
  742. else:
  743. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT true")
  744. # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
  745. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
  746. # Migration: Add queue_force_color_match column to virtual_printers (#1188).
  747. # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
  748. # from the 3MF onto the queue item's filament_overrides so the scheduler
  749. # refuses to dispatch onto a printer with the wrong filament loaded.
  750. # Default false to preserve current behaviour for upgraders.
  751. if is_sqlite():
  752. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT 0")
  753. else:
  754. await _safe_execute(
  755. conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
  756. )
  757. # Migration: Add target_parts_count column to projects for tracking total parts needed
  758. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
  759. # Migration: Add url + cover_image_filename columns to projects (#1155).
  760. # url: external link rendered next to the project name on the card.
  761. # cover_image_filename: filename of the project's hero image inside the
  762. # existing attachments dir; rendered as a thumbnail on the card.
  763. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
  764. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
  765. # Migration: enhanced filament colour handling on color_catalog (#1154).
  766. # Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
  767. # so catalog entries can store an alpha component (#RRGGBBAA). SQLite
  768. # ignores VARCHAR length, so the widen only matters on PostgreSQL.
  769. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
  770. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
  771. if not is_sqlite():
  772. await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
  773. # Migration: Make printer_id nullable in print_queue for unassigned queue items
  774. # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
  775. # PostgreSQL gets the correct schema from create_all(), so skip this
  776. if is_sqlite():
  777. try:
  778. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  779. row = result.fetchone()
  780. if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
  781. await conn.execute(
  782. text("""
  783. CREATE TABLE print_queue_new (
  784. id INTEGER PRIMARY KEY,
  785. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  786. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  787. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  788. position INTEGER DEFAULT 0,
  789. scheduled_time DATETIME,
  790. manual_start BOOLEAN DEFAULT 0,
  791. require_previous_success BOOLEAN DEFAULT 0,
  792. auto_off_after BOOLEAN DEFAULT 0,
  793. ams_mapping TEXT,
  794. status VARCHAR(20) DEFAULT 'pending',
  795. started_at DATETIME,
  796. completed_at DATETIME,
  797. error_message TEXT,
  798. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  799. )
  800. """)
  801. )
  802. await conn.execute(
  803. text("""
  804. INSERT INTO print_queue_new
  805. SELECT id, printer_id, archive_id, project_id, position, scheduled_time,
  806. manual_start, require_previous_success, auto_off_after, ams_mapping,
  807. status, started_at, completed_at, error_message, created_at
  808. FROM print_queue
  809. """)
  810. )
  811. await conn.execute(text("DROP TABLE print_queue"))
  812. await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
  813. except (OperationalError, ProgrammingError):
  814. pass # Already applied
  815. # Migration: Add plug_type column to smart_plugs for HA integration
  816. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'")
  817. # Migration: Add ha_entity_id column to smart_plugs for HA integration
  818. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)")
  819. # Migration: Add project_id column to library_folders for linking folders to projects
  820. try:
  821. async with conn.begin_nested():
  822. await conn.execute(
  823. text(
  824. "ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  825. )
  826. )
  827. except (OperationalError, ProgrammingError):
  828. pass # Already applied
  829. # Migration: Add archive_id column to library_folders for linking folders to archives
  830. try:
  831. async with conn.begin_nested():
  832. await conn.execute(
  833. text(
  834. "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
  835. )
  836. )
  837. except (OperationalError, ProgrammingError):
  838. pass # Already applied
  839. # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
  840. # PostgreSQL gets the correct schema from create_all(), so skip this
  841. if is_sqlite():
  842. try:
  843. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  844. row = result.fetchone()
  845. if row and "ip_address VARCHAR(45) NOT NULL" in (row[0] or ""):
  846. await conn.execute(
  847. text("""
  848. CREATE TABLE smart_plugs_new (
  849. id INTEGER PRIMARY KEY,
  850. name VARCHAR(100) NOT NULL,
  851. ip_address VARCHAR(45),
  852. plug_type VARCHAR(20) DEFAULT 'tasmota',
  853. ha_entity_id VARCHAR(100),
  854. printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,
  855. enabled BOOLEAN NOT NULL DEFAULT 1,
  856. auto_on BOOLEAN NOT NULL DEFAULT 1,
  857. auto_off BOOLEAN NOT NULL DEFAULT 1,
  858. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  859. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  860. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  861. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  862. username VARCHAR(50),
  863. password VARCHAR(100),
  864. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  865. power_alert_high FLOAT,
  866. power_alert_low FLOAT,
  867. power_alert_last_triggered DATETIME,
  868. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  869. schedule_on_time VARCHAR(5),
  870. schedule_off_time VARCHAR(5),
  871. show_in_switchbar BOOLEAN DEFAULT 0,
  872. last_state VARCHAR(10),
  873. last_checked DATETIME,
  874. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  875. auto_off_pending BOOLEAN DEFAULT 0,
  876. auto_off_pending_since DATETIME,
  877. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  878. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  879. )
  880. """)
  881. )
  882. await conn.execute(
  883. text("""
  884. INSERT INTO smart_plugs_new
  885. SELECT id, name, ip_address,
  886. COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
  887. enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  888. off_delay_mode, off_delay_minutes, off_temp_threshold,
  889. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  890. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  891. COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
  892. COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at
  893. FROM smart_plugs
  894. """)
  895. )
  896. await conn.execute(text("DROP TABLE smart_plugs"))
  897. await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
  898. except (OperationalError, ProgrammingError):
  899. pass # Already applied
  900. # Migration: Add plate_id column to print_queue for multi-plate 3MF support
  901. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN plate_id INTEGER")
  902. # Migration: Add print options columns to print_queue
  903. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1")
  904. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0")
  905. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1")
  906. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")
  907. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")
  908. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")
  909. # Migration: Add library_file_id column to print_queue and make archive_id nullable
  910. # This allows queue items to reference library files directly (archive created at print start)
  911. try:
  912. async with conn.begin_nested():
  913. await conn.execute(
  914. text(
  915. "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
  916. )
  917. )
  918. except (OperationalError, ProgrammingError):
  919. pass # Already applied
  920. # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
  921. # PostgreSQL gets the correct schema from create_all(), so skip this
  922. if is_sqlite():
  923. try:
  924. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  925. row = result.fetchone()
  926. if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
  927. await conn.execute(
  928. text("""
  929. CREATE TABLE print_queue_new2 (
  930. id INTEGER PRIMARY KEY,
  931. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  932. archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
  933. library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
  934. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  935. position INTEGER DEFAULT 0,
  936. scheduled_time DATETIME,
  937. manual_start BOOLEAN DEFAULT 0,
  938. require_previous_success BOOLEAN DEFAULT 0,
  939. auto_off_after BOOLEAN DEFAULT 0,
  940. ams_mapping TEXT,
  941. plate_id INTEGER,
  942. bed_levelling BOOLEAN DEFAULT 1,
  943. flow_cali BOOLEAN DEFAULT 0,
  944. vibration_cali BOOLEAN DEFAULT 1,
  945. layer_inspect BOOLEAN DEFAULT 0,
  946. timelapse BOOLEAN DEFAULT 0,
  947. use_ams BOOLEAN DEFAULT 1,
  948. status VARCHAR(20) DEFAULT 'pending',
  949. started_at DATETIME,
  950. completed_at DATETIME,
  951. error_message TEXT,
  952. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  953. )
  954. """)
  955. )
  956. await conn.execute(
  957. text("""
  958. INSERT INTO print_queue_new2
  959. SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
  960. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  961. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  962. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  963. status, started_at, completed_at, error_message, created_at
  964. FROM print_queue
  965. """)
  966. )
  967. await conn.execute(text("DROP TABLE print_queue"))
  968. await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
  969. except (OperationalError, ProgrammingError):
  970. pass # Already applied
  971. # Migration: Add HA energy sensor entity columns to smart_plugs
  972. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
  973. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
  974. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)")
  975. # Migration: Create users table for authentication
  976. try:
  977. async with conn.begin_nested():
  978. await conn.execute(
  979. text("""
  980. CREATE TABLE IF NOT EXISTS users (
  981. id INTEGER PRIMARY KEY,
  982. username VARCHAR(100) NOT NULL UNIQUE,
  983. password_hash VARCHAR(255) NOT NULL,
  984. role VARCHAR(20) NOT NULL DEFAULT 'user',
  985. is_active BOOLEAN NOT NULL DEFAULT 1,
  986. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  987. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  988. )
  989. """)
  990. )
  991. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
  992. except (OperationalError, ProgrammingError):
  993. pass # Already applied
  994. # Migration: Add external camera columns to printers
  995. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
  996. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
  997. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
  998. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_snapshot_url VARCHAR(500)")
  999. # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
  1000. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")
  1001. # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
  1002. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")
  1003. # Migration: Add is_external column to library_files for external cloud files
  1004. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0")
  1005. # Migration: Add project_id column to library_files
  1006. try:
  1007. async with conn.begin_nested():
  1008. await conn.execute(
  1009. text(
  1010. "ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  1011. )
  1012. )
  1013. except (OperationalError, ProgrammingError):
  1014. pass # Already applied
  1015. # Migration: Add is_external column to library_folders for external cloud folders
  1016. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0")
  1017. # Migration: Add external folder settings columns to library_folders
  1018. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0")
  1019. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0")
  1020. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)")
  1021. # Migration: Add plate_detection_enabled column to printers
  1022. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0")
  1023. # Migration: Add plate detection ROI columns to printers
  1024. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL")
  1025. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL")
  1026. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL")
  1027. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL")
  1028. # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
  1029. # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
  1030. # SQLite requires table recreation to drop constraints
  1031. # PostgreSQL gets the correct schema from create_all(), so skip this
  1032. if is_sqlite():
  1033. try:
  1034. needs_migration = False
  1035. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  1036. row = result.fetchone()
  1037. table_sql = (row[0] or "").upper() if row else ""
  1038. if "PRINTER_ID" in table_sql and "UNIQUE" in table_sql:
  1039. import re
  1040. if re.search(r'"?PRINTER_ID"?\s+\w+\s+UNIQUE', table_sql) or re.search(
  1041. r'UNIQUE\s*\([^)]*"?PRINTER_ID"?', table_sql
  1042. ):
  1043. needs_migration = True
  1044. idx_result = await conn.execute(
  1045. text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL")
  1046. )
  1047. for idx_row in idx_result.fetchall():
  1048. idx_sql = (idx_row[0] or "").upper()
  1049. if "UNIQUE" in idx_sql and "PRINTER_ID" in idx_sql:
  1050. needs_migration = True
  1051. break
  1052. if needs_migration:
  1053. # Create new table without UNIQUE constraint on printer_id
  1054. await conn.execute(
  1055. text("""
  1056. CREATE TABLE smart_plugs_temp (
  1057. id INTEGER PRIMARY KEY,
  1058. name VARCHAR(100) NOT NULL,
  1059. ip_address VARCHAR(45),
  1060. plug_type VARCHAR(20) DEFAULT 'tasmota',
  1061. ha_entity_id VARCHAR(100),
  1062. ha_power_entity VARCHAR(100),
  1063. ha_energy_today_entity VARCHAR(100),
  1064. ha_energy_total_entity VARCHAR(100),
  1065. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  1066. enabled BOOLEAN NOT NULL DEFAULT 1,
  1067. auto_on BOOLEAN NOT NULL DEFAULT 1,
  1068. auto_off BOOLEAN NOT NULL DEFAULT 1,
  1069. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  1070. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  1071. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  1072. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  1073. username VARCHAR(50),
  1074. password VARCHAR(100),
  1075. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  1076. power_alert_high FLOAT,
  1077. power_alert_low FLOAT,
  1078. power_alert_last_triggered DATETIME,
  1079. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  1080. schedule_on_time VARCHAR(5),
  1081. schedule_off_time VARCHAR(5),
  1082. show_in_switchbar BOOLEAN DEFAULT 0,
  1083. last_state VARCHAR(10),
  1084. last_checked DATETIME,
  1085. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  1086. auto_off_pending BOOLEAN DEFAULT 0,
  1087. auto_off_pending_since DATETIME,
  1088. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  1089. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  1090. )
  1091. """)
  1092. )
  1093. # Copy data
  1094. await conn.execute(
  1095. text("""
  1096. INSERT INTO smart_plugs_temp
  1097. SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
  1098. ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
  1099. auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  1100. off_delay_mode, off_delay_minutes, off_temp_threshold,
  1101. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  1102. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  1103. show_in_switchbar, last_state, last_checked, auto_off_executed,
  1104. auto_off_pending, auto_off_pending_since, created_at, updated_at
  1105. FROM smart_plugs
  1106. """)
  1107. )
  1108. # Drop old table and rename new one
  1109. await conn.execute(text("DROP TABLE smart_plugs"))
  1110. await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
  1111. except (OperationalError, ProgrammingError):
  1112. pass # Already applied
  1113. # Migration: Add show_on_printer_card column to smart_plugs
  1114. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")
  1115. # Migration: Add MQTT smart plug fields (legacy)
  1116. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")
  1117. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")
  1118. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")
  1119. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")
  1120. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")
  1121. # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
  1122. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")
  1123. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")
  1124. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")
  1125. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")
  1126. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")
  1127. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")
  1128. # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
  1129. try:
  1130. async with conn.begin_nested():
  1131. await conn.execute(
  1132. text("""
  1133. UPDATE smart_plugs
  1134. SET mqtt_power_topic = mqtt_topic,
  1135. mqtt_power_multiplier = mqtt_multiplier
  1136. WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
  1137. """)
  1138. )
  1139. except (OperationalError, ProgrammingError):
  1140. pass # Already applied
  1141. # Migration: Create groups table for permission-based access control
  1142. try:
  1143. async with conn.begin_nested():
  1144. await conn.execute(
  1145. text("""
  1146. CREATE TABLE IF NOT EXISTS groups (
  1147. id INTEGER PRIMARY KEY,
  1148. name VARCHAR(100) NOT NULL UNIQUE,
  1149. description VARCHAR(500),
  1150. permissions JSON,
  1151. is_system BOOLEAN NOT NULL DEFAULT 0,
  1152. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1153. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1154. )
  1155. """)
  1156. )
  1157. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
  1158. except (OperationalError, ProgrammingError):
  1159. pass # Already applied
  1160. # Migration: Create user_groups association table
  1161. try:
  1162. async with conn.begin_nested():
  1163. await conn.execute(
  1164. text("""
  1165. CREATE TABLE IF NOT EXISTS user_groups (
  1166. user_id INTEGER NOT NULL,
  1167. group_id INTEGER NOT NULL,
  1168. PRIMARY KEY (user_id, group_id),
  1169. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  1170. FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
  1171. )
  1172. """)
  1173. )
  1174. except (OperationalError, ProgrammingError):
  1175. pass # Already applied
  1176. # Migration: Add model-based queue assignment columns to print_queue
  1177. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")
  1178. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")
  1179. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")
  1180. # Migration: Add nozzle_count column to printers (for dual-extruder detection)
  1181. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1")
  1182. # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
  1183. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0")
  1184. # Migration: Add queue notification event columns to notification_providers
  1185. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0")
  1186. try:
  1187. async with conn.begin_nested():
  1188. await conn.execute(
  1189. text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
  1190. )
  1191. except (OperationalError, ProgrammingError):
  1192. pass # Already applied
  1193. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0")
  1194. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1")
  1195. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1")
  1196. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1")
  1197. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0")
  1198. # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
  1199. try:
  1200. async with conn.begin_nested():
  1201. await conn.execute(
  1202. text(
  1203. "ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  1204. )
  1205. )
  1206. except (OperationalError, ProgrammingError):
  1207. pass # Already applied
  1208. # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
  1209. try:
  1210. async with conn.begin_nested():
  1211. await conn.execute(
  1212. text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  1213. )
  1214. except (OperationalError, ProgrammingError):
  1215. pass # Already applied
  1216. # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
  1217. try:
  1218. async with conn.begin_nested():
  1219. await conn.execute(
  1220. text(
  1221. "ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  1222. )
  1223. )
  1224. except (OperationalError, ProgrammingError):
  1225. pass # Already applied
  1226. # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
  1227. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")
  1228. # Migration: Convert absolute paths to relative paths in library_files table
  1229. # This ensures backup/restore portability across different installations
  1230. try:
  1231. async with conn.begin_nested():
  1232. base_dir_str = str(settings.base_dir)
  1233. # Ensure we have a trailing slash for clean replacement
  1234. if not base_dir_str.endswith("/"):
  1235. base_dir_str += "/"
  1236. # Update file_path - remove base_dir prefix from absolute paths
  1237. await conn.execute(
  1238. text("""
  1239. UPDATE library_files
  1240. SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
  1241. WHERE file_path LIKE :pattern
  1242. """),
  1243. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  1244. )
  1245. # Update thumbnail_path - remove base_dir prefix from absolute paths
  1246. await conn.execute(
  1247. text("""
  1248. UPDATE library_files
  1249. SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
  1250. WHERE thumbnail_path LIKE :pattern
  1251. """),
  1252. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  1253. )
  1254. except (OperationalError, ProgrammingError):
  1255. pass # Already applied
  1256. # Create active_print_spoolman table for Spoolman per-filament tracking
  1257. await _safe_execute(
  1258. conn,
  1259. """
  1260. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  1261. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1262. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1263. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  1264. filament_usage TEXT NOT NULL,
  1265. ams_trays TEXT NOT NULL,
  1266. slot_to_tray TEXT,
  1267. layer_usage TEXT,
  1268. filament_properties TEXT,
  1269. UNIQUE(printer_id, archive_id)
  1270. )
  1271. """
  1272. if is_sqlite()
  1273. else """
  1274. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  1275. id SERIAL PRIMARY KEY,
  1276. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1277. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  1278. filament_usage TEXT NOT NULL,
  1279. ams_trays TEXT NOT NULL,
  1280. slot_to_tray TEXT,
  1281. layer_usage TEXT,
  1282. filament_properties TEXT,
  1283. UNIQUE(printer_id, archive_id)
  1284. )
  1285. """,
  1286. )
  1287. # Migration: Add preset_source column to slot_preset_mappings for local preset support
  1288. try:
  1289. async with conn.begin_nested():
  1290. await conn.execute(
  1291. text("ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'")
  1292. )
  1293. except (OperationalError, ProgrammingError):
  1294. pass # Already applied
  1295. # Migration: Add email column to users for Advanced Auth (PR #322)
  1296. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN email VARCHAR(255)")
  1297. # Migration: Add inventory spool tracking columns
  1298. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN added_full BOOLEAN")
  1299. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_used DATETIME")
  1300. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN encode_time DATETIME")
  1301. # Migration: Add RFID tag matching columns to spool
  1302. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)")
  1303. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)")
  1304. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)")
  1305. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)")
  1306. # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
  1307. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER")
  1308. # Migration: Create spool_usage_history table for filament consumption tracking
  1309. await _safe_execute(
  1310. conn,
  1311. """
  1312. CREATE TABLE IF NOT EXISTS spool_usage_history (
  1313. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1314. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  1315. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  1316. print_name VARCHAR(500),
  1317. weight_used REAL NOT NULL DEFAULT 0,
  1318. percent_used INTEGER NOT NULL DEFAULT 0,
  1319. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  1320. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1321. )
  1322. """
  1323. if is_sqlite()
  1324. else """
  1325. CREATE TABLE IF NOT EXISTS spool_usage_history (
  1326. id SERIAL PRIMARY KEY,
  1327. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  1328. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  1329. print_name VARCHAR(500),
  1330. weight_used REAL NOT NULL DEFAULT 0,
  1331. percent_used INTEGER NOT NULL DEFAULT 0,
  1332. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  1333. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  1334. )
  1335. """,
  1336. )
  1337. # Migration: Add open_in_new_tab column to external_links
  1338. await _safe_execute(conn, "ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0")
  1339. # Migration: Add bed cooled notification column to notification_providers
  1340. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0")
  1341. # Migration: Add first layer complete notification column to notification_providers
  1342. try:
  1343. async with conn.begin_nested():
  1344. await conn.execute(
  1345. text("ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0")
  1346. )
  1347. except (OperationalError, ProgrammingError):
  1348. pass # Already applied
  1349. # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
  1350. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0")
  1351. # Migration: Add SpoolBuddy scale weight tracking columns to spool table
  1352. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER")
  1353. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME")
  1354. # Migration: Add cost tracking fields to spool table
  1355. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
  1356. # Migration: Per-spool category + low-stock threshold override (#729). Both
  1357. # nullable — NULL category leaves the spool uncategorised, NULL threshold
  1358. # falls back to the global low_stock_threshold setting.
  1359. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
  1360. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
  1361. # Migration: Add user-editable storage location to spool table
  1362. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
  1363. # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
  1364. # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
  1365. # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.
  1366. if not is_sqlite():
  1367. await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
  1368. # Migration: enhanced filament colour handling (#1154). `extra_colors` is
  1369. # a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
  1370. # colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
  1371. # matte} as a visual rendering hint. Both nullable — NULL keeps the
  1372. # current single-rgba/no-effect behaviour.
  1373. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
  1374. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
  1375. # Migration: Add cost field to spool_usage_history table
  1376. await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
  1377. # Migration: Add archive_id field to spool_usage_history table
  1378. try:
  1379. async with conn.begin_nested():
  1380. await conn.execute(
  1381. text("ALTER TABLE spool_usage_history ADD COLUMN archive_id INTEGER REFERENCES print_archives(id)")
  1382. )
  1383. except (OperationalError, ProgrammingError):
  1384. pass # Already applied
  1385. # Migration: Migrate single virtual printer key-value settings to virtual_printers table
  1386. try:
  1387. async with conn.begin_nested():
  1388. result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
  1389. count = result.scalar() or 0
  1390. if count == 0:
  1391. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
  1392. row = result.fetchone()
  1393. if row:
  1394. # Old settings exist — migrate to first virtual printer row
  1395. old_enabled = row[0] == "true" if row[0] else False
  1396. result = await conn.execute(
  1397. text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
  1398. )
  1399. row = result.fetchone()
  1400. old_access_code = row[0] if row else None
  1401. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
  1402. row = result.fetchone()
  1403. old_mode = row[0] if row else "immediate"
  1404. if old_mode == "queue":
  1405. old_mode = "review"
  1406. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
  1407. row = result.fetchone()
  1408. old_model = row[0] if row else "BL-P001"
  1409. result = await conn.execute(
  1410. text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
  1411. )
  1412. row = result.fetchone()
  1413. old_target_id = int(row[0]) if row and row[0] else None
  1414. result = await conn.execute(
  1415. text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
  1416. )
  1417. row = result.fetchone()
  1418. old_remote_iface = row[0] if row else None
  1419. await conn.execute(
  1420. text("""
  1421. INSERT INTO virtual_printers
  1422. (name, enabled, mode, model, access_code, target_printer_id,
  1423. bind_ip, remote_interface_ip, serial_suffix, position)
  1424. VALUES
  1425. (:name, :enabled, :mode, :model, :access_code, :target_id,
  1426. NULL, :remote_iface, '391800001', 0)
  1427. """),
  1428. {
  1429. "name": "Bambuddy",
  1430. "enabled": old_enabled,
  1431. "mode": old_mode or "immediate",
  1432. "model": old_model,
  1433. "access_code": old_access_code,
  1434. "target_id": old_target_id,
  1435. "remote_iface": old_remote_iface,
  1436. },
  1437. )
  1438. except (OperationalError, ProgrammingError, IntegrityError):
  1439. pass # Table may not exist yet on first run, or columns have different constraints
  1440. # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment
  1441. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT")
  1442. # Migration: Add NFC reader and display control columns to spoolbuddy_devices
  1443. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)")
  1444. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)")
  1445. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100")
  1446. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0")
  1447. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0")
  1448. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME")
  1449. # Migration: Add NFC tag write payload column to spoolbuddy_devices
  1450. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT")
  1451. # Migration: Add OTA update tracking columns to spoolbuddy_devices
  1452. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_status VARCHAR(20)")
  1453. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_message VARCHAR(255)")
  1454. # Migration: Persist SpoolBuddy backend URL and queued system payload
  1455. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)")
  1456. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT")
  1457. # Migration: Add system_stats JSON blob column to spoolbuddy_devices
  1458. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
  1459. # Migration: Add SSH host key for TOFU verification (H1 security fix)
  1460. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)")
  1461. # Migration: Widen ssh_host_key from VARCHAR(500) to TEXT — RSA-3072+ host keys
  1462. # in OpenSSH format exceed 500 chars (RSA-4096 ~720 chars). PostgreSQL enforces
  1463. # the limit and rejects the UPDATE; SQLite ignores VARCHAR length so no-op there.
  1464. if not is_sqlite():
  1465. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT")
  1466. # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
  1467. # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
  1468. # PostgreSQL gets the correct schema from create_all(), so skip this
  1469. if is_sqlite():
  1470. try:
  1471. await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new"))
  1472. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'"))
  1473. row = result.fetchone()
  1474. if row and "printer_id" in (row[0] or ""):
  1475. # Old schema: rebuild the table with ams_serial_number as the unique key.
  1476. # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved.
  1477. await conn.execute(
  1478. text("""
  1479. CREATE TABLE ams_labels_new (
  1480. id INTEGER PRIMARY KEY,
  1481. ams_serial_number VARCHAR(50) NOT NULL,
  1482. ams_id INTEGER,
  1483. label VARCHAR(100) NOT NULL,
  1484. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  1485. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  1486. CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)
  1487. )
  1488. """)
  1489. )
  1490. await conn.execute(
  1491. text("""
  1492. INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)
  1493. SELECT id,
  1494. 'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),
  1495. ams_id,
  1496. label,
  1497. created_at,
  1498. updated_at
  1499. FROM ams_labels
  1500. """)
  1501. )
  1502. await conn.execute(text("DROP TABLE ams_labels"))
  1503. await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels"))
  1504. except (OperationalError, ProgrammingError):
  1505. pass # Already migrated or table does not exist yet
  1506. # Migration: Add auto_dispatch column to virtual_printers
  1507. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN auto_dispatch BOOLEAN DEFAULT 1")
  1508. # Migration: Fix VP model codes — convert legacy SSDP codes and display names to correct SSDP codes
  1509. # Legacy codes (from multi-VP refactor) and display names (from proxy auto-inherit)
  1510. vp_model_fixes = {
  1511. "3DPrinter-X1-Carbon": "BL-P001",
  1512. "3DPrinter-X1": "BL-P002",
  1513. "X1C": "BL-P001",
  1514. "X1": "BL-P002",
  1515. "X1E": "C13",
  1516. "X2D": "N6",
  1517. "P1P": "C11",
  1518. "P1S": "C12",
  1519. "P2S": "N7",
  1520. "A1": "N2S",
  1521. "A1 Mini": "N1",
  1522. "H2D": "O1D",
  1523. "H2C": "O1C",
  1524. "H2S": "O1S",
  1525. }
  1526. for old_val, new_val in vp_model_fixes.items():
  1527. await conn.execute(
  1528. text("UPDATE virtual_printers SET model = :new WHERE model = :old"),
  1529. {"old": old_val, "new": new_val},
  1530. )
  1531. await conn.execute(
  1532. text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_model' AND value = :old"),
  1533. {"old": old_val, "new": new_val},
  1534. )
  1535. # Migration: Add per-user Bambu Cloud credential columns
  1536. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
  1537. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
  1538. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_region VARCHAR(10)")
  1539. # Cleanup: Remove obsolete settings keys that are no longer used
  1540. obsolete_keys = ["slicer_binary_path"]
  1541. for key in obsolete_keys:
  1542. await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
  1543. # Migration: Create user_email_preferences table for user-specific email notification settings
  1544. try:
  1545. async with conn.begin_nested():
  1546. await conn.execute(
  1547. text("""
  1548. CREATE TABLE IF NOT EXISTS user_email_preferences (
  1549. id INTEGER PRIMARY KEY,
  1550. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  1551. notify_print_start BOOLEAN NOT NULL DEFAULT 1,
  1552. notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
  1553. notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
  1554. notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
  1555. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1556. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1557. )
  1558. """)
  1559. )
  1560. await conn.execute(
  1561. text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
  1562. )
  1563. except (OperationalError, ProgrammingError):
  1564. pass # Already applied
  1565. # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
  1566. try:
  1567. async with conn.begin_nested():
  1568. await conn.execute(
  1569. text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
  1570. )
  1571. except (OperationalError, ProgrammingError):
  1572. pass # Column already exists or table created with full schema
  1573. # Migration: Add camera_rotation column to printers
  1574. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0")
  1575. # Migration: Add awaiting_plate_clear column to printers (#961)
  1576. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN awaiting_plate_clear BOOLEAN DEFAULT FALSE NOT NULL")
  1577. # Migration: Add REST/Webhook smart plug fields
  1578. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)")
  1579. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT")
  1580. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)")
  1581. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT")
  1582. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)")
  1583. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT")
  1584. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)")
  1585. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)")
  1586. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)")
  1587. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)")
  1588. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)")
  1589. # Migration: Add separate REST power/energy URLs and multipliers
  1590. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_url VARCHAR(500)")
  1591. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_multiplier REAL DEFAULT 1.0")
  1592. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
  1593. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
  1594. # Migration: Add batch_id column to print_queue for batch grouping
  1595. try:
  1596. async with conn.begin_nested():
  1597. await conn.execute(
  1598. text(
  1599. "ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL"
  1600. )
  1601. )
  1602. except (OperationalError, ProgrammingError):
  1603. pass
  1604. # Migration: Shortest-job-first scheduling columns on print_queue
  1605. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
  1606. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
  1607. # Migration: Auto-print G-code injection (#422)
  1608. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
  1609. # Migration: Add backup_spools and backup_archives columns to github_backup_config
  1610. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
  1611. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
  1612. # Migration: Widen columns where SQLite allowed data beyond the declared VARCHAR limit
  1613. if not is_sqlite():
  1614. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_hash TYPE VARCHAR(255)")
  1615. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_prefix TYPE VARCHAR(20)")
  1616. await _safe_execute(conn, "ALTER TABLE print_archives ALTER COLUMN filament_color TYPE VARCHAR(200)")
  1617. # Migration: Create GIN index for full-text search on PostgreSQL
  1618. # (SQLite uses FTS5 virtual table instead, set up above)
  1619. if not is_sqlite():
  1620. try:
  1621. await conn.execute(
  1622. text("""
  1623. CREATE INDEX IF NOT EXISTS idx_archives_fulltext
  1624. ON print_archives
  1625. USING GIN (to_tsvector('simple',
  1626. COALESCE(print_name, '') || ' ' ||
  1627. COALESCE(filename, '') || ' ' ||
  1628. COALESCE(tags, '') || ' ' ||
  1629. COALESCE(notes, '') || ' ' ||
  1630. COALESCE(designer, '') || ' ' ||
  1631. COALESCE(filament_type, '')
  1632. ))
  1633. """)
  1634. )
  1635. except (OperationalError, ProgrammingError):
  1636. pass # Already applied
  1637. # Migration: Normalize empty printer_ids [] to NULL (global access) on API keys
  1638. # Previously both None and [] meant "all printers"; now [] means "no printers"
  1639. # PostgreSQL stores printer_ids as JSONB; comparing JSONB to a string literal fails
  1640. # with "operator does not exist: jsonb = unknown" — cast the literal to jsonb explicitly.
  1641. await _migrate_normalize_printer_ids(conn)
  1642. # Migration: Add auth_source column to users for LDAP support (#794)
  1643. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
  1644. # Migration: Make password_hash nullable for LDAP users (#794)
  1645. # LDAP users have no local password — the column must allow NULL so auto-provisioning
  1646. # doesn't hit a NOT NULL constraint failure on upgraded installs whose users table was
  1647. # originally created before LDAP support landed.
  1648. if is_sqlite():
  1649. # SQLite can't ALTER COLUMN; patch sqlite_master directly via writable_schema.
  1650. # Bump schema_version afterwards so SQLite reloads the table definition from disk —
  1651. # without that bump, the current connection keeps enforcing the old NOT NULL from
  1652. # its cached schema. Safe because row data is untouched and the replace() is a
  1653. # no-op if the constraint has already been removed.
  1654. try:
  1655. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'"))
  1656. users_sql = result.scalar()
  1657. if users_sql and "password_hash VARCHAR(255) NOT NULL" in users_sql:
  1658. version_result = await conn.execute(text("PRAGMA schema_version"))
  1659. schema_version = version_result.scalar() or 0
  1660. await conn.execute(text("PRAGMA writable_schema = ON"))
  1661. await conn.execute(
  1662. text(
  1663. "UPDATE sqlite_master "
  1664. "SET sql = replace(sql, 'password_hash VARCHAR(255) NOT NULL', 'password_hash VARCHAR(255)') "
  1665. "WHERE type = 'table' AND name = 'users'"
  1666. )
  1667. )
  1668. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  1669. await conn.execute(text("PRAGMA writable_schema = OFF"))
  1670. except (OperationalError, ProgrammingError) as exc:
  1671. logger.error(
  1672. "Failed to remove NOT NULL from users.password_hash via writable_schema — "
  1673. "OIDC/LDAP user creation will fail on this install: %s",
  1674. exc,
  1675. exc_info=True,
  1676. )
  1677. else:
  1678. await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
  1679. # Migration: Add energy_start_kwh to print_archives (#941)
  1680. # Persists the smart plug lifetime counter captured at print start, so per-print
  1681. # energy tracking survives a backend restart mid-print.
  1682. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_kwh REAL")
  1683. # Migration: Add subtask_id to print_archives (#972)
  1684. # MQTT-provided task identifier used to resume the same archive row across a
  1685. # backend restart mid-print. Without it, a long print (e.g. 13h) triggers
  1686. # stale-cancel + new-archive, losing started_at continuity.
  1687. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN subtask_id VARCHAR(64)")
  1688. # Migration: Add bed_type to print_archives (#1253)
  1689. # Build plate type extracted from 3MF (curr_bed_type), drives the bed icon
  1690. # rendered on archive cards.
  1691. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
  1692. # Migration: Create smart_plug_energy_snapshots table (#941)
  1693. # Hourly snapshots of each plug's lifetime counter, so date-range queries in
  1694. # "total consumption" energy mode can compute (last - first) deltas.
  1695. await _safe_execute(
  1696. conn,
  1697. """
  1698. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  1699. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1700. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  1701. recorded_at DATETIME NOT NULL,
  1702. lifetime_kwh REAL NOT NULL
  1703. )
  1704. """
  1705. if is_sqlite()
  1706. else """
  1707. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  1708. id SERIAL PRIMARY KEY,
  1709. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  1710. recorded_at TIMESTAMP NOT NULL,
  1711. lifetime_kwh REAL NOT NULL
  1712. )
  1713. """,
  1714. )
  1715. await _safe_execute(
  1716. conn,
  1717. "CREATE INDEX IF NOT EXISTS ix_plug_energy_snapshots_plug_time "
  1718. "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
  1719. )
  1720. # Migration: Add PKCE code_verifier column to auth_ephemeral_tokens
  1721. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)")
  1722. # Migration: Add TOTP replay-protection counter to user_totp
  1723. await _safe_execute(conn, "ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT")
  1724. # Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)
  1725. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
  1726. # Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
  1727. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  1728. if is_sqlite():
  1729. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0")
  1730. else:
  1731. await _safe_execute(
  1732. conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
  1733. )
  1734. # Migration: Azure Entra ID support — configurable email claim and verification requirement
  1735. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN email_claim VARCHAR(64) DEFAULT 'email'")
  1736. # Postgres rejects `DEFAULT 1` for BOOLEAN columns.
  1737. if is_sqlite():
  1738. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
  1739. else:
  1740. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true")
  1741. # SEC-1 backfill: reset auto_link only for Fall B (email_claim='email' + require_email_verified=False).
  1742. # Fall C (custom claim) is now allowed to use auto_link — do NOT reset those rows.
  1743. # Runs BEFORE the CHECK constraint below so Fall B rows self-heal rather than failing
  1744. # PostgreSQL's "check constraint is violated by some row" on ADD CONSTRAINT.
  1745. # On fresh installs the column defaults guarantee this UPDATE matches zero rows.
  1746. # TRUE/FALSE literals are accepted by both SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
  1747. try:
  1748. async with conn.begin_nested():
  1749. await conn.execute(
  1750. text(
  1751. "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
  1752. "WHERE auto_link_existing_accounts = TRUE "
  1753. "AND email_claim = 'email' AND require_email_verified = FALSE"
  1754. )
  1755. )
  1756. except Exception as exc:
  1757. logger.error(
  1758. "SEC-1 safety backfill FAILED — auto_link_existing_accounts may remain enabled "
  1759. "on providers with unsafe email settings: %s",
  1760. exc,
  1761. exc_info=True,
  1762. )
  1763. raise
  1764. # SEC-1: Add DB-level CHECK constraint for existing PostgreSQL installs.
  1765. # SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
  1766. # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
  1767. if not is_sqlite():
  1768. try:
  1769. async with conn.begin_nested():
  1770. await conn.execute(
  1771. text(
  1772. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
  1773. "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
  1774. )
  1775. )
  1776. except (OperationalError, ProgrammingError) as exc:
  1777. msg = str(exc).lower()
  1778. if "already exists" not in msg:
  1779. logger.error(
  1780. "Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
  1781. exc,
  1782. exc_info=True,
  1783. )
  1784. raise
  1785. # Migration: Update auto_link CHECK constraint formula (existing installs).
  1786. # Existing PostgreSQL installs that ran the ADD CONSTRAINT above with the old formula
  1787. # (or a previous version of this code) need an explicit DROP + ADD to update it.
  1788. # For SQLite, the table is recreated with the new constraint formula if the old formula
  1789. # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
  1790. await _migrate_update_auto_link_constraint(conn)
  1791. # Migration: Add default_group_id to oidc_providers.
  1792. # Must run AFTER _migrate_update_auto_link_constraint to avoid being dropped during
  1793. # the SQLite table recreation that function performs on stale-formula databases.
  1794. await _safe_execute(
  1795. conn,
  1796. "ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
  1797. )
  1798. # Migration: Add cached-icon columns to oidc_providers (#1333).
  1799. # SPA's strict CSP (img-src 'self' data: blob:) blocks hotlinking external
  1800. # icon hosts, so we proxy them: admin sets icon_url, backend fetches and
  1801. # caches the bytes here, the SPA renders <img src="/api/v1/auth/oidc/providers/{id}/icon">.
  1802. # Must run AFTER _migrate_update_auto_link_constraint for the same reason as
  1803. # default_group_id above (SQLite table recreation drops unknown columns).
  1804. # Dialect-conditional type: BLOB on SQLite, BYTEA on PostgreSQL.
  1805. _blob_type = "BLOB" if is_sqlite() else "BYTEA"
  1806. await _safe_execute(conn, f"ALTER TABLE oidc_providers ADD COLUMN icon_data {_blob_type}")
  1807. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_content_type VARCHAR(20)")
  1808. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_etag VARCHAR(64)")
  1809. # PostgreSQL-only: enforce the all-or-nothing triplet at the DB layer.
  1810. # SQLite cannot ADD CONSTRAINT to an existing table — fresh SQLite
  1811. # installs get the CHECK via metadata.create_all (model __table_args__);
  1812. # stale SQLite installs rely on the application layer, same trade-off
  1813. # as the default_group_id FK ON DELETE SET NULL above.
  1814. if not is_sqlite():
  1815. await _safe_execute(
  1816. conn,
  1817. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_oidc_icon_triplet_co_null "
  1818. "CHECK ((icon_data IS NULL) = (icon_content_type IS NULL) "
  1819. "AND (icon_content_type IS NULL) = (icon_etag IS NULL))",
  1820. )
  1821. # Migration: Add password_changed_at to users (M-R7-B)
  1822. # Tracks the last time a user's password was changed/reset. JWTs whose iat
  1823. # predates this timestamp are rejected in all six auth validation paths.
  1824. # R4 fix: TIMESTAMP is accepted by both SQLite and PostgreSQL; DATETIME
  1825. # is rejected by Postgres ("type 'datetime' does not exist"), which made
  1826. # _safe_execute swallow the error and leave existing Postgres installs
  1827. # without the column — causing UndefinedColumnError on every User query.
  1828. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN password_changed_at TIMESTAMP")
  1829. # Migration: Back-fill password_changed_at = created_at for existing users (I2).
  1830. # Users who never changed their password would have NULL here, meaning old
  1831. # tokens could never be invalidated via the freshness check. Setting it to
  1832. # created_at is conservative: any token issued before the account was created
  1833. # is always invalid, so this is a safe lower bound.
  1834. async with conn.begin_nested():
  1835. await conn.execute(text("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL"))
  1836. # Migration: Provenance columns on library_files for MakerWorld imports.
  1837. # source_url is indexed so "already imported" dedupe lookups stay O(log N)
  1838. # as the library grows.
  1839. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_type VARCHAR(32)")
  1840. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_url VARCHAR(512)")
  1841. await _safe_execute(
  1842. conn,
  1843. "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
  1844. )
  1845. # Migration: Cache metadata title on pending uploads (#1152 follow-up).
  1846. # Without this column the review card always shows the FTP filename while
  1847. # the eventual archive's print_name comes from the 3MF metadata title,
  1848. # creating a confusing review→archive name mismatch. Captured at upload
  1849. # time so /pending-uploads/ list calls don't have to reopen each 3MF.
  1850. await _safe_execute(
  1851. conn,
  1852. "ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)",
  1853. )
  1854. # Migration: Per-user API key ownership + cloud-access scope (#1182).
  1855. # user_id is nullable so legacy keys (created before #1182) survive the
  1856. # migration; cloud routes reject calls from keys without an owner so the
  1857. # operator is forced to recreate them. ON DELETE CASCADE so deleting a user
  1858. # takes their keys with them — orphan keys must never authenticate.
  1859. # SQLite ignores REFERENCES on ADD COLUMN (not enforced but not an error);
  1860. # PostgreSQL enforces the FK from this point forward. Indexed for the
  1861. # auth-gate's owner→keys lookup that runs on every API-keyed request.
  1862. await _safe_execute(
  1863. conn,
  1864. "ALTER TABLE api_keys ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
  1865. )
  1866. await _safe_execute(
  1867. conn,
  1868. "CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys(user_id)",
  1869. )
  1870. # ``DEFAULT 0`` works on SQLite (boolean is just integer-coerced) but
  1871. # asyncpg's strict type-check rejects it: "column is of type boolean but
  1872. # default expression is of type integer". Use ``DEFAULT FALSE`` so both
  1873. # dialects accept the same statement — same pattern as the print_queue
  1874. # gcode_injection migration above.
  1875. await _safe_execute(
  1876. conn,
  1877. "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
  1878. )
  1879. # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
  1880. # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
  1881. # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
  1882. #
  1883. # ``DATETIME`` is a SQLite-only type alias — PostgreSQL rejects it as
  1884. # invalid syntax, _safe_execute swallows the error, and the column is
  1885. # never added (breaking every query that references it). Emit
  1886. # dialect-appropriate SQL so both backends get the column.
  1887. if is_sqlite():
  1888. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at DATETIME")
  1889. else:
  1890. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at TIMESTAMP")
  1891. await _safe_execute(
  1892. conn,
  1893. "CREATE INDEX IF NOT EXISTS ix_library_files_deleted_at ON library_files(deleted_at)",
  1894. )
  1895. # Legacy SQLite installs created `settings` without a UNIQUE constraint on `key`,
  1896. # so `INSERT OR IGNORE` below silently degrades to a plain INSERT and dupes rows on
  1897. # every restart. Dedupe (keep lowest id per key) and add the missing unique index
  1898. # before seeding. Safe/idempotent on both dialects — fresh installs already have
  1899. # no dupes and `create_all` already emits the index.
  1900. async with conn.begin_nested():
  1901. await conn.execute(text("DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)"))
  1902. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_key ON settings(key)")
  1903. # Migration: Normalise provider_email to lowercase (SEC-3).
  1904. # Required for Entra ID where UPN/email claims may arrive in mixed case.
  1905. # LOWER() is supported by both SQLite and PostgreSQL; the UPDATE is idempotent.
  1906. # Executed directly (not via _safe_execute) so any column-reference failure
  1907. # is always fatal and never silently swallowed.
  1908. async with conn.begin_nested():
  1909. await conn.execute(
  1910. text(
  1911. "UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
  1912. "WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
  1913. )
  1914. )
  1915. # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
  1916. # Replaces the pattern of writing spool.location in Spoolman (which polluted the
  1917. # user-editable storage_location field in the UI).
  1918. # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
  1919. await _safe_execute(
  1920. conn,
  1921. """
  1922. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  1923. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1924. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1925. ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
  1926. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  1927. spoolman_spool_id INTEGER NOT NULL,
  1928. assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1929. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  1930. )
  1931. """
  1932. if is_sqlite()
  1933. else """
  1934. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  1935. id SERIAL PRIMARY KEY,
  1936. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1937. ams_id INTEGER NOT NULL CHECK ((ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255),
  1938. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  1939. spoolman_spool_id INTEGER NOT NULL,
  1940. assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1941. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  1942. )
  1943. """,
  1944. )
  1945. await _safe_execute(
  1946. conn,
  1947. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)",
  1948. )
  1949. # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
  1950. # AMS-HT ids (128-191). Existing installs created before #1274 carry the
  1951. # stale formula which rejects every AMS-HT slot link with a CHECK violation.
  1952. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  1953. # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
  1954. await _safe_execute(
  1955. conn,
  1956. """
  1957. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  1958. id INTEGER PRIMARY KEY AUTOINCREMENT,
  1959. spoolman_spool_id INTEGER NOT NULL,
  1960. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1961. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  1962. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  1963. nozzle_type VARCHAR(50),
  1964. k_value REAL NOT NULL,
  1965. name VARCHAR(100),
  1966. cali_idx INTEGER,
  1967. setting_id VARCHAR(50),
  1968. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1969. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  1970. )
  1971. """
  1972. if is_sqlite()
  1973. else """
  1974. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  1975. id SERIAL PRIMARY KEY,
  1976. spoolman_spool_id INTEGER NOT NULL,
  1977. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  1978. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  1979. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  1980. nozzle_type VARCHAR(50),
  1981. k_value DOUBLE PRECISION NOT NULL,
  1982. name VARCHAR(100),
  1983. cali_idx INTEGER,
  1984. setting_id VARCHAR(50),
  1985. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1986. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  1987. )
  1988. """,
  1989. )
  1990. await _safe_execute(
  1991. conn,
  1992. "CREATE INDEX IF NOT EXISTS ix_spoolman_k_profile_spool ON spoolman_k_profile (spoolman_spool_id)",
  1993. )
  1994. # Migration: Add provider column to github_backup_config for multi-provider support
  1995. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN provider VARCHAR(30) DEFAULT 'github'")
  1996. # Migration: Add allow_insecure_http column to github_backup_config for self-hosted HTTP instances
  1997. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN allow_insecure_http BOOLEAN DEFAULT FALSE")
  1998. # Seed default settings keys that must exist on fresh install
  1999. default_settings = [
  2000. ("advanced_auth_enabled", "false"),
  2001. ("smtp_auth_enabled", "true"),
  2002. ]
  2003. for key, value in default_settings:
  2004. try:
  2005. if is_sqlite():
  2006. await conn.execute(
  2007. text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
  2008. {"key": key, "value": value},
  2009. )
  2010. else:
  2011. await conn.execute(
  2012. text("INSERT INTO settings (key, value) VALUES (:key, :value) ON CONFLICT (key) DO NOTHING"),
  2013. {"key": key, "value": value},
  2014. )
  2015. except (OperationalError, ProgrammingError):
  2016. pass
  2017. # Migration: Create filament_sku_settings table for reorder forecasting
  2018. if is_sqlite():
  2019. await _safe_execute(
  2020. conn,
  2021. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  2022. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2023. material VARCHAR(50) NOT NULL,
  2024. subtype VARCHAR(50),
  2025. brand VARCHAR(100),
  2026. lead_time_days INTEGER NOT NULL DEFAULT 0,
  2027. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  2028. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  2029. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2030. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2031. UNIQUE (material, subtype, brand)
  2032. )""",
  2033. )
  2034. async with conn.begin_nested():
  2035. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  2036. await _safe_execute(
  2037. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_value INTEGER NOT NULL DEFAULT 14"
  2038. )
  2039. await _safe_execute(
  2040. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'"
  2041. )
  2042. await _safe_execute(
  2043. conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
  2044. )
  2045. # Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
  2046. # Only run if the stale column still exists.
  2047. cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
  2048. col_names = [row[1] for row in cols_result.fetchall()]
  2049. if "safety_margin_days" in col_names:
  2050. async with conn.begin_nested():
  2051. # Defensive: a previous startup may have crashed mid-rebuild leaving
  2052. # filament_sku_settings_new behind, which would break the CREATE below.
  2053. await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_new"))
  2054. await conn.execute(
  2055. text(
  2056. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  2057. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  2058. )
  2059. )
  2060. await conn.execute(
  2061. text(
  2062. """CREATE TABLE filament_sku_settings_new (
  2063. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2064. material VARCHAR(50) NOT NULL,
  2065. subtype VARCHAR(50),
  2066. brand VARCHAR(100),
  2067. lead_time_days INTEGER NOT NULL DEFAULT 0,
  2068. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  2069. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  2070. alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
  2071. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2072. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2073. UNIQUE (material, subtype, brand)
  2074. )"""
  2075. )
  2076. )
  2077. await conn.execute(
  2078. text(
  2079. """INSERT INTO filament_sku_settings_new
  2080. (id, material, subtype, brand, lead_time_days, safety_margin_value,
  2081. safety_margin_unit, alerts_snoozed, created_at, updated_at)
  2082. SELECT id, material, subtype, brand, lead_time_days, safety_margin_value,
  2083. safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
  2084. FROM filament_sku_settings"""
  2085. )
  2086. )
  2087. await conn.execute(text("DROP TABLE filament_sku_settings"))
  2088. await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
  2089. await _safe_execute(
  2090. conn,
  2091. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  2092. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2093. material VARCHAR(50) NOT NULL,
  2094. subtype VARCHAR(50),
  2095. brand VARCHAR(100),
  2096. quantity_spools INTEGER NOT NULL DEFAULT 1,
  2097. note VARCHAR(500),
  2098. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  2099. purchased_at DATETIME,
  2100. added_at DATETIME DEFAULT CURRENT_TIMESTAMP
  2101. )""",
  2102. )
  2103. # SQLite has no implicit updated_at trigger — add one so the column stays current.
  2104. await _safe_execute(
  2105. conn,
  2106. """CREATE TRIGGER IF NOT EXISTS trg_filament_sku_settings_updated_at
  2107. AFTER UPDATE ON filament_sku_settings FOR EACH ROW
  2108. BEGIN
  2109. UPDATE filament_sku_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
  2110. END""",
  2111. )
  2112. else:
  2113. await _safe_execute(
  2114. conn,
  2115. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  2116. id SERIAL PRIMARY KEY,
  2117. material VARCHAR(50) NOT NULL,
  2118. subtype VARCHAR(50),
  2119. brand VARCHAR(100),
  2120. lead_time_days INTEGER NOT NULL DEFAULT 0,
  2121. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  2122. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  2123. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  2124. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  2125. UNIQUE (material, subtype, brand)
  2126. )""",
  2127. )
  2128. async with conn.begin_nested():
  2129. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  2130. await _safe_execute(
  2131. conn,
  2132. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_value INTEGER NOT NULL DEFAULT 14",
  2133. )
  2134. await _safe_execute(
  2135. conn,
  2136. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'",
  2137. )
  2138. await _safe_execute(
  2139. conn,
  2140. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
  2141. )
  2142. # Only backfill from safety_margin_days if that column still exists (PostgreSQL).
  2143. col_check = await conn.execute(
  2144. text(
  2145. "SELECT 1 FROM information_schema.columns "
  2146. "WHERE table_name = 'filament_sku_settings' AND column_name = 'safety_margin_days'"
  2147. )
  2148. )
  2149. if col_check.fetchone():
  2150. async with conn.begin_nested():
  2151. await conn.execute(
  2152. text(
  2153. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  2154. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  2155. )
  2156. )
  2157. await _safe_execute(
  2158. conn,
  2159. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  2160. id SERIAL PRIMARY KEY,
  2161. material VARCHAR(50) NOT NULL,
  2162. subtype VARCHAR(50),
  2163. brand VARCHAR(100),
  2164. quantity_spools INTEGER NOT NULL DEFAULT 1,
  2165. note VARCHAR(500),
  2166. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  2167. purchased_at TIMESTAMP,
  2168. added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  2169. )""",
  2170. )
  2171. await _safe_execute(
  2172. conn,
  2173. "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'",
  2174. )
  2175. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMP")
  2176. # Migration: Add inventory stock alert columns to notification_providers.
  2177. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  2178. if is_sqlite():
  2179. await _safe_execute(
  2180. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT 0"
  2181. )
  2182. await _safe_execute(
  2183. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT 0"
  2184. )
  2185. else:
  2186. await _safe_execute(
  2187. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT false"
  2188. )
  2189. await _safe_execute(
  2190. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
  2191. )
  2192. # Migration: Heal orphan auth-related rows left behind by user-delete
  2193. # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
  2194. # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
  2195. # CASCADE on user_id — both predate the explicit APIKey-cleanup
  2196. # pattern in PR #1182. PostgreSQL enforces the cascade, but SQLite
  2197. # ships with FK enforcement off, so rows pointing to a deleted user
  2198. # persisted — blocking SSO re-login (the OIDC callback finds the
  2199. # orphan link, fails to resolve the missing user, and falls through
  2200. # to "account_inactive" instead of triggering auto_create), leaking
  2201. # MFA secrets, and leaving camera-stream tokens whose secret_hash is
  2202. # still verify()-able by lookup_prefix. See issue #1285 (#1295 review
  2203. # extended the cleanup to long_lived_tokens). This migration is a
  2204. # no-op on PostgreSQL and idempotent on SQLite.
  2205. async with conn.begin_nested():
  2206. oidc_result = await conn.execute(
  2207. text("DELETE FROM user_oidc_links WHERE user_id NOT IN (SELECT id FROM users)")
  2208. )
  2209. totp_result = await conn.execute(text("DELETE FROM user_totp WHERE user_id NOT IN (SELECT id FROM users)"))
  2210. otp_result = await conn.execute(text("DELETE FROM user_otp_codes WHERE user_id NOT IN (SELECT id FROM users)"))
  2211. llt_result = await conn.execute(
  2212. text("DELETE FROM long_lived_tokens WHERE user_id NOT IN (SELECT id FROM users)")
  2213. )
  2214. oidc_n = oidc_result.rowcount or 0
  2215. totp_n = totp_result.rowcount or 0
  2216. otp_n = otp_result.rowcount or 0
  2217. llt_n = llt_result.rowcount or 0
  2218. if oidc_n or totp_n or otp_n or llt_n:
  2219. logger.info(
  2220. "Cleaned up orphan auth rows: %d OIDC links, %d TOTP, %d OTP codes, %d long-lived tokens",
  2221. oidc_n,
  2222. totp_n,
  2223. otp_n,
  2224. llt_n,
  2225. )
  2226. async def seed_notification_templates():
  2227. """Seed default notification templates if they don't exist."""
  2228. from sqlalchemy import select
  2229. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  2230. async with async_session() as session:
  2231. # Get existing template event types
  2232. result = await session.execute(select(NotificationTemplate.event_type))
  2233. existing_types = {row[0] for row in result.fetchall()}
  2234. if not existing_types:
  2235. # No templates exist - insert all defaults
  2236. for template_data in DEFAULT_TEMPLATES:
  2237. template = NotificationTemplate(
  2238. event_type=template_data["event_type"],
  2239. name=template_data["name"],
  2240. title_template=template_data["title_template"],
  2241. body_template=template_data["body_template"],
  2242. is_default=True,
  2243. )
  2244. session.add(template)
  2245. else:
  2246. # Templates exist - only add missing ones
  2247. for template_data in DEFAULT_TEMPLATES:
  2248. if template_data["event_type"] not in existing_types:
  2249. template = NotificationTemplate(
  2250. event_type=template_data["event_type"],
  2251. name=template_data["name"],
  2252. title_template=template_data["title_template"],
  2253. body_template=template_data["body_template"],
  2254. is_default=True,
  2255. )
  2256. session.add(template)
  2257. await session.commit()
  2258. async def seed_default_groups():
  2259. """Seed default groups and migrate existing users to appropriate groups.
  2260. Creates the default system groups (Administrators, Operators, Viewers) if they
  2261. don't exist, then migrates existing users:
  2262. - Users with role='admin' -> Administrators group
  2263. - Users with role='user' -> Operators group
  2264. Also migrates old permissions to new ownership-based permissions (Issue #205).
  2265. """
  2266. import logging
  2267. from sqlalchemy import select
  2268. from backend.app.core.permissions import DEFAULT_GROUPS
  2269. from backend.app.models.group import Group
  2270. from backend.app.models.user import User
  2271. logger = logging.getLogger(__name__)
  2272. # Map old permissions to new ones for migration
  2273. # Administrators get *_all permissions, Operators get *_own permissions
  2274. PERMISSION_MIGRATION_ALL = {
  2275. "queue:update": "queue:update_all",
  2276. "queue:delete": "queue:delete_all",
  2277. "archives:update": "archives:update_all",
  2278. "archives:delete": "archives:delete_all",
  2279. "archives:reprint": "archives:reprint_all",
  2280. "library:update": "library:update_all",
  2281. "library:delete": "library:delete_all",
  2282. }
  2283. PERMISSION_MIGRATION_OWN = {
  2284. "queue:update": "queue:update_own",
  2285. "queue:delete": "queue:delete_own",
  2286. "archives:update": "archives:update_own",
  2287. "archives:delete": "archives:delete_own",
  2288. "archives:reprint": "archives:reprint_own",
  2289. "library:update": "library:update_own",
  2290. "library:delete": "library:delete_own",
  2291. }
  2292. async with async_session() as session:
  2293. # Get existing groups
  2294. result = await session.execute(select(Group))
  2295. existing_groups = {group.name: group for group in result.scalars().all()}
  2296. # Create default groups if they don't exist
  2297. groups_created = []
  2298. for group_name, group_config in DEFAULT_GROUPS.items():
  2299. if group_name not in existing_groups:
  2300. group = Group(
  2301. name=group_name,
  2302. description=group_config["description"],
  2303. permissions=group_config["permissions"],
  2304. is_system=group_config["is_system"],
  2305. )
  2306. session.add(group)
  2307. groups_created.append(group_name)
  2308. logger.info("Created default group: %s", group_name)
  2309. else:
  2310. # Migrate existing group's permissions from old to new format
  2311. group = existing_groups[group_name]
  2312. if group.permissions:
  2313. updated = False
  2314. new_permissions = list(group.permissions)
  2315. # Determine which migration map to use based on group
  2316. migration_map = (
  2317. PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
  2318. )
  2319. for old_perm, new_perm in migration_map.items():
  2320. if old_perm in new_permissions:
  2321. new_permissions.remove(old_perm)
  2322. if new_perm not in new_permissions:
  2323. new_permissions.append(new_perm)
  2324. updated = True
  2325. logger.info(
  2326. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  2327. )
  2328. # For Administrators, also ensure they get *_all permissions if they have any new *_own
  2329. if group_name == "Administrators":
  2330. for _own_perm, all_perm in [
  2331. ("queue:update_own", "queue:update_all"),
  2332. ("queue:delete_own", "queue:delete_all"),
  2333. ("archives:update_own", "archives:update_all"),
  2334. ("archives:delete_own", "archives:delete_all"),
  2335. ("archives:reprint_own", "archives:reprint_all"),
  2336. ("library:update_own", "library:update_all"),
  2337. ("library:delete_own", "library:delete_all"),
  2338. ]:
  2339. # Add *_all if not present
  2340. if all_perm not in new_permissions:
  2341. new_permissions.append(all_perm)
  2342. updated = True
  2343. if updated:
  2344. group.permissions = new_permissions
  2345. await session.commit()
  2346. # Migrate new permissions: grant printers:clear_plate to all groups with printers:control
  2347. result = await session.execute(select(Group))
  2348. all_groups = result.scalars().all()
  2349. for group in all_groups:
  2350. if (
  2351. group.permissions
  2352. and "printers:control" in group.permissions
  2353. and "printers:clear_plate" not in group.permissions
  2354. ):
  2355. group.permissions = [*group.permissions, "printers:clear_plate"]
  2356. logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name)
  2357. await session.commit()
  2358. # Migrate new permissions for MakerWorld integration: groups that
  2359. # already have library:upload (i.e. can write to the library) are
  2360. # the correct audience for makerworld:view + makerworld:import, and
  2361. # groups that only have library:read get makerworld:view (browse
  2362. # only). Matches the intent of DEFAULT_GROUPS without clobbering
  2363. # any user-customised permission lists.
  2364. result = await session.execute(select(Group))
  2365. for group in result.scalars().all():
  2366. if not group.permissions:
  2367. continue
  2368. perms = list(group.permissions)
  2369. changed = False
  2370. if "library:upload" in perms:
  2371. for new_perm in ("makerworld:view", "makerworld:import"):
  2372. if new_perm not in perms:
  2373. perms.append(new_perm)
  2374. changed = True
  2375. logger.info("Added %s to group '%s' (has library:upload)", new_perm, group.name)
  2376. elif "library:read" in perms and "makerworld:view" not in perms:
  2377. perms.append("makerworld:view")
  2378. changed = True
  2379. logger.info("Added makerworld:view to group '%s' (has library:read)", group.name)
  2380. if changed:
  2381. group.permissions = perms
  2382. await session.commit()
  2383. # Backfill library:purge + archives:purge for the Administrators group
  2384. # on existing installs. Both permissions were added after Administrators
  2385. # was first seeded, so upgrading users miss them even though the default
  2386. # config (ALL_PERMISSIONS) includes them for fresh installs.
  2387. result = await session.execute(select(Group).where(Group.name == "Administrators"))
  2388. admin_group = result.scalar_one_or_none()
  2389. if admin_group and admin_group.permissions is not None:
  2390. perms = list(admin_group.permissions)
  2391. added = False
  2392. for new_perm in ("library:purge", "archives:purge"):
  2393. if new_perm not in perms:
  2394. perms.append(new_perm)
  2395. added = True
  2396. logger.info("Added %s to Administrators group (backfill)", new_perm)
  2397. if added:
  2398. admin_group.permissions = perms
  2399. await session.commit()
  2400. # Backfill inventory forecast permissions for existing groups.
  2401. # inventory:forecast_read was added after initial seeding, so groups
  2402. # that already have inventory:read (or inventory:update) need it added.
  2403. # inventory:forecast_write goes to any group with inventory:update.
  2404. result = await session.execute(select(Group))
  2405. for group in result.scalars().all():
  2406. if not group.permissions:
  2407. continue
  2408. perms = list(group.permissions)
  2409. changed = False
  2410. if "inventory:read" in perms and "inventory:forecast_read" not in perms:
  2411. perms.append("inventory:forecast_read")
  2412. changed = True
  2413. logger.info("Added inventory:forecast_read to group '%s' (backfill)", group.name)
  2414. if "inventory:update" in perms and "inventory:forecast_write" not in perms:
  2415. perms.append("inventory:forecast_write")
  2416. changed = True
  2417. logger.info("Added inventory:forecast_write to group '%s' (backfill)", group.name)
  2418. if changed:
  2419. group.permissions = perms
  2420. await session.commit()
  2421. # Migrate existing users to groups if they're not already in any group
  2422. if groups_created:
  2423. # Refresh to get newly created groups
  2424. admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
  2425. admin_group = admin_result.scalar_one_or_none()
  2426. operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
  2427. operators_group = operators_result.scalar_one_or_none()
  2428. # Get all users
  2429. users_result = await session.execute(select(User))
  2430. users = users_result.scalars().all()
  2431. for user in users:
  2432. # Skip if user already has groups
  2433. if user.groups:
  2434. continue
  2435. if user.role == "admin" and admin_group:
  2436. user.groups.append(admin_group)
  2437. logger.info("Migrated admin user '%s' to Administrators group", user.username)
  2438. elif operators_group:
  2439. user.groups.append(operators_group)
  2440. logger.info("Migrated user '%s' to Operators group", user.username)
  2441. await session.commit()
  2442. async def seed_spool_catalog():
  2443. """Seed the spool catalog with default entries if empty."""
  2444. import logging
  2445. from sqlalchemy import func, select
  2446. from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG
  2447. from backend.app.models.spool_catalog import SpoolCatalogEntry
  2448. logger = logging.getLogger(__name__)
  2449. async with async_session() as session:
  2450. result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))
  2451. count = result.scalar() or 0
  2452. if count > 0:
  2453. return # Already seeded
  2454. for name, weight in DEFAULT_SPOOL_CATALOG:
  2455. session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  2456. await session.commit()
  2457. logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG))
  2458. async def seed_color_catalog():
  2459. """Seed the color catalog with default entries if empty."""
  2460. import logging
  2461. from sqlalchemy import func, select
  2462. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
  2463. from backend.app.models.color_catalog import ColorCatalogEntry
  2464. logger = logging.getLogger(__name__)
  2465. async with async_session() as session:
  2466. result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))
  2467. count = result.scalar() or 0
  2468. if count > 0:
  2469. return # Already seeded
  2470. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  2471. session.add(
  2472. ColorCatalogEntry(
  2473. manufacturer=manufacturer,
  2474. color_name=color_name,
  2475. hex_color=hex_color,
  2476. material=material,
  2477. is_default=True,
  2478. )
  2479. )
  2480. await session.commit()
  2481. logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))