database.py 239 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032303330343035303630373038303930403041304230433044304530463047304830493050305130523053305430553056305730583059306030613062306330643065306630673068306930703071307230733074307530763077307830793080308130823083308430853086308730883089309030913092309330943095309630973098309931003101310231033104310531063107310831093110311131123113311431153116311731183119312031213122312331243125312631273128312931303131313231333134313531363137313831393140314131423143314431453146314731483149315031513152315331543155315631573158315931603161316231633164316531663167316831693170317131723173317431753176317731783179318031813182318331843185318631873188318931903191319231933194319531963197319831993200320132023203320432053206320732083209321032113212321332143215321632173218321932203221322232233224322532263227322832293230323132323233323432353236323732383239324032413242324332443245324632473248324932503251325232533254325532563257325832593260326132623263326432653266326732683269327032713272327332743275327632773278327932803281328232833284328532863287328832893290329132923293329432953296329732983299330033013302330333043305330633073308330933103311331233133314331533163317331833193320332133223323332433253326332733283329333033313332333333343335333633373338333933403341334233433344334533463347334833493350335133523353335433553356335733583359336033613362336333643365336633673368336933703371337233733374337533763377337833793380338133823383338433853386338733883389339033913392339333943395339633973398339934003401340234033404340534063407340834093410341134123413341434153416341734183419342034213422342334243425342634273428342934303431343234333434343534363437343834393440344134423443344434453446344734483449345034513452345334543455345634573458345934603461346234633464346534663467346834693470347134723473347434753476347734783479348034813482348334843485348634873488348934903491349234933494349534963497349834993500350135023503350435053506350735083509351035113512351335143515351635173518351935203521352235233524352535263527352835293530353135323533353435353536353735383539354035413542354335443545354635473548354935503551355235533554355535563557355835593560356135623563356435653566356735683569357035713572357335743575357635773578357935803581358235833584358535863587358835893590359135923593359435953596359735983599360036013602360336043605360636073608360936103611361236133614361536163617361836193620362136223623362436253626362736283629363036313632363336343635363636373638363936403641364236433644364536463647364836493650365136523653365436553656365736583659366036613662366336643665366636673668366936703671367236733674367536763677367836793680368136823683368436853686368736883689369036913692369336943695369636973698369937003701370237033704370537063707370837093710371137123713371437153716371737183719372037213722372337243725372637273728372937303731373237333734373537363737373837393740374137423743374437453746374737483749375037513752375337543755375637573758375937603761376237633764376537663767376837693770377137723773377437753776377737783779378037813782378337843785378637873788378937903791379237933794379537963797379837993800380138023803380438053806380738083809381038113812381338143815381638173818381938203821382238233824382538263827382838293830383138323833383438353836383738383839384038413842384338443845384638473848384938503851385238533854385538563857385838593860386138623863386438653866386738683869387038713872387338743875387638773878387938803881388238833884388538863887388838893890389138923893389438953896389738983899390039013902390339043905390639073908390939103911391239133914391539163917391839193920392139223923392439253926392739283929393039313932393339343935393639373938393939403941394239433944394539463947394839493950395139523953395439553956395739583959396039613962396339643965396639673968396939703971397239733974397539763977397839793980398139823983398439853986398739883989399039913992399339943995399639973998399940004001400240034004400540064007400840094010401140124013401440154016401740184019402040214022402340244025402640274028402940304031403240334034403540364037403840394040404140424043404440454046404740484049405040514052405340544055405640574058405940604061406240634064406540664067406840694070407140724073407440754076407740784079408040814082408340844085408640874088408940904091409240934094409540964097409840994100410141024103410441054106410741084109411041114112411341144115411641174118411941204121412241234124412541264127412841294130413141324133413441354136413741384139414041414142414341444145414641474148414941504151415241534154415541564157415841594160416141624163416441654166416741684169417041714172417341744175417641774178417941804181418241834184418541864187418841894190419141924193419441954196419741984199420042014202420342044205420642074208420942104211421242134214421542164217421842194220422142224223422442254226422742284229423042314232423342344235423642374238423942404241424242434244424542464247424842494250425142524253425442554256425742584259426042614262426342644265426642674268426942704271427242734274427542764277427842794280428142824283428442854286428742884289429042914292429342944295429642974298429943004301430243034304430543064307430843094310431143124313431443154316431743184319432043214322432343244325432643274328432943304331433243334334433543364337433843394340434143424343434443454346434743484349435043514352435343544355435643574358435943604361436243634364436543664367436843694370437143724373437443754376437743784379438043814382438343844385438643874388438943904391439243934394439543964397439843994400440144024403440444054406440744084409441044114412441344144415441644174418441944204421442244234424442544264427442844294430443144324433443444354436443744384439444044414442444344444445444644474448444944504451445244534454445544564457445844594460446144624463446444654466446744684469447044714472447344744475447644774478447944804481448244834484448544864487448844894490449144924493449444954496449744984499450045014502450345044505450645074508450945104511451245134514451545164517451845194520452145224523452445254526452745284529453045314532453345344535453645374538453945404541454245434544454545464547454845494550455145524553455445554556455745584559456045614562456345644565456645674568456945704571457245734574457545764577457845794580458145824583458445854586458745884589459045914592459345944595459645974598459946004601460246034604460546064607460846094610461146124613461446154616461746184619462046214622462346244625462646274628462946304631463246334634463546364637463846394640464146424643464446454646464746484649465046514652465346544655465646574658465946604661466246634664466546664667466846694670467146724673467446754676467746784679468046814682468346844685468646874688468946904691469246934694469546964697469846994700470147024703470447054706470747084709471047114712471347144715471647174718471947204721472247234724472547264727472847294730473147324733473447354736473747384739474047414742474347444745474647474748474947504751475247534754475547564757475847594760476147624763476447654766476747684769477047714772477347744775477647774778477947804781478247834784478547864787478847894790479147924793479447954796479747984799480048014802480348044805480648074808480948104811481248134814481548164817481848194820482148224823482448254826482748284829483048314832
  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. # Resolved connection-pool configuration, captured at engine creation so
  20. # /system/db-pool can report it without re-deriving the dialect defaults.
  21. _pool_config: dict = {}
  22. # What the PostgreSQL server itself will allow, read once at startup. None on
  23. # SQLite, or when the probe could not run. Reported by get_pool_status() so a
  24. # support bundle carries both sides of the comparison.
  25. _server_connection_limits: dict | None = None
  26. def _resolve_pool_kwargs() -> dict:
  27. """Build the pool kwargs for ``create_async_engine`` (issue #2572).
  28. Dialect-aware defaults, each overridable via env (``DB_POOL_SIZE`` etc.):
  29. - PostgreSQL: pool_size 20 + max_overflow 80, ``pool_pre_ping`` (recover
  30. server-dropped connections instead of erroring the request) and
  31. ``pool_recycle`` 1800s. The old hard-coded 10 + 20 exhausted on large
  32. farms while printer callbacks held connections.
  33. - SQLite: pool_size 20 + max_overflow 200 (unchanged); no pre-ping /
  34. recycle — the connection is a local file, not a server socket.
  35. """
  36. if is_sqlite():
  37. pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
  38. max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 200
  39. kwargs = {"pool_size": pool_size, "max_overflow": max_overflow}
  40. else:
  41. pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
  42. max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 80
  43. kwargs = {
  44. "pool_size": pool_size,
  45. "max_overflow": max_overflow,
  46. "pool_pre_ping": True,
  47. "pool_recycle": settings.db_pool_recycle if settings.db_pool_recycle is not None else 1800,
  48. # LIFO checkout keeps a bursty farm on a small hot connection set and
  49. # lets overflow connections recycle out during quiet spells (#2572).
  50. "pool_use_lifo": settings.db_pool_use_lifo if settings.db_pool_use_lifo is not None else True,
  51. }
  52. if settings.db_pool_timeout is not None:
  53. kwargs["pool_timeout"] = settings.db_pool_timeout
  54. return kwargs
  55. def _create_engine():
  56. """Create the async engine with dialect-appropriate settings."""
  57. kwargs = _resolve_pool_kwargs()
  58. global _pool_config
  59. _pool_config = {
  60. "pool_size": kwargs["pool_size"],
  61. "max_overflow": kwargs["max_overflow"],
  62. # SQLAlchemy's own defaults when we don't pass the kwarg.
  63. "pool_timeout": kwargs.get("pool_timeout", 30),
  64. "pool_recycle": kwargs.get("pool_recycle", -1),
  65. "pool_pre_ping": kwargs.get("pool_pre_ping", False),
  66. "pool_use_lifo": kwargs.get("pool_use_lifo", False),
  67. }
  68. eng = create_async_engine(
  69. settings.database_url,
  70. echo=settings.debug,
  71. **kwargs,
  72. )
  73. if is_sqlite():
  74. event.listen(eng.sync_engine, "connect", _set_sqlite_pragmas)
  75. else:
  76. # Strip timezone info from aware datetimes before they reach asyncpg.
  77. # asyncpg rejects timezone-aware values for TIMESTAMP WITHOUT TIME ZONE columns.
  78. # The codebase uses datetime.now(timezone.utc) in many places — this makes
  79. # Postgres behave like SQLite which ignores timezone info entirely.
  80. @event.listens_for(eng.sync_engine, "before_cursor_execute", retval=True)
  81. def _strip_tz_from_params(conn, cursor, statement, parameters, context, executemany):
  82. import datetime
  83. if parameters is None:
  84. return statement, parameters
  85. # Recursive strip that walks any nesting of dict/list/tuple. Needed
  86. # because SQLAlchemy passes parameters in several shapes depending
  87. # on the path: a dict for named binds, a tuple for positional, a
  88. # list of dicts/tuples for executemany, and for insertmanyvalues
  89. # sometimes a list of tuples inside an outer list. The simplest
  90. # correct answer is "strip datetimes at any depth".
  91. def _strip(val):
  92. if isinstance(val, datetime.datetime) and val.tzinfo is not None:
  93. return val.replace(tzinfo=None)
  94. if isinstance(val, dict):
  95. return {k: _strip(v) for k, v in val.items()}
  96. if isinstance(val, list):
  97. return [_strip(v) for v in val]
  98. if isinstance(val, tuple):
  99. return tuple(_strip(v) for v in val)
  100. return val
  101. return statement, _strip(parameters)
  102. return eng
  103. engine = _create_engine()
  104. async_session = async_sessionmaker(
  105. engine,
  106. class_=AsyncSession,
  107. expire_on_commit=False,
  108. )
  109. def get_pool_status() -> dict:
  110. """Snapshot the DB connection pool for diagnostics (issue #2572).
  111. Returns the resolved configuration plus live gauges (checked-out /
  112. checked-in / overflow). Reads the pool's own counters — it does NOT
  113. check out a connection, so it stays truthful even when the pool is
  114. exhausted. Gauges a given pool implementation doesn't expose come back
  115. as ``None`` rather than raising.
  116. """
  117. pool = engine.sync_engine.pool
  118. gauges: dict = {}
  119. for key, method_name in (
  120. ("current_size", "size"),
  121. ("checked_out", "checkedout"),
  122. ("checked_in", "checkedin"),
  123. ("overflow", "overflow"),
  124. ):
  125. method = getattr(pool, method_name, None)
  126. try:
  127. gauges[key] = method() if callable(method) else None
  128. except Exception:
  129. # A gauge should never take down the diagnostics endpoint.
  130. gauges[key] = None
  131. return {
  132. "dialect": "sqlite" if is_sqlite() else "postgresql",
  133. "config": dict(_pool_config),
  134. # Both sides of the ceiling-vs-server comparison, so a support bundle
  135. # shows whether a TooManyConnectionsError was a misconfiguration or a
  136. # genuine leak. None on SQLite or if the startup probe couldn't run.
  137. "server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
  138. **gauges,
  139. }
  140. async def run_with_retry(fn, *, max_attempts: int = 3, label: str = ""):
  141. """Run an async DB operation with retry for SQLite 'database is locked' errors.
  142. ``fn`` is an async callable that receives an ``AsyncSession`` and performs
  143. the full query-mutate-commit cycle. On each retry a fresh session is used
  144. so there are no stale-object / expired-attribute issues after rollback.
  145. On PostgreSQL this calls ``fn`` once with no retry (Postgres uses row-level
  146. locking and doesn't suffer from single-writer contention).
  147. """
  148. if not is_sqlite():
  149. async with async_session() as db:
  150. return await fn(db)
  151. last_exc: OperationalError | None = None
  152. for attempt in range(1, max_attempts + 1):
  153. try:
  154. async with async_session() as db:
  155. return await fn(db)
  156. except OperationalError as exc:
  157. last_exc = exc
  158. if "database is locked" not in str(exc) or attempt == max_attempts:
  159. raise
  160. delay = 0.5 * attempt # 0.5s, 1.0s
  161. logger.warning(
  162. "SQLite locked%s (attempt %d/%d), retrying in %.1fs: %s",
  163. f" ({label})" if label else "",
  164. attempt,
  165. max_attempts,
  166. delay,
  167. exc,
  168. )
  169. await asyncio.sleep(delay)
  170. raise last_exc # unreachable, but keeps type checkers happy
  171. async def close_all_connections():
  172. """Close all database connections for backup/restore operations."""
  173. global engine
  174. await engine.dispose()
  175. async def reinitialize_database():
  176. """Reinitialize database connection after restore."""
  177. global engine, async_session
  178. engine = _create_engine()
  179. async_session = async_sessionmaker(
  180. engine,
  181. class_=AsyncSession,
  182. expire_on_commit=False,
  183. )
  184. class Base(DeclarativeBase):
  185. pass
  186. async def get_db() -> AsyncSession:
  187. async with async_session() as session:
  188. try:
  189. yield session
  190. await session.commit()
  191. except BaseException:
  192. # Catch BaseException (not just Exception) so CancelledError —
  193. # raised when Starlette's BaseHTTPMiddleware cancels the inner
  194. # task scope on client disconnect — also triggers rollback.
  195. # `asyncio.shield` keeps the rollback running to completion
  196. # even when the await itself gets cancelled, so the SQLite
  197. # write lock is released promptly instead of being held until
  198. # the connection is GC'd ages later (which was producing the
  199. # "database is locked" cascade in #1112's support package).
  200. try:
  201. await asyncio.shield(session.rollback())
  202. except BaseException: # noqa: BLE001 — rollback failure must not mask the original
  203. pass
  204. raise
  205. finally:
  206. try:
  207. await asyncio.shield(session.close())
  208. except BaseException: # noqa: BLE001 — close failure must not mask the original
  209. pass
  210. async def init_db():
  211. # Import models to register them with SQLAlchemy
  212. from backend.app.models import ( # noqa: F401
  213. active_print_spoolman,
  214. ams_history,
  215. ams_label,
  216. api_key,
  217. archive,
  218. auth_ephemeral,
  219. bug_report,
  220. color_catalog,
  221. external_link,
  222. filament,
  223. filament_sku_settings,
  224. finance,
  225. github_backup,
  226. group,
  227. kprofile_note,
  228. library,
  229. local_preset,
  230. location,
  231. long_lived_token,
  232. maintenance,
  233. notification,
  234. notification_template,
  235. oidc_provider,
  236. orca_base_cache,
  237. pending_upload,
  238. pipeline_run,
  239. print_batch,
  240. print_log,
  241. print_queue,
  242. printer,
  243. printer_sensor_history,
  244. project,
  245. project_bom,
  246. settings,
  247. shopping_list,
  248. slicer_pipeline,
  249. slot_preset,
  250. smart_plug,
  251. smart_plug_energy_snapshot,
  252. spool,
  253. spool_assignment,
  254. spool_catalog,
  255. spool_k_profile,
  256. spool_usage_history,
  257. spoolbuddy_device,
  258. spoolman_k_profile,
  259. spoolman_slot_assignment,
  260. user,
  261. user_email_pref,
  262. user_otp_code,
  263. user_totp,
  264. virtual_printer,
  265. )
  266. async with engine.begin() as conn:
  267. await conn.run_sync(Base.metadata.create_all)
  268. # Run migrations for new columns (SQLite doesn't auto-add columns)
  269. await run_migrations(conn)
  270. # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
  271. # that exist from before the encryption key was configured.
  272. # Runs on a fresh AsyncSession (NOT the run_migrations() connection) so it
  273. # doesn't share a transaction with the schema-DDL block above — required to
  274. # avoid SQLite "database is locked" contention on the WAL writer.
  275. await _migrate_encrypt_legacy_secrets()
  276. # Seed default notification templates
  277. await seed_notification_templates()
  278. # Seed default groups and migrate existing users
  279. await seed_default_groups()
  280. # Seed default catalog entries
  281. await seed_spool_catalog()
  282. await seed_color_catalog()
  283. await check_pool_fits_server()
  284. async def check_pool_fits_server() -> None:
  285. """Warn when the pool may ask PostgreSQL for more connections than it allows.
  286. ``pool_size + max_overflow`` is the most connections one worker process will
  287. ever open. If that exceeds what the server permits, the pool never reaches
  288. its own limit and so never queues: it goes straight to the server, which
  289. refuses with ``TooManyConnectionsError``. That surfaces wherever the next
  290. connection happened to be needed — in the reported case, halfway through a
  291. queue dispatch, which then left an expected-print registration and a dispatch
  292. claim behind (#2702 follow-up).
  293. The distinction is worth knowing when reading a log: SQLAlchemy's own
  294. ``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
  295. concurrency, or connections held too long), whereas asyncpg's
  296. ``TooManyConnectionsError`` means the pool's ceiling is above the server's.
  297. Not clamped, deliberately. Pool sizes are fixed when the engine is created,
  298. which happens at import — before any connection exists to ask the server
  299. with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
  300. so swapping the engine afterwards would leave stale references. The correct
  301. ceiling also depends on the worker count and on anything else sharing the
  302. server, neither of which Bambuddy can see. So this reports the mismatch with
  303. both numbers and the knobs to fix it, and leaves the choice to the operator.
  304. """
  305. global _server_connection_limits
  306. if is_sqlite():
  307. return
  308. from sqlalchemy import text
  309. in_use: int | None = None
  310. try:
  311. async with engine.connect() as conn:
  312. max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
  313. reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
  314. try:
  315. in_use = int(
  316. (
  317. await conn.execute(
  318. text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
  319. )
  320. ).scalar_one()
  321. )
  322. except Exception as exc:
  323. # `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
  324. # restricted role sees fewer rows. The count is a nice-to-have
  325. # for spotting other clients; the warning itself only needs the
  326. # two settings above, so losing it must not cost the warning.
  327. # Done last on purpose: a failed statement can abort the
  328. # transaction, and nothing else uses this connection after it.
  329. logger.debug("Could not count client backends: %s", exc)
  330. except Exception as exc:
  331. # A diagnostic must never be the reason startup fails. An older server
  332. # or a restricted role may refuse these.
  333. logger.debug("Could not read PostgreSQL connection limits: %s", exc)
  334. return
  335. available = max_conn - reserved
  336. ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
  337. _server_connection_limits = {
  338. "max_connections": max_conn,
  339. "superuser_reserved_connections": reserved,
  340. "available_to_bambuddy": available,
  341. "client_backends_at_startup": in_use,
  342. "pool_ceiling_per_worker": ceiling,
  343. }
  344. if ceiling > available:
  345. in_use_note = (
  346. f" {in_use} client connection(s) are open on the server right now, including "
  347. "this one — a count well above 1 means something else shares it."
  348. if in_use is not None
  349. else ""
  350. )
  351. logger.warning(
  352. "DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
  353. "connections (pool_size %d + max_overflow %d) but the server permits %d "
  354. "(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
  355. "as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
  356. "Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
  357. "max_connections — and account for every worker process and any other "
  358. "client sharing this server.",
  359. ceiling,
  360. _pool_config.get("pool_size", 0),
  361. _pool_config.get("max_overflow", 0),
  362. available,
  363. max_conn,
  364. reserved,
  365. in_use_note,
  366. )
  367. else:
  368. logger.info(
  369. "DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
  370. ceiling,
  371. available,
  372. max_conn,
  373. )
  374. # B2: Module-level counter exposing the number of rows skipped during the last
  375. # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
  376. # (migration_error_count) so operators can spot poison rows that need attention.
  377. _migration_error_count: int = 0
  378. def get_migration_error_count() -> int:
  379. """Return the number of rows that failed to re-encrypt during the last
  380. _migrate_encrypt_legacy_secrets() run."""
  381. return _migration_error_count
  382. async def _migrate_encrypt_legacy_secrets() -> None:
  383. """Re-encrypt OIDC ``client_secret`` and TOTP ``secret`` rows that are still
  384. stored as plaintext (no ``fernet:`` prefix).
  385. Called from :func:`init_db` after :func:`run_migrations` finishes. No-ops
  386. when no encryption key is configured (so plaintext storage stays the
  387. legacy behaviour for installs without a key).
  388. B2: per-row strategy — each row is committed in its own AsyncSession so a
  389. single corrupt row does NOT block other successful re-encryptions on every
  390. startup forever. The skipped-row count is exposed via
  391. :func:`get_migration_error_count` and surfaced on /encryption-status.
  392. B3: unexpected (non-row) failures during the read phase are re-raised so
  393. operators see the problem instead of silent data corruption — startup
  394. fails loudly rather than running with half-migrated rows.
  395. Idempotent: rows that already start with ``fernet:`` are skipped, and the
  396. write-phase re-checks the prefix before encrypting (guards against double
  397. encryption from concurrent workers).
  398. """
  399. from sqlalchemy import not_, select
  400. from backend.app.core.encryption import is_encryption_active
  401. from backend.app.models.oidc_provider import OIDCProvider
  402. from backend.app.models.user_totp import UserTOTP
  403. global _migration_error_count
  404. if not is_encryption_active():
  405. # Reset stale counter from a previous active-key run — we no longer
  406. # have any rows to migrate, so the count must not leak across runs.
  407. _migration_error_count = 0
  408. return
  409. # Phase 1 (read): collect (id, stored_value) tuples for plaintext rows.
  410. # Read phase failures are startup-fatal — re-raise (B3).
  411. try:
  412. async with async_session() as ro:
  413. oidc_rows = await ro.execute(
  414. select(OIDCProvider.id, OIDCProvider._client_secret_enc).where(
  415. not_(OIDCProvider._client_secret_enc.like("fernet:%"))
  416. )
  417. )
  418. oidc_candidates = [(r[0], r[1]) for r in oidc_rows.all()]
  419. totp_rows = await ro.execute(
  420. select(UserTOTP.id, UserTOTP._secret_enc).where(not_(UserTOTP._secret_enc.like("fernet:%")))
  421. )
  422. totp_candidates = [(r[0], r[1]) for r in totp_rows.all()]
  423. except Exception:
  424. logger.error("_migrate_encrypt_legacy_secrets: phase 1 read failed", exc_info=True)
  425. raise # B3
  426. oidc_count = totp_count = error_count = 0
  427. # Phase 2 (write): each row in its own AsyncSession + transaction.
  428. # Failure of one row does NOT block the others.
  429. for oidc_id, stored in oidc_candidates:
  430. if not stored:
  431. continue # defensive: skip empty strings
  432. try:
  433. async with async_session() as wr:
  434. provider = await wr.get(OIDCProvider, oidc_id)
  435. if provider is None:
  436. continue # row deleted between phase 1 and phase 2
  437. # Idempotent guard: re-check inside the write session in case a
  438. # concurrent worker beat us to it.
  439. if not provider._client_secret_enc.startswith("fernet:"):
  440. provider.client_secret = stored # setter -> mfa_encrypt
  441. await wr.commit()
  442. oidc_count += 1
  443. except Exception:
  444. logger.error(
  445. "Failed to re-encrypt OIDCProvider id=%s — skipping",
  446. oidc_id,
  447. exc_info=True,
  448. )
  449. error_count += 1
  450. for totp_id, stored in totp_candidates:
  451. if not stored:
  452. continue
  453. try:
  454. async with async_session() as wr:
  455. totp = await wr.get(UserTOTP, totp_id)
  456. if totp is None:
  457. continue
  458. if not totp._secret_enc.startswith("fernet:"):
  459. totp.secret = stored
  460. await wr.commit()
  461. totp_count += 1
  462. except Exception:
  463. logger.error(
  464. "Failed to re-encrypt UserTOTP id=%s — skipping",
  465. totp_id,
  466. exc_info=True,
  467. )
  468. error_count += 1
  469. _migration_error_count = error_count
  470. if oidc_count or totp_count:
  471. logger.info(
  472. "Re-encrypted legacy plaintext secrets: %d OIDC client_secret(s), %d TOTP secret(s)",
  473. oidc_count,
  474. totp_count,
  475. )
  476. elif error_count == 0:
  477. logger.debug("_migrate_encrypt_legacy_secrets: no rows needed re-encryption")
  478. if error_count:
  479. logger.error(
  480. "_migrate_encrypt_legacy_secrets: %d row(s) skipped due to errors. "
  481. "See /api/v1/auth/encryption-status (migration_error_count).",
  482. error_count,
  483. )
  484. async def _safe_execute(conn, sql):
  485. """Execute a DDL migration statement, silently ignoring idempotency errors.
  486. 'already exists', 'duplicate column name' (SQLite ADD COLUMN), 'no such column'
  487. (SQLite RENAME COLUMN), 'duplicate key', and the compound
  488. 'column … does not exist' (PostgreSQL RENAME COLUMN idempotency) are swallowed
  489. so that re-running DDL migrations is safe. The compound check additionally
  490. requires the SQL to be a RENAME COLUMN statement so that "does not exist" errors
  491. from ADD COLUMN or CREATE INDEX (which would indicate schema corruption, not
  492. idempotency) are never silently swallowed.
  493. Any other error is logged and re-raised — callers must not assume silent
  494. recovery, as a failure will abort the migration sequence and prevent
  495. application startup.
  496. Only use for DDL statements (ALTER TABLE, CREATE INDEX, etc.).
  497. For DML backfills (UPDATE, DELETE) use conn.execute() directly inside
  498. async with conn.begin_nested() so failures are never silently swallowed.
  499. Uses a savepoint so that a failed statement doesn't poison the surrounding
  500. transaction (required for PostgreSQL).
  501. """
  502. from sqlalchemy import text
  503. try:
  504. async with conn.begin_nested():
  505. await conn.execute(text(sql))
  506. except (OperationalError, ProgrammingError) as exc:
  507. msg = str(exc).lower()
  508. # Only swallow "column … does not exist" for RENAME COLUMN — not for ADD COLUMN
  509. # or CREATE INDEX where it would indicate schema corruption, not idempotency.
  510. column_not_exists = "rename column" in sql.lower() and "column" in msg and "does not exist" in msg
  511. if (
  512. not any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column"))
  513. and not column_not_exists
  514. ):
  515. logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
  516. raise
  517. async def _api_keys_column_exists(conn, column_name: str) -> bool:
  518. """Return True if the named column exists on ``api_keys``.
  519. Used to gate one-shot data backfills that must run only on the migration
  520. that adds a column — without this, repeating the UPDATE on every startup
  521. would silently overwrite values the user later edited in the UI.
  522. Dialect-specific because SQLite has no information_schema.
  523. """
  524. from sqlalchemy import text
  525. if is_sqlite():
  526. result = await conn.execute(text("PRAGMA table_info(api_keys)"))
  527. return any(row[1] == column_name for row in result)
  528. result = await conn.execute(
  529. text("SELECT 1 FROM information_schema.columns WHERE table_name = 'api_keys' AND column_name = :col"),
  530. {"col": column_name},
  531. )
  532. return result.scalar_one_or_none() is not None
  533. async def _migrate_normalize_printer_ids(conn) -> None:
  534. from sqlalchemy import text
  535. async with conn.begin_nested():
  536. if is_sqlite():
  537. await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids = '[]'"))
  538. else:
  539. await conn.execute(text("UPDATE api_keys SET printer_ids = NULL WHERE printer_ids::text = '[]'"))
  540. async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
  541. """Re-scope queue items that carry another plate's filament overrides (#2551).
  542. Queueing several plates of one 3MF used to store the union of every selected
  543. plate's overrides on each item, so a ``force_color_match`` plate printing one
  544. colour sat at Waiting until a printer had the whole batch's palette loaded.
  545. The write paths now narrow to the plate, but items queued before the fix would
  546. stay stuck until the user deleted and re-added them by hand — with a waiting
  547. reason that gives no hint as to why. Repair them here instead.
  548. Only pending items are touched: a printing or finished item's overrides are a
  549. record of what it dispatched with, not an instruction. An item whose plate we
  550. cannot read keeps every override, per ``overrides_for_plate``. Idempotent —
  551. an already-scoped item narrows to itself and is not rewritten.
  552. """
  553. import json
  554. from pathlib import Path
  555. from sqlalchemy import text
  556. from backend.app.services.filament_requirements import overrides_for_plate
  557. rows = (
  558. await conn.execute(
  559. text(
  560. "SELECT q.id, q.plate_id, q.filament_overrides, "
  561. "a.file_path AS archive_path, l.file_path AS library_path "
  562. "FROM print_queue q "
  563. "LEFT JOIN print_archives a ON a.id = q.archive_id "
  564. "LEFT JOIN library_files l ON l.id = q.library_file_id "
  565. "WHERE q.status = 'pending' "
  566. "AND q.plate_id IS NOT NULL "
  567. "AND q.filament_overrides IS NOT NULL"
  568. )
  569. )
  570. ).fetchall()
  571. repaired = 0
  572. for row in rows:
  573. try:
  574. overrides = json.loads(row.filament_overrides)
  575. except (json.JSONDecodeError, TypeError):
  576. continue
  577. if not isinstance(overrides, list) or not overrides:
  578. continue
  579. stored_path = row.archive_path or row.library_path
  580. if not stored_path:
  581. continue
  582. path = Path(stored_path)
  583. if not path.is_absolute():
  584. path = settings.base_dir / stored_path
  585. scoped = overrides_for_plate(overrides, path, row.plate_id)
  586. if len(scoped) == len(overrides):
  587. continue
  588. async with conn.begin_nested():
  589. await conn.execute(
  590. text("UPDATE print_queue SET filament_overrides = :overrides WHERE id = :id"),
  591. {"overrides": json.dumps(scoped) if scoped else None, "id": row.id},
  592. )
  593. repaired += 1
  594. if repaired:
  595. logger.info(
  596. "Re-scoped the filament overrides of %d queued item(s) to the plate they print (#2551)",
  597. repaired,
  598. )
  599. async def _migrate_scope_run_filament_to_plate(conn) -> None:
  600. """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
  601. filament (and cost) instead of the printed plate's (#2614).
  602. When the AMS tracker measured nothing for a completed run, the per-run filament
  603. fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
  604. the source 3MF (right for the archive card / project rollup, wrong for one
  605. printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
  606. inflating lifetime / user / project / filament stats by the plate count. The
  607. forward fix scopes new rows; this repairs the rows already written.
  608. Only completed rows whose stored grams EXACTLY equal the archive's whole-file
  609. value are touched — that is the mis-copy signature. Tracker-measured rows (a
  610. rounded spool-delta sum) and partial-progress rows (scaled to progress) never
  611. match, so they are never clobbered. Cost is scaled by the plate's share of the
  612. whole so it stays consistent with the corrected grams. Runs AFTER the #2603
  613. archive plate_id backfill so ``print_archives.plate_id`` is populated.
  614. Gated to run **exactly once** via a settings flag. This is not merely for
  615. idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
  616. always sends one), and for it the plate estimate legitimately equals the
  617. whole-file value — so those rows match the signature on every boot. Without
  618. the one-shot gate we would re-parse every single-plate 3MF on the print log at
  619. each startup, a cost that grows without bound with print history. One pass is
  620. enough: the forward fix keeps all new rows correct.
  621. """
  622. from pathlib import Path
  623. from sqlalchemy import text
  624. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  625. flag = "_backfill_2614_plate_filament_done"
  626. async with conn.begin_nested():
  627. already = (
  628. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  629. ).scalar_one_or_none()
  630. if already:
  631. return
  632. rows = (
  633. await conn.execute(
  634. text(
  635. "SELECT ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
  636. "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
  637. "FROM print_log_entries ple "
  638. "JOIN print_archives a ON a.id = ple.archive_id "
  639. "WHERE ple.status = 'completed' "
  640. "AND a.plate_id IS NOT NULL "
  641. "AND a.file_path IS NOT NULL "
  642. "AND a.filament_used_grams IS NOT NULL "
  643. "AND ple.filament_used_grams IS NOT NULL "
  644. "AND ple.filament_used_grams = a.filament_used_grams"
  645. )
  646. )
  647. ).fetchall()
  648. corrected = 0
  649. grams_removed = 0.0
  650. for row in rows:
  651. path = Path(row.file_path)
  652. if not path.is_absolute():
  653. path = settings.base_dir / row.file_path
  654. if not path.exists():
  655. continue
  656. try:
  657. plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
  658. except Exception as exc:
  659. logger.warning(
  660. "[#2614] could not read plate %s of %s for log entry %s: %s",
  661. row.plate_id,
  662. row.file_path,
  663. row.entry_id,
  664. exc,
  665. )
  666. continue
  667. if not plate_grams or plate_grams <= 0:
  668. continue
  669. new_grams = round(plate_grams, 2)
  670. if abs(new_grams - (row.grams or 0)) < 0.01:
  671. continue # nothing to change (e.g. a genuine single-plate file)
  672. new_cost = row.cost
  673. whole = row.whole_grams or 0
  674. if row.cost and whole > 0:
  675. new_cost = round(row.cost * (plate_grams / whole), 2)
  676. await conn.execute(
  677. text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
  678. {"g": new_grams, "c": new_cost, "id": row.entry_id},
  679. )
  680. corrected += 1
  681. grams_removed += (row.grams or 0) - new_grams
  682. if corrected:
  683. logger.info(
  684. "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
  685. "(removed %.0f g of over-counted usage from statistics)",
  686. corrected,
  687. grams_removed,
  688. )
  689. # Mark done unconditionally (even when nothing matched) so this one-shot
  690. # never re-scans the print log on subsequent boots. id/timestamps come
  691. # from the table's own defaults; "key" is quoted as it's a keyword.
  692. await conn.execute(
  693. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  694. {"k": flag, "v": "true"},
  695. )
  696. async def _migrate_drop_library_print_name(conn) -> None:
  697. """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
  698. Library files stored the 3MF's ``<metadata name="Title">`` as
  699. ``file_metadata.print_name`` — generic ("Exported 3D Model") for Bambu
  700. Studio exports, a marketing title for MakerWorld downloads — and the
  701. FileManager wrongly preferred it over the filename for the card label,
  702. search and sort. New imports no longer store it; this clears it from rows
  703. imported before the fix so existing libraries don't need a rename
  704. round-trip. Idempotent — rows without the key are untouched.
  705. """
  706. from sqlalchemy import text
  707. async with conn.begin_nested():
  708. if is_sqlite():
  709. await conn.execute(
  710. text(
  711. "UPDATE library_files SET file_metadata = json_remove(file_metadata, '$.print_name') "
  712. "WHERE json_extract(file_metadata, '$.print_name') IS NOT NULL"
  713. )
  714. )
  715. else:
  716. # file_metadata is a JSON (not JSONB) column — cast to jsonb for the
  717. # key-exists test (jsonb_exists, avoiding the `?` operator which
  718. # clashes with driver parameter syntax) and the `- key` removal.
  719. await conn.execute(
  720. text(
  721. "UPDATE library_files SET file_metadata = (file_metadata::jsonb - 'print_name')::json "
  722. "WHERE jsonb_exists(file_metadata::jsonb, 'print_name')"
  723. )
  724. )
  725. async def _migrate_update_auto_link_constraint(conn) -> None:
  726. """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
  727. Old formula: auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')
  728. New formula: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
  729. Only Fall B (email_claim='email' + require_ev=False) remains blocked.
  730. Fall C (custom claim, e.g. Azure preferred_username/upn) is now allowed.
  731. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute (idempotent).
  732. SQLite: table recreation when old formula is detected in sqlite_master (idempotent).
  733. """
  734. from sqlalchemy import text
  735. _NEW_FORMULA = "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE"
  736. _CONSTRAINT_NAME = "ck_auto_link_requires_verified_email_claim"
  737. if not is_sqlite():
  738. await _safe_execute(conn, f"ALTER TABLE oidc_providers DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}")
  739. await _safe_execute(
  740. conn,
  741. f"ALTER TABLE oidc_providers ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  742. )
  743. else:
  744. row = (
  745. await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
  746. ).fetchone()
  747. # Only recreate if the old (more restrictive) formula is still present.
  748. # Fresh installs created with the new __table_args__ already have the correct formula.
  749. # Installs without any constraint (pre-SEC-1 upgrades) are skipped — app-level guards suffice.
  750. if row and "require_email_verified = TRUE AND email_claim = 'email'" in row[0]:
  751. try:
  752. async with conn.begin_nested():
  753. await conn.execute(text("DROP TABLE IF EXISTS oidc_providers_v2"))
  754. await conn.execute(
  755. text(
  756. "CREATE TABLE oidc_providers_v2 ("
  757. "id INTEGER NOT NULL, "
  758. "name VARCHAR(100) NOT NULL, "
  759. "issuer_url VARCHAR(500) NOT NULL, "
  760. "client_id VARCHAR(255) NOT NULL, "
  761. "client_secret VARCHAR(512) NOT NULL, "
  762. "scopes VARCHAR(500), "
  763. "is_enabled BOOLEAN, "
  764. "auto_create_users BOOLEAN, "
  765. "auto_link_existing_accounts BOOLEAN DEFAULT 0, "
  766. "email_claim VARCHAR(64) DEFAULT 'email', "
  767. "require_email_verified BOOLEAN DEFAULT 1, "
  768. "icon_url TEXT, "
  769. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  770. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  771. "PRIMARY KEY (id), "
  772. f"UNIQUE (name), "
  773. f"CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})"
  774. ")"
  775. )
  776. )
  777. await conn.execute(
  778. text(
  779. "INSERT INTO oidc_providers_v2 "
  780. "(id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  781. "auto_create_users, auto_link_existing_accounts, email_claim, "
  782. "require_email_verified, icon_url, created_at, updated_at) "
  783. "SELECT id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  784. "auto_create_users, auto_link_existing_accounts, email_claim, "
  785. "require_email_verified, icon_url, created_at, updated_at "
  786. "FROM oidc_providers"
  787. )
  788. )
  789. original = (await conn.execute(text("SELECT count(*) FROM oidc_providers"))).scalar_one()
  790. copied = (await conn.execute(text("SELECT count(*) FROM oidc_providers_v2"))).scalar_one()
  791. if copied != original:
  792. raise RuntimeError(
  793. f"auto_link constraint migration: row count mismatch after copy "
  794. f"({original} in source, {copied} in copy)"
  795. )
  796. await conn.execute(text("DROP TABLE oidc_providers"))
  797. await conn.execute(text("ALTER TABLE oidc_providers_v2 RENAME TO oidc_providers"))
  798. except Exception as exc:
  799. logger.error(
  800. "auto_link constraint update (SQLite table recreation) FAILED: %s",
  801. exc,
  802. exc_info=True,
  803. )
  804. raise
  805. async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
  806. """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
  807. Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
  808. New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
  809. The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
  810. AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
  811. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
  812. SQLite: table recreation when the old (narrower) formula is detected in
  813. sqlite_master. Fresh installs already have the widened constraint from
  814. the CREATE TABLE migration above.
  815. """
  816. from sqlalchemy import text
  817. _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
  818. _CONSTRAINT_NAME = "ck_ams_id_range"
  819. if not is_sqlite():
  820. await _safe_execute(
  821. conn,
  822. f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
  823. )
  824. await _safe_execute(
  825. conn,
  826. f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  827. )
  828. return
  829. row = (
  830. await conn.execute(
  831. text("SELECT sql FROM sqlite_master WHERE type='table' AND name='spoolman_slot_assignments'")
  832. )
  833. ).fetchone()
  834. if not row:
  835. return
  836. sql = row[0] or ""
  837. # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
  838. if "ams_id >= 128" in sql:
  839. return
  840. # Pre-migration table without any CHECK constraint at all → leave alone;
  841. # the app-level validation handles correctness and we don't risk a
  842. # destructive table rebuild for a constraint that isn't blocking anyone.
  843. if "ck_ams_id_range" not in sql and "ams_id <= 7" not in sql:
  844. return
  845. try:
  846. async with conn.begin_nested():
  847. await conn.execute(text("DROP TABLE IF EXISTS spoolman_slot_assignments_v2"))
  848. await conn.execute(
  849. text(
  850. "CREATE TABLE spoolman_slot_assignments_v2 ("
  851. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  852. "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
  853. f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
  854. "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
  855. "spoolman_spool_id INTEGER NOT NULL, "
  856. "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  857. "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
  858. ")"
  859. )
  860. )
  861. await conn.execute(
  862. text(
  863. "INSERT INTO spoolman_slot_assignments_v2 "
  864. "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
  865. "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
  866. "FROM spoolman_slot_assignments"
  867. )
  868. )
  869. original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
  870. copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
  871. if copied != original:
  872. raise RuntimeError(
  873. f"spoolman_slot_assignments migration: row count mismatch after copy "
  874. f"({original} in source, {copied} in copy)"
  875. )
  876. await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
  877. await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
  878. # The index sits on the renamed table; recreate it idempotently
  879. # to handle older sqlite versions that don't auto-rename indexes.
  880. await conn.execute(
  881. text(
  882. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool "
  883. "ON spoolman_slot_assignments (spoolman_spool_id)"
  884. )
  885. )
  886. except Exception as exc:
  887. logger.error(
  888. "spoolman_slot_assignments ck_ams_id_range widening (SQLite table recreation) FAILED: %s",
  889. exc,
  890. exc_info=True,
  891. )
  892. raise
  893. async def _migrate_create_finance_tables(conn) -> None:
  894. """Create finance tables missing from databases that predate billing.
  895. ``Base.metadata.create_all()`` covers fresh installs, but upgrade and
  896. restore paths can run the handwritten migrations against an existing
  897. PostgreSQL schema. The finance column migrations below must therefore not
  898. assume these tables already exist.
  899. ``UserWallet`` is mapped to ``user_wallets``. The invitation table is kept
  900. as a compatibility table for pre-release billing databases; current code
  901. does not map or query it.
  902. """
  903. if is_sqlite():
  904. statements = [
  905. """
  906. CREATE TABLE IF NOT EXISTS cost_centers (
  907. id INTEGER PRIMARY KEY,
  908. code VARCHAR(32) NOT NULL UNIQUE,
  909. name VARCHAR(150) NOT NULL,
  910. is_active BOOLEAN NOT NULL DEFAULT 1,
  911. is_private BOOLEAN NOT NULL DEFAULT 0,
  912. owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  913. total_budget FLOAT,
  914. monthly_budget FLOAT,
  915. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  916. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  917. )
  918. """,
  919. """
  920. CREATE TABLE IF NOT EXISTS user_wallets (
  921. id INTEGER PRIMARY KEY,
  922. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  923. balance FLOAT NOT NULL DEFAULT 0.0,
  924. currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
  925. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  926. )
  927. """,
  928. """
  929. CREATE TABLE IF NOT EXISTS cost_center_members (
  930. id INTEGER PRIMARY KEY,
  931. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  932. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  933. can_print BOOLEAN NOT NULL DEFAULT 1,
  934. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  935. CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
  936. )
  937. """,
  938. """
  939. CREATE TABLE IF NOT EXISTS cost_center_invitations (
  940. id INTEGER PRIMARY KEY,
  941. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  942. email VARCHAR(320) NOT NULL,
  943. token VARCHAR(255) NOT NULL UNIQUE,
  944. invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  945. expires_at DATETIME,
  946. accepted_at DATETIME,
  947. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  948. )
  949. """,
  950. """
  951. CREATE TABLE IF NOT EXISTS wallet_transactions (
  952. id INTEGER PRIMARY KEY,
  953. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  954. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  955. transaction_type VARCHAR(40) NOT NULL,
  956. amount FLOAT NOT NULL,
  957. balance_after FLOAT,
  958. description TEXT,
  959. created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  960. print_run_id VARCHAR(100),
  961. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  962. print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
  963. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  964. CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
  965. transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
  966. )
  967. )
  968. """,
  969. """
  970. CREATE TABLE IF NOT EXISTS budget_reservations (
  971. id INTEGER PRIMARY KEY,
  972. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  973. amount FLOAT NOT NULL,
  974. status VARCHAR(20) NOT NULL,
  975. source_type VARCHAR(50) NOT NULL,
  976. source_id INTEGER,
  977. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  978. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  979. released_at DATETIME
  980. )
  981. """,
  982. ]
  983. else:
  984. statements = [
  985. """
  986. CREATE TABLE IF NOT EXISTS cost_centers (
  987. id SERIAL PRIMARY KEY,
  988. code VARCHAR(32) NOT NULL UNIQUE,
  989. name VARCHAR(150) NOT NULL,
  990. is_active BOOLEAN NOT NULL DEFAULT TRUE,
  991. is_private BOOLEAN NOT NULL DEFAULT FALSE,
  992. owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  993. total_budget FLOAT,
  994. monthly_budget FLOAT,
  995. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  996. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  997. )
  998. """,
  999. """
  1000. CREATE TABLE IF NOT EXISTS user_wallets (
  1001. id SERIAL PRIMARY KEY,
  1002. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  1003. balance FLOAT NOT NULL DEFAULT 0.0,
  1004. currency VARCHAR(3) NOT NULL DEFAULT 'EUR',
  1005. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  1006. )
  1007. """,
  1008. """
  1009. CREATE TABLE IF NOT EXISTS cost_center_members (
  1010. id SERIAL PRIMARY KEY,
  1011. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1012. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  1013. can_print BOOLEAN NOT NULL DEFAULT TRUE,
  1014. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1015. CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
  1016. )
  1017. """,
  1018. """
  1019. CREATE TABLE IF NOT EXISTS cost_center_invitations (
  1020. id SERIAL PRIMARY KEY,
  1021. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1022. email VARCHAR(320) NOT NULL,
  1023. token VARCHAR(255) NOT NULL UNIQUE,
  1024. invited_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1025. expires_at TIMESTAMP,
  1026. accepted_at TIMESTAMP,
  1027. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  1028. )
  1029. """,
  1030. """
  1031. CREATE TABLE IF NOT EXISTS wallet_transactions (
  1032. id SERIAL PRIMARY KEY,
  1033. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  1034. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  1035. transaction_type VARCHAR(40) NOT NULL,
  1036. amount FLOAT NOT NULL,
  1037. balance_after FLOAT,
  1038. description TEXT,
  1039. created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1040. print_run_id VARCHAR(100),
  1041. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1042. print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
  1043. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1044. CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
  1045. transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
  1046. )
  1047. )
  1048. """,
  1049. """
  1050. CREATE TABLE IF NOT EXISTS budget_reservations (
  1051. id SERIAL PRIMARY KEY,
  1052. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1053. amount FLOAT NOT NULL,
  1054. status VARCHAR(20) NOT NULL,
  1055. source_type VARCHAR(50) NOT NULL,
  1056. source_id INTEGER,
  1057. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1058. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1059. released_at TIMESTAMP
  1060. )
  1061. """,
  1062. ]
  1063. for statement in statements:
  1064. await _safe_execute(conn, statement)
  1065. async def _migrate_create_finance_indexes(conn) -> None:
  1066. """Create finance indexes after legacy tables have received new columns."""
  1067. indexes = [
  1068. "CREATE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
  1069. "CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
  1070. "CREATE UNIQUE INDEX IF NOT EXISTS ix_user_wallets_user_id ON user_wallets (user_id)",
  1071. "CREATE INDEX IF NOT EXISTS ix_cost_center_members_cost_center_id ON cost_center_members (cost_center_id)",
  1072. "CREATE INDEX IF NOT EXISTS ix_cost_center_members_user_id ON cost_center_members (user_id)",
  1073. "CREATE INDEX IF NOT EXISTS ix_cost_center_invitations_cost_center_id "
  1074. "ON cost_center_invitations (cost_center_id)",
  1075. "CREATE INDEX IF NOT EXISTS ix_cost_center_invitations_email ON cost_center_invitations (email)",
  1076. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_user_id ON wallet_transactions (user_id)",
  1077. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_cost_center_id ON wallet_transactions (cost_center_id)",
  1078. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_transaction_type "
  1079. "ON wallet_transactions (transaction_type)",
  1080. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_by_user_id "
  1081. "ON wallet_transactions (created_by_user_id)",
  1082. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_run_id ON wallet_transactions (print_run_id)",
  1083. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_archive_id "
  1084. "ON wallet_transactions (print_archive_id)",
  1085. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_queue_id ON wallet_transactions (print_queue_id)",
  1086. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_at ON wallet_transactions (created_at)",
  1087. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id "
  1088. "ON budget_reservations (cost_center_id)",
  1089. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)",
  1090. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)",
  1091. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)",
  1092. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id "
  1093. "ON budget_reservations (print_archive_id)",
  1094. ]
  1095. for statement in indexes:
  1096. await _safe_execute(conn, statement)
  1097. async def run_migrations(conn):
  1098. """Run all schema migrations and data backfills on startup.
  1099. Includes ALTER TABLE (add columns, rename columns, add constraints),
  1100. CREATE INDEX, CREATE TRIGGER, data UPDATE backfills, and table recreations
  1101. for complex SQLite schema changes that ALTER TABLE cannot handle.
  1102. DDL statements are wrapped in _safe_execute for idempotency.
  1103. DML backfills (UPDATE/DELETE) are executed directly via conn.execute()
  1104. inside begin_nested() so any failure is always fatal and never silently
  1105. swallowed.
  1106. """
  1107. from sqlalchemy import text
  1108. # Existing PostgreSQL databases predate the finance ORM tables. These must
  1109. # exist before any ALTER TABLE / CREATE INDEX statements below reference
  1110. # them. Fresh installs remain idempotent because create_all() runs first.
  1111. await _migrate_create_finance_tables(conn)
  1112. # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
  1113. # Links a retry-failed run back to its parent so the dashboard can show
  1114. # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
  1115. await _safe_execute(
  1116. conn,
  1117. "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
  1118. )
  1119. # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
  1120. # Allows a pipeline run to source from an archive's source 3MF in addition
  1121. # to a library file. Idempotent — _safe_execute swallows the "already exists"
  1122. # case on both SQLite and Postgres.
  1123. await _safe_execute(
  1124. conn,
  1125. "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
  1126. )
  1127. # Migration: Add is_favorite column to print_archives
  1128. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
  1129. # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
  1130. if is_sqlite():
  1131. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")
  1132. else:
  1133. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT FALSE")
  1134. # Migration: Add content_hash column to print_archives for duplicate detection
  1135. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
  1136. # Migration: Add auto_off_executed column to smart_plugs
  1137. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0")
  1138. # Migration: Add on_print_stopped column to notification_providers
  1139. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1")
  1140. # Migration: Add source_3mf_path column to print_archives
  1141. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)")
  1142. # Migration: Add f3d_path column to print_archives for Fusion 360 design files
  1143. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
  1144. # Migration: Add plate_id column to print_archives (#2603). The selected plate
  1145. # of a multi-plate 3MF is copied from the queue item at dispatch so Print
  1146. # History can show the actual plate instead of falling back to Plate 1.
  1147. # Nullable, no default — identical DDL on SQLite and Postgres. Backfilled from
  1148. # linked queue rows below.
  1149. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_id INTEGER")
  1150. # Migration: Add on_maintenance_due column to notification_providers
  1151. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
  1152. # Migration: Add location column to printers for grouping
  1153. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN location VARCHAR(100)")
  1154. # Migration: Add interval_type column to maintenance_types
  1155. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'")
  1156. # Migration: Add is_deleted column to maintenance_types for soft-deletes
  1157. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
  1158. # Migration: Add cost_center columns expected by current finance model
  1159. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
  1160. if is_sqlite():
  1161. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
  1162. else:
  1163. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
  1164. await _safe_execute(
  1165. conn,
  1166. "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
  1167. )
  1168. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget FLOAT")
  1169. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget FLOAT")
  1170. timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
  1171. await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
  1172. await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
  1173. # Backfill empty cost center codes on upgraded databases.
  1174. if is_sqlite():
  1175. await _safe_execute(
  1176. conn,
  1177. "UPDATE cost_centers SET code = lower(hex(randomblob(6))) WHERE code IS NULL OR trim(code) = ''",
  1178. )
  1179. else:
  1180. await _safe_execute(
  1181. conn,
  1182. "UPDATE cost_centers SET code = substr(md5(random()::text || clock_timestamp()::text), 1, 12) "
  1183. "WHERE code IS NULL OR btrim(code) = ''",
  1184. )
  1185. # Migration: Add custom_interval_type column to printer_maintenance
  1186. await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
  1187. # Migration: Add power alert columns to smart_plugs
  1188. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0")
  1189. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL")
  1190. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL")
  1191. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME")
  1192. # Migration: Add schedule columns to smart_plugs
  1193. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0")
  1194. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)")
  1195. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)")
  1196. # Migration: Add daily digest columns to notification_providers
  1197. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")
  1198. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
  1199. # Migration: Add print_run_id to wallet_transactions so repeated prints of the same archive
  1200. # can be billed independently without mutating archive history.
  1201. await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN print_run_id VARCHAR(100)")
  1202. # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
  1203. # Delay indexes until every legacy column they reference has been added.
  1204. await _migrate_create_finance_indexes(conn)
  1205. # Migration: Add missing-spool-assignment print-start notification toggle
  1206. try:
  1207. async with conn.begin_nested():
  1208. await conn.execute(
  1209. text(
  1210. "ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0"
  1211. )
  1212. )
  1213. except (OperationalError, ProgrammingError):
  1214. pass # Already applied
  1215. # Migration: Add project_id column to print_archives
  1216. try:
  1217. async with conn.begin_nested():
  1218. await conn.execute(
  1219. text(
  1220. "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  1221. )
  1222. )
  1223. except (OperationalError, ProgrammingError):
  1224. pass # Already applied
  1225. # Migration: Add project_id column to print_queue
  1226. try:
  1227. async with conn.begin_nested():
  1228. await conn.execute(
  1229. text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  1230. )
  1231. except (OperationalError, ProgrammingError):
  1232. pass # Already applied
  1233. # Migration: Enforce uniqueness on user_oidc_links for existing rows.
  1234. # create_all() is idempotent and does not add constraints to existing tables,
  1235. # so we create covering unique indexes explicitly here.
  1236. await _safe_execute(
  1237. conn,
  1238. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub"
  1239. " ON user_oidc_links (provider_id, provider_user_id)",
  1240. )
  1241. await _safe_execute(
  1242. conn,
  1243. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
  1244. )
  1245. # Migration: Add unique indexes to prevent duplicate print-charge transactions
  1246. await _safe_execute(
  1247. conn,
  1248. "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_print_run ON wallet_transactions (transaction_type, print_run_id)",
  1249. )
  1250. await _safe_execute(
  1251. conn,
  1252. "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive ON wallet_transactions (transaction_type, print_archive_id)",
  1253. )
  1254. # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
  1255. # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
  1256. if is_sqlite():
  1257. try:
  1258. await conn.execute(
  1259. text("""
  1260. CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
  1261. print_name,
  1262. filename,
  1263. tags,
  1264. notes,
  1265. designer,
  1266. filament_type,
  1267. content='print_archives',
  1268. content_rowid='id'
  1269. )
  1270. """)
  1271. )
  1272. except (OperationalError, ProgrammingError):
  1273. pass # Already applied
  1274. # Migration: Create triggers to keep FTS index in sync
  1275. try:
  1276. await conn.execute(
  1277. text("""
  1278. CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
  1279. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  1280. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  1281. END
  1282. """)
  1283. )
  1284. except (OperationalError, ProgrammingError):
  1285. pass # Already applied
  1286. try:
  1287. await conn.execute(
  1288. text("""
  1289. CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
  1290. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  1291. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  1292. END
  1293. """)
  1294. )
  1295. except (OperationalError, ProgrammingError):
  1296. pass # Already applied
  1297. try:
  1298. await conn.execute(
  1299. text("""
  1300. CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
  1301. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  1302. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  1303. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  1304. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  1305. END
  1306. """)
  1307. )
  1308. except (OperationalError, ProgrammingError):
  1309. pass # Already applied
  1310. # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
  1311. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0")
  1312. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME")
  1313. # Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)
  1314. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0")
  1315. # Migration: Add AMS alarm notification columns to notification_providers
  1316. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0")
  1317. try:
  1318. async with conn.begin_nested():
  1319. await conn.execute(
  1320. text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
  1321. )
  1322. except (OperationalError, ProgrammingError):
  1323. pass # Already applied
  1324. # Migration: Add AMS-HT alarm notification columns to notification_providers
  1325. try:
  1326. async with conn.begin_nested():
  1327. await conn.execute(
  1328. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
  1329. )
  1330. except (OperationalError, ProgrammingError):
  1331. pass # Already applied
  1332. try:
  1333. async with conn.begin_nested():
  1334. await conn.execute(
  1335. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
  1336. )
  1337. except (OperationalError, ProgrammingError):
  1338. pass # Already applied
  1339. # Migration: Add plate not empty notification column to notification_providers
  1340. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1")
  1341. # Migration: Add notes column to projects (Phase 2)
  1342. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN notes TEXT")
  1343. # Migration: Add attachments column to projects (Phase 3)
  1344. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN attachments JSON")
  1345. # Migration: Add tags column to projects (Phase 4)
  1346. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN tags TEXT")
  1347. # Migration: Add due_date column to projects (Phase 5)
  1348. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN due_date DATETIME")
  1349. # Migration: Add priority column to projects (Phase 5)
  1350. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'")
  1351. # Migration: Add budget column to projects (Phase 6)
  1352. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN budget REAL")
  1353. # Migration: Add is_template column to projects (Phase 8)
  1354. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0")
  1355. # Migration: Add template_source_id column to projects (Phase 8)
  1356. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN template_source_id INTEGER")
  1357. # Migration: Add parent_id column to projects (Phase 10)
  1358. try:
  1359. async with conn.begin_nested():
  1360. await conn.execute(
  1361. text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  1362. )
  1363. except (OperationalError, ProgrammingError):
  1364. pass # Already applied
  1365. # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
  1366. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired")
  1367. # Migration: Add unit_price column to project_bom_items
  1368. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN unit_price REAL")
  1369. # Migration: Add sourcing_url column to project_bom_items
  1370. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)")
  1371. # Migration: Rename notes to remarks in project_bom_items
  1372. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks")
  1373. # Migration: Add show_in_switchbar column to smart_plugs
  1374. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0")
  1375. # Migration: Add runtime tracking columns to printers
  1376. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0")
  1377. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME")
  1378. # Migration: Add quantity column to print_archives for tracking item count
  1379. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1")
  1380. # Migration: Add manual_start column to print_queue for staged prints
  1381. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
  1382. # Migration: Add cost_center_id column to print_queue for billing metadata
  1383. try:
  1384. async with conn.begin_nested():
  1385. await conn.execute(
  1386. text(
  1387. "ALTER TABLE print_queue ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL"
  1388. )
  1389. )
  1390. except (OperationalError, ProgrammingError):
  1391. pass # Already applied
  1392. # Migration: Add wiki_url column to maintenance_types for documentation links
  1393. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
  1394. # Migration: Add tailscale_disabled column to virtual_printers. Opt-in: default TRUE so
  1395. # the auto-detect + fallback noise only runs for users who explicitly enable it.
  1396. # Postgres rejects `DEFAULT 1` for BOOLEAN (#1070 round-2 review).
  1397. if is_sqlite():
  1398. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 1")
  1399. else:
  1400. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT true")
  1401. # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
  1402. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
  1403. # Migration: filament_short flag on print_queue (#1496). Set by the
  1404. # dispatch scheduler when the assigned spool can't satisfy the print's
  1405. # per-slot weight; surfaced as a "filament short" badge on the queue row.
  1406. # Postgres rejects `DEFAULT 0` for BOOLEAN — branch on dialect.
  1407. if is_sqlite():
  1408. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT 0")
  1409. else:
  1410. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
  1411. # Migration: skip_filament_check flag on print_queue (#1698-followup).
  1412. # Persists the user's "Print Anyway" acknowledgement so the scheduler
  1413. # doesn't re-flag the item every tick after they've confirmed dispatch
  1414. # despite the deficit warning. Set from the start route's skip_filament_check
  1415. # query param and from PrintModal at queue-creation time. Postgres / SQLite
  1416. # boolean default branch matches filament_short above.
  1417. if is_sqlite():
  1418. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT 0")
  1419. else:
  1420. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
  1421. # Migration: cleanup flag for transient printer-card uploads routed through
  1422. # the scheduler. The archive copy is durable; the library row/file can be
  1423. # deleted after dispatch.
  1424. if is_sqlite():
  1425. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT 0")
  1426. else:
  1427. await _safe_execute(
  1428. conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT false"
  1429. )
  1430. # Migration: Add queue_force_color_match column to virtual_printers (#1188).
  1431. # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
  1432. # from the 3MF onto the queue item's filament_overrides so the scheduler
  1433. # refuses to dispatch onto a printer with the wrong filament loaded.
  1434. # Default false to preserve current behaviour for upgraders.
  1435. if is_sqlite():
  1436. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT 0")
  1437. else:
  1438. await _safe_execute(
  1439. conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
  1440. )
  1441. # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
  1442. # existing gcode_snippets users don't silently start injecting on VP/Studio
  1443. # Send jobs after upgrading.
  1444. if is_sqlite():
  1445. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0")
  1446. else:
  1447. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
  1448. # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
  1449. # slicer-pick preservation (#1780). Opaque JSON-string column carrying
  1450. # BambuStudio's per-filament physical nozzle position IDs, forwarded
  1451. # straight from the VP intake to the dispatcher's project_file MQTT
  1452. # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
  1453. # divergence here. `nozzles_info` shipped in the original #1780 attempt
  1454. # but BambuStudio never actually sends it (verified via wire capture on
  1455. # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows
  1456. # still load; nothing reads or writes to it anymore.
  1457. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
  1458. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
  1459. # Migration: Add target_parts_count column to projects for tracking total parts needed
  1460. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
  1461. # Migration: Add url + cover_image_filename columns to projects (#1155).
  1462. # url: external link rendered next to the project name on the card.
  1463. # cover_image_filename: filename of the project's hero image inside the
  1464. # existing attachments dir; rendered as a thumbnail on the card.
  1465. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
  1466. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
  1467. # Migration: enhanced filament colour handling on color_catalog (#1154).
  1468. # Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
  1469. # so catalog entries can store an alpha component (#RRGGBBAA). SQLite
  1470. # ignores VARCHAR length, so the widen only matters on PostgreSQL.
  1471. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
  1472. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
  1473. if not is_sqlite():
  1474. await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
  1475. # Migration: Make printer_id nullable in print_queue for unassigned queue items
  1476. # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
  1477. # PostgreSQL gets the correct schema from create_all(), so skip this
  1478. if is_sqlite():
  1479. try:
  1480. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  1481. row = result.fetchone()
  1482. if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
  1483. cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
  1484. col_names = {col[1] for col in cols_result.fetchall()}
  1485. await conn.execute(
  1486. text("""
  1487. CREATE TABLE print_queue_new (
  1488. id INTEGER PRIMARY KEY,
  1489. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  1490. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  1491. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  1492. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  1493. position INTEGER DEFAULT 0,
  1494. scheduled_time DATETIME,
  1495. manual_start BOOLEAN DEFAULT 0,
  1496. require_previous_success BOOLEAN DEFAULT 0,
  1497. auto_off_after BOOLEAN DEFAULT 0,
  1498. ams_mapping TEXT,
  1499. status VARCHAR(20) DEFAULT 'pending',
  1500. started_at DATETIME,
  1501. completed_at DATETIME,
  1502. error_message TEXT,
  1503. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  1504. )
  1505. """)
  1506. )
  1507. if "cost_center_id" in col_names:
  1508. await conn.execute(
  1509. text("""
  1510. INSERT INTO print_queue_new
  1511. SELECT id, printer_id, archive_id, cost_center_id, project_id, position, scheduled_time,
  1512. manual_start, require_previous_success, auto_off_after, ams_mapping,
  1513. status, started_at, completed_at, error_message, created_at
  1514. FROM print_queue
  1515. """)
  1516. )
  1517. else:
  1518. await conn.execute(
  1519. text("""
  1520. INSERT INTO print_queue_new
  1521. SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
  1522. manual_start, require_previous_success, auto_off_after, ams_mapping,
  1523. status, started_at, completed_at, error_message, created_at
  1524. FROM print_queue
  1525. """)
  1526. )
  1527. await conn.execute(text("DROP TABLE print_queue"))
  1528. await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
  1529. except (OperationalError, ProgrammingError):
  1530. pass # Already applied
  1531. # Migration: Add plug_type column to smart_plugs for HA integration
  1532. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'")
  1533. # Migration: Add ha_entity_id column to smart_plugs for HA integration
  1534. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)")
  1535. # Migration: Add project_id column to library_folders for linking folders to projects
  1536. try:
  1537. async with conn.begin_nested():
  1538. await conn.execute(
  1539. text(
  1540. "ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  1541. )
  1542. )
  1543. except (OperationalError, ProgrammingError):
  1544. pass # Already applied
  1545. # Migration: Add archive_id column to library_folders for linking folders to archives
  1546. try:
  1547. async with conn.begin_nested():
  1548. await conn.execute(
  1549. text(
  1550. "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
  1551. )
  1552. )
  1553. except (OperationalError, ProgrammingError):
  1554. pass # Already applied
  1555. # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
  1556. # PostgreSQL gets the correct schema from create_all(), so skip this
  1557. if is_sqlite():
  1558. try:
  1559. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  1560. row = result.fetchone()
  1561. if row and "ip_address VARCHAR(45) NOT NULL" in (row[0] or ""):
  1562. await conn.execute(
  1563. text("""
  1564. CREATE TABLE smart_plugs_new (
  1565. id INTEGER PRIMARY KEY,
  1566. name VARCHAR(100) NOT NULL,
  1567. ip_address VARCHAR(45),
  1568. plug_type VARCHAR(20) DEFAULT 'tasmota',
  1569. ha_entity_id VARCHAR(100),
  1570. printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,
  1571. enabled BOOLEAN NOT NULL DEFAULT 1,
  1572. auto_on BOOLEAN NOT NULL DEFAULT 1,
  1573. auto_off BOOLEAN NOT NULL DEFAULT 1,
  1574. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  1575. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  1576. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  1577. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  1578. username VARCHAR(50),
  1579. password VARCHAR(100),
  1580. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  1581. power_alert_high FLOAT,
  1582. power_alert_low FLOAT,
  1583. power_alert_last_triggered DATETIME,
  1584. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  1585. schedule_on_time VARCHAR(5),
  1586. schedule_off_time VARCHAR(5),
  1587. show_in_switchbar BOOLEAN DEFAULT 0,
  1588. last_state VARCHAR(10),
  1589. last_checked DATETIME,
  1590. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  1591. auto_off_pending BOOLEAN DEFAULT 0,
  1592. auto_off_pending_since DATETIME,
  1593. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  1594. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  1595. )
  1596. """)
  1597. )
  1598. await conn.execute(
  1599. text("""
  1600. INSERT INTO smart_plugs_new
  1601. SELECT id, name, ip_address,
  1602. COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
  1603. enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  1604. off_delay_mode, off_delay_minutes, off_temp_threshold,
  1605. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  1606. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  1607. COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
  1608. COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at
  1609. FROM smart_plugs
  1610. """)
  1611. )
  1612. await conn.execute(text("DROP TABLE smart_plugs"))
  1613. await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
  1614. except (OperationalError, ProgrammingError):
  1615. pass # Already applied
  1616. # Migration: Add plate_id column to print_queue for multi-plate 3MF support
  1617. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN plate_id INTEGER")
  1618. # Migration: Add print options columns to print_queue
  1619. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1")
  1620. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0")
  1621. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1")
  1622. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")
  1623. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")
  1624. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")
  1625. # Migration: Add nozzle offset calibration option (dual-nozzle printers, #1682).
  1626. # Postgres rejects `DEFAULT 1` on a BOOLEAN column — use TRUE / 1 per dialect.
  1627. if is_sqlite():
  1628. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT 1")
  1629. else:
  1630. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
  1631. # Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
  1632. # boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
  1633. # "auto" state for these (skip the calibration if it was done recently); our
  1634. # booleans could only send force-on / off. Legacy rows map true->'on',
  1635. # false->'off'; the new default is 'auto'. Idempotent on both dialects:
  1636. # SQLite leans on column affinity (a BOOLEAN-declared column stores text
  1637. # fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
  1638. # column type only while it is still boolean, so re-runs and fresh
  1639. # create_all() schemas (already VARCHAR) are skipped. Column names are
  1640. # hardcoded constants, not user input.
  1641. _tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
  1642. if is_sqlite():
  1643. for _col in _tristate_cols:
  1644. async with conn.begin_nested():
  1645. # B608 is a false positive here: _col is a hardcoded constant
  1646. # from _tristate_cols, never user input, and SQL identifiers
  1647. # can't be bound as parameters. Suppressed inline below.
  1648. await conn.execute(
  1649. text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')") # nosec B608
  1650. )
  1651. await conn.execute(
  1652. text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')") # nosec B608
  1653. )
  1654. else:
  1655. for _col in _tristate_cols:
  1656. result = await conn.execute(
  1657. text(
  1658. "SELECT data_type FROM information_schema.columns "
  1659. "WHERE table_name = 'print_queue' AND column_name = :col"
  1660. ),
  1661. {"col": _col},
  1662. )
  1663. row = result.fetchone()
  1664. if row and row[0] == "boolean":
  1665. await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
  1666. await _safe_execute(
  1667. conn,
  1668. f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
  1669. f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
  1670. )
  1671. await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
  1672. # Migration: normalise the workflow-default settings rows that back these
  1673. # options from legacy "true"/"false" to the tri-state vocabulary so the API
  1674. # returns real values (the AppSettings validator also coerces on read, but
  1675. # rewriting keeps the stored data honest). Only these three became tri-state.
  1676. for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
  1677. async with conn.begin_nested():
  1678. await conn.execute(
  1679. text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
  1680. {"k": _skey},
  1681. )
  1682. await conn.execute(
  1683. text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
  1684. {"k": _skey},
  1685. )
  1686. # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
  1687. # is one of {inherit, on, off} — 'inherit' falls back to the global
  1688. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  1689. # target column overrides the filament-map derivation when not null.
  1690. # Existing rows default to 'inherit' + NULL so behaviour is unchanged for
  1691. # in-flight queues.
  1692. await _safe_execute(
  1693. conn,
  1694. "ALTER TABLE print_queue ADD COLUMN preheat_override VARCHAR(10) DEFAULT 'inherit'",
  1695. )
  1696. await _safe_execute(
  1697. conn,
  1698. "ALTER TABLE print_queue ADD COLUMN preheat_chamber_target_override INTEGER",
  1699. )
  1700. # Migration: Add library_file_id column to print_queue and make archive_id nullable
  1701. # This allows queue items to reference library files directly (archive created at print start)
  1702. try:
  1703. async with conn.begin_nested():
  1704. await conn.execute(
  1705. text(
  1706. "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
  1707. )
  1708. )
  1709. except (OperationalError, ProgrammingError):
  1710. pass # Already applied
  1711. # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
  1712. # PostgreSQL gets the correct schema from create_all(), so skip this
  1713. if is_sqlite():
  1714. try:
  1715. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  1716. row = result.fetchone()
  1717. if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
  1718. cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
  1719. col_names = {col[1] for col in cols_result.fetchall()}
  1720. await conn.execute(
  1721. text("""
  1722. CREATE TABLE print_queue_new2 (
  1723. id INTEGER PRIMARY KEY,
  1724. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  1725. archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
  1726. library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
  1727. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  1728. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  1729. position INTEGER DEFAULT 0,
  1730. scheduled_time DATETIME,
  1731. manual_start BOOLEAN DEFAULT 0,
  1732. require_previous_success BOOLEAN DEFAULT 0,
  1733. auto_off_after BOOLEAN DEFAULT 0,
  1734. ams_mapping TEXT,
  1735. plate_id INTEGER,
  1736. bed_levelling BOOLEAN DEFAULT 1,
  1737. flow_cali BOOLEAN DEFAULT 0,
  1738. vibration_cali BOOLEAN DEFAULT 1,
  1739. layer_inspect BOOLEAN DEFAULT 0,
  1740. timelapse BOOLEAN DEFAULT 0,
  1741. use_ams BOOLEAN DEFAULT 1,
  1742. status VARCHAR(20) DEFAULT 'pending',
  1743. started_at DATETIME,
  1744. completed_at DATETIME,
  1745. error_message TEXT,
  1746. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  1747. )
  1748. """)
  1749. )
  1750. if "cost_center_id" in col_names:
  1751. await conn.execute(
  1752. text("""
  1753. INSERT INTO print_queue_new2
  1754. SELECT id, printer_id, archive_id, NULL, cost_center_id, project_id, position, scheduled_time,
  1755. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  1756. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  1757. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  1758. status, started_at, completed_at, error_message, created_at
  1759. FROM print_queue
  1760. """)
  1761. )
  1762. else:
  1763. await conn.execute(
  1764. text("""
  1765. INSERT INTO print_queue_new2
  1766. SELECT id, printer_id, archive_id, NULL, NULL, project_id, position, scheduled_time,
  1767. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  1768. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  1769. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  1770. status, started_at, completed_at, error_message, created_at
  1771. FROM print_queue
  1772. """)
  1773. )
  1774. await conn.execute(text("DROP TABLE print_queue"))
  1775. await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
  1776. except (OperationalError, ProgrammingError):
  1777. pass # Already applied
  1778. # Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
  1779. # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
  1780. # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
  1781. # exist" on Postgres. On a fresh DB create_all() already built the column, so
  1782. # the ALTER is swallowed as "already exists".
  1783. #
  1784. # Placed AFTER the print_queue_new2 table-recreate above: that recreate
  1785. # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
  1786. # rebuilds print_queue from an explicit column list that doesn't carry this
  1787. # column, so adding it earlier would let the recreate silently drop it. Adding
  1788. # it here means it survives that path.
  1789. if is_sqlite():
  1790. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
  1791. else:
  1792. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
  1793. # Migration: Add HA energy sensor entity columns to smart_plugs
  1794. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
  1795. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
  1796. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)")
  1797. # Migration: Create users table for authentication
  1798. try:
  1799. async with conn.begin_nested():
  1800. await conn.execute(
  1801. text("""
  1802. CREATE TABLE IF NOT EXISTS users (
  1803. id INTEGER PRIMARY KEY,
  1804. username VARCHAR(100) NOT NULL UNIQUE,
  1805. password_hash VARCHAR(255) NOT NULL,
  1806. role VARCHAR(20) NOT NULL DEFAULT 'user',
  1807. is_active BOOLEAN NOT NULL DEFAULT 1,
  1808. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1809. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1810. )
  1811. """)
  1812. )
  1813. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
  1814. except (OperationalError, ProgrammingError):
  1815. pass # Already applied
  1816. # Migration: Add external camera columns to printers
  1817. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
  1818. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
  1819. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
  1820. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_snapshot_url VARCHAR(500)")
  1821. # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
  1822. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")
  1823. # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
  1824. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")
  1825. # Migration: Add is_external column to library_files for external cloud files
  1826. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0")
  1827. # Migration: Add project_id column to library_files
  1828. try:
  1829. async with conn.begin_nested():
  1830. await conn.execute(
  1831. text(
  1832. "ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  1833. )
  1834. )
  1835. except (OperationalError, ProgrammingError):
  1836. pass # Already applied
  1837. # Migration: Add is_external column to library_folders for external cloud folders
  1838. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0")
  1839. # Migration: Add external folder settings columns to library_folders
  1840. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0")
  1841. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0")
  1842. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)")
  1843. # Migration: Add plate_detection_enabled column to printers
  1844. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0")
  1845. # Migration: Add plate detection ROI columns to printers
  1846. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL")
  1847. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL")
  1848. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL")
  1849. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL")
  1850. # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
  1851. # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
  1852. # SQLite requires table recreation to drop constraints
  1853. # PostgreSQL gets the correct schema from create_all(), so skip this
  1854. if is_sqlite():
  1855. try:
  1856. needs_migration = False
  1857. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  1858. row = result.fetchone()
  1859. table_sql = (row[0] or "").upper() if row else ""
  1860. if "PRINTER_ID" in table_sql and "UNIQUE" in table_sql:
  1861. import re
  1862. if re.search(r'"?PRINTER_ID"?\s+\w+\s+UNIQUE', table_sql) or re.search(
  1863. r'UNIQUE\s*\([^)]*"?PRINTER_ID"?', table_sql
  1864. ):
  1865. needs_migration = True
  1866. idx_result = await conn.execute(
  1867. text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL")
  1868. )
  1869. for idx_row in idx_result.fetchall():
  1870. idx_sql = (idx_row[0] or "").upper()
  1871. if "UNIQUE" in idx_sql and "PRINTER_ID" in idx_sql:
  1872. needs_migration = True
  1873. break
  1874. if needs_migration:
  1875. # Create new table without UNIQUE constraint on printer_id
  1876. await conn.execute(
  1877. text("""
  1878. CREATE TABLE smart_plugs_temp (
  1879. id INTEGER PRIMARY KEY,
  1880. name VARCHAR(100) NOT NULL,
  1881. ip_address VARCHAR(45),
  1882. plug_type VARCHAR(20) DEFAULT 'tasmota',
  1883. ha_entity_id VARCHAR(100),
  1884. ha_power_entity VARCHAR(100),
  1885. ha_energy_today_entity VARCHAR(100),
  1886. ha_energy_total_entity VARCHAR(100),
  1887. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  1888. enabled BOOLEAN NOT NULL DEFAULT 1,
  1889. auto_on BOOLEAN NOT NULL DEFAULT 1,
  1890. auto_off BOOLEAN NOT NULL DEFAULT 1,
  1891. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  1892. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  1893. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  1894. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  1895. username VARCHAR(50),
  1896. password VARCHAR(100),
  1897. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  1898. power_alert_high FLOAT,
  1899. power_alert_low FLOAT,
  1900. power_alert_last_triggered DATETIME,
  1901. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  1902. schedule_on_time VARCHAR(5),
  1903. schedule_off_time VARCHAR(5),
  1904. show_in_switchbar BOOLEAN DEFAULT 0,
  1905. last_state VARCHAR(10),
  1906. last_checked DATETIME,
  1907. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  1908. auto_off_pending BOOLEAN DEFAULT 0,
  1909. auto_off_pending_since DATETIME,
  1910. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  1911. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  1912. )
  1913. """)
  1914. )
  1915. # Copy data
  1916. await conn.execute(
  1917. text("""
  1918. INSERT INTO smart_plugs_temp
  1919. SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
  1920. ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
  1921. auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  1922. off_delay_mode, off_delay_minutes, off_temp_threshold,
  1923. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  1924. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  1925. show_in_switchbar, last_state, last_checked, auto_off_executed,
  1926. auto_off_pending, auto_off_pending_since, created_at, updated_at
  1927. FROM smart_plugs
  1928. """)
  1929. )
  1930. # Drop old table and rename new one
  1931. await conn.execute(text("DROP TABLE smart_plugs"))
  1932. await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
  1933. except (OperationalError, ProgrammingError):
  1934. pass # Already applied
  1935. # Migration: Add show_on_printer_card column to smart_plugs
  1936. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")
  1937. # Migration: Add MQTT smart plug fields (legacy)
  1938. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")
  1939. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")
  1940. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")
  1941. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")
  1942. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")
  1943. # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
  1944. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")
  1945. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")
  1946. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")
  1947. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")
  1948. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")
  1949. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")
  1950. # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
  1951. try:
  1952. async with conn.begin_nested():
  1953. await conn.execute(
  1954. text("""
  1955. UPDATE smart_plugs
  1956. SET mqtt_power_topic = mqtt_topic,
  1957. mqtt_power_multiplier = mqtt_multiplier
  1958. WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
  1959. """)
  1960. )
  1961. except (OperationalError, ProgrammingError):
  1962. pass # Already applied
  1963. # Migration: Create groups table for permission-based access control
  1964. try:
  1965. async with conn.begin_nested():
  1966. await conn.execute(
  1967. text("""
  1968. CREATE TABLE IF NOT EXISTS groups (
  1969. id INTEGER PRIMARY KEY,
  1970. name VARCHAR(100) NOT NULL UNIQUE,
  1971. description VARCHAR(500),
  1972. permissions JSON,
  1973. is_system BOOLEAN NOT NULL DEFAULT 0,
  1974. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1975. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1976. )
  1977. """)
  1978. )
  1979. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
  1980. except (OperationalError, ProgrammingError):
  1981. pass # Already applied
  1982. # Migration: Create user_groups association table
  1983. try:
  1984. async with conn.begin_nested():
  1985. await conn.execute(
  1986. text("""
  1987. CREATE TABLE IF NOT EXISTS user_groups (
  1988. user_id INTEGER NOT NULL,
  1989. group_id INTEGER NOT NULL,
  1990. PRIMARY KEY (user_id, group_id),
  1991. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  1992. FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
  1993. )
  1994. """)
  1995. )
  1996. except (OperationalError, ProgrammingError):
  1997. pass # Already applied
  1998. # Migration: Add model-based queue assignment columns to print_queue
  1999. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")
  2000. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")
  2001. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")
  2002. # Migration: Add nozzle_count column to printers (for dual-extruder detection)
  2003. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1")
  2004. # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
  2005. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0")
  2006. # Migration: Add queue notification event columns to notification_providers
  2007. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0")
  2008. try:
  2009. async with conn.begin_nested():
  2010. await conn.execute(
  2011. text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
  2012. )
  2013. except (OperationalError, ProgrammingError):
  2014. pass # Already applied
  2015. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0")
  2016. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1")
  2017. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1")
  2018. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1")
  2019. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0")
  2020. # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
  2021. try:
  2022. async with conn.begin_nested():
  2023. await conn.execute(
  2024. text(
  2025. "ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  2026. )
  2027. )
  2028. except (OperationalError, ProgrammingError):
  2029. pass # Already applied
  2030. # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
  2031. try:
  2032. async with conn.begin_nested():
  2033. await conn.execute(
  2034. text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  2035. )
  2036. except (OperationalError, ProgrammingError):
  2037. pass # Already applied
  2038. # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
  2039. try:
  2040. async with conn.begin_nested():
  2041. await conn.execute(
  2042. text(
  2043. "ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  2044. )
  2045. )
  2046. except (OperationalError, ProgrammingError):
  2047. pass # Already applied
  2048. # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
  2049. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")
  2050. # Migration: Convert absolute paths to relative paths in library_files table
  2051. # This ensures backup/restore portability across different installations
  2052. try:
  2053. async with conn.begin_nested():
  2054. base_dir_str = str(settings.base_dir)
  2055. # Ensure we have a trailing slash for clean replacement
  2056. if not base_dir_str.endswith("/"):
  2057. base_dir_str += "/"
  2058. # Update file_path - remove base_dir prefix from absolute paths
  2059. await conn.execute(
  2060. text("""
  2061. UPDATE library_files
  2062. SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
  2063. WHERE file_path LIKE :pattern
  2064. """),
  2065. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  2066. )
  2067. # Update thumbnail_path - remove base_dir prefix from absolute paths
  2068. await conn.execute(
  2069. text("""
  2070. UPDATE library_files
  2071. SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
  2072. WHERE thumbnail_path LIKE :pattern
  2073. """),
  2074. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  2075. )
  2076. except (OperationalError, ProgrammingError):
  2077. pass # Already applied
  2078. # Create active_print_spoolman table for Spoolman per-filament tracking.
  2079. # filament_usage is nullable so the no-3MF branch can still create a row
  2080. # that carries only tray_remain_start for the remain%-delta fallback
  2081. # (#1820 — matches internal-inventory Path 2 in usage_tracker).
  2082. await _safe_execute(
  2083. conn,
  2084. """
  2085. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  2086. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2087. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  2088. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  2089. filament_usage TEXT,
  2090. ams_trays TEXT NOT NULL,
  2091. slot_to_tray TEXT,
  2092. layer_usage TEXT,
  2093. filament_properties TEXT,
  2094. tray_remain_start TEXT,
  2095. UNIQUE(printer_id, archive_id)
  2096. )
  2097. """
  2098. if is_sqlite()
  2099. else """
  2100. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  2101. id SERIAL PRIMARY KEY,
  2102. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  2103. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  2104. filament_usage TEXT,
  2105. ams_trays TEXT NOT NULL,
  2106. slot_to_tray TEXT,
  2107. layer_usage TEXT,
  2108. filament_properties TEXT,
  2109. tray_remain_start TEXT,
  2110. UNIQUE(printer_id, archive_id)
  2111. )
  2112. """,
  2113. )
  2114. # Migration for installs that already created active_print_spoolman with
  2115. # the original schema: add tray_remain_start, and relax filament_usage's
  2116. # NOT NULL so the no-3MF branch can persist a remain-only tracking row.
  2117. await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT")
  2118. if is_sqlite():
  2119. # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the
  2120. # users.password_hash NULL-relaxation a few hundred lines below — see
  2121. # the comment there for the schema_version bump rationale.
  2122. try:
  2123. result = await conn.execute(
  2124. text("SELECT sql FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'")
  2125. )
  2126. tbl_sql = result.scalar()
  2127. if tbl_sql and "filament_usage TEXT NOT NULL" in tbl_sql:
  2128. version_result = await conn.execute(text("PRAGMA schema_version"))
  2129. schema_version = version_result.scalar() or 0
  2130. await conn.execute(text("PRAGMA writable_schema = ON"))
  2131. await conn.execute(
  2132. text(
  2133. "UPDATE sqlite_master "
  2134. "SET sql = replace(sql, 'filament_usage TEXT NOT NULL', 'filament_usage TEXT') "
  2135. "WHERE type='table' AND name='active_print_spoolman'"
  2136. )
  2137. )
  2138. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  2139. await conn.execute(text("PRAGMA writable_schema = OFF"))
  2140. except (OperationalError, ProgrammingError) as exc:
  2141. logger.warning(
  2142. "Could not relax active_print_spoolman.filament_usage NOT NULL via writable_schema: %s — "
  2143. "no-3MF Spoolman fallback will be a no-op on this install",
  2144. exc,
  2145. )
  2146. else:
  2147. await _safe_execute(conn, "ALTER TABLE active_print_spoolman ALTER COLUMN filament_usage DROP NOT NULL")
  2148. # Migration: Add preset_source column to slot_preset_mappings for local preset support
  2149. try:
  2150. async with conn.begin_nested():
  2151. await conn.execute(
  2152. text("ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'")
  2153. )
  2154. except (OperationalError, ProgrammingError):
  2155. pass # Already applied
  2156. # Migration: Add email column to users for Advanced Auth (PR #322)
  2157. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN email VARCHAR(255)")
  2158. # Migration: Add inventory spool tracking columns
  2159. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN added_full BOOLEAN")
  2160. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_used DATETIME")
  2161. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN encode_time DATETIME")
  2162. # Migration: Add RFID tag matching columns to spool
  2163. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)")
  2164. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)")
  2165. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)")
  2166. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)")
  2167. # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
  2168. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER")
  2169. # Migration: Create spool_usage_history table for filament consumption tracking
  2170. await _safe_execute(
  2171. conn,
  2172. """
  2173. CREATE TABLE IF NOT EXISTS spool_usage_history (
  2174. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2175. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  2176. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  2177. print_name VARCHAR(500),
  2178. weight_used REAL NOT NULL DEFAULT 0,
  2179. percent_used INTEGER NOT NULL DEFAULT 0,
  2180. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  2181. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  2182. )
  2183. """
  2184. if is_sqlite()
  2185. else """
  2186. CREATE TABLE IF NOT EXISTS spool_usage_history (
  2187. id SERIAL PRIMARY KEY,
  2188. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  2189. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  2190. print_name VARCHAR(500),
  2191. weight_used REAL NOT NULL DEFAULT 0,
  2192. percent_used INTEGER NOT NULL DEFAULT 0,
  2193. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  2194. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  2195. )
  2196. """,
  2197. )
  2198. # Migration: Add open_in_new_tab column to external_links
  2199. await _safe_execute(conn, "ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0")
  2200. # Migration: Add bed cooled notification column to notification_providers
  2201. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0")
  2202. # Migration: Add first layer complete notification column to notification_providers
  2203. try:
  2204. async with conn.begin_nested():
  2205. await conn.execute(
  2206. text("ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0")
  2207. )
  2208. except (OperationalError, ProgrammingError):
  2209. pass # Already applied
  2210. # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
  2211. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0")
  2212. # Migration: Add SpoolBuddy scale weight tracking columns to spool table
  2213. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER")
  2214. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME")
  2215. # Migration: Add cost tracking fields to spool table
  2216. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
  2217. # Migration: Per-spool category + low-stock threshold override (#729). Both
  2218. # nullable — NULL category leaves the spool uncategorised, NULL threshold
  2219. # falls back to the global low_stock_threshold setting.
  2220. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
  2221. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
  2222. # Migration: Add user-editable storage location to spool table
  2223. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
  2224. # Migration: Add weight_used_baseline anchor for the resettable "Total
  2225. # Consumed" stat (#1390). Existing spools default to 0 (no baseline),
  2226. # so the counter starts unaffected; pressing "Reset usage to 0" now
  2227. # stamps baseline = weight_used without touching remaining.
  2228. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0")
  2229. # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
  2230. # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
  2231. # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.
  2232. if not is_sqlite():
  2233. await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
  2234. # Migration: enhanced filament colour handling (#1154). `extra_colors` is
  2235. # a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
  2236. # colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
  2237. # matte} as a visual rendering hint. Both nullable — NULL keeps the
  2238. # current single-rgba/no-effect behaviour.
  2239. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
  2240. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
  2241. # Migration: Add cost field to spool_usage_history table
  2242. await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
  2243. # Migration: Add archive_id field to spool_usage_history table
  2244. try:
  2245. async with conn.begin_nested():
  2246. await conn.execute(
  2247. text("ALTER TABLE spool_usage_history ADD COLUMN archive_id INTEGER REFERENCES print_archives(id)")
  2248. )
  2249. except (OperationalError, ProgrammingError):
  2250. pass # Already applied
  2251. # Migration: Migrate single virtual printer key-value settings to virtual_printers table
  2252. try:
  2253. async with conn.begin_nested():
  2254. result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
  2255. count = result.scalar() or 0
  2256. if count == 0:
  2257. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
  2258. row = result.fetchone()
  2259. if row:
  2260. # Old settings exist — migrate to first virtual printer row
  2261. old_enabled = row[0] == "true" if row[0] else False
  2262. result = await conn.execute(
  2263. text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
  2264. )
  2265. row = result.fetchone()
  2266. old_access_code = row[0] if row else None
  2267. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
  2268. row = result.fetchone()
  2269. old_mode = row[0] if row else "archive"
  2270. # Translate to canonical wire values (#1429 mode-label
  2271. # discrepancy): legacy `immediate` → `archive`, legacy
  2272. # `print_queue` → `queue`. The historical `queue` alias
  2273. # for `review` predates the canonical rename and is
  2274. # preserved (existing user intent was "pending review").
  2275. if old_mode == "queue":
  2276. old_mode = "review"
  2277. elif old_mode == "immediate":
  2278. old_mode = "archive"
  2279. elif old_mode == "print_queue":
  2280. old_mode = "queue"
  2281. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
  2282. row = result.fetchone()
  2283. old_model = row[0] if row else "BL-P001"
  2284. result = await conn.execute(
  2285. text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
  2286. )
  2287. row = result.fetchone()
  2288. old_target_id = int(row[0]) if row and row[0] else None
  2289. result = await conn.execute(
  2290. text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
  2291. )
  2292. row = result.fetchone()
  2293. old_remote_iface = row[0] if row else None
  2294. await conn.execute(
  2295. text("""
  2296. INSERT INTO virtual_printers
  2297. (name, enabled, mode, model, access_code, target_printer_id,
  2298. bind_ip, remote_interface_ip, serial_suffix, position)
  2299. VALUES
  2300. (:name, :enabled, :mode, :model, :access_code, :target_id,
  2301. NULL, :remote_iface, '391800001', 0)
  2302. """),
  2303. {
  2304. "name": "Bambuddy",
  2305. "enabled": old_enabled,
  2306. "mode": old_mode or "archive",
  2307. "model": old_model,
  2308. "access_code": old_access_code,
  2309. "target_id": old_target_id,
  2310. "remote_iface": old_remote_iface,
  2311. },
  2312. )
  2313. except (OperationalError, ProgrammingError, IntegrityError):
  2314. pass # Table may not exist yet on first run, or columns have different constraints
  2315. # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment
  2316. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT")
  2317. # Migration: Add NFC reader and display control columns to spoolbuddy_devices
  2318. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)")
  2319. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)")
  2320. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100")
  2321. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0")
  2322. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0")
  2323. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME")
  2324. # Migration: Add NFC tag write payload column to spoolbuddy_devices
  2325. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT")
  2326. # Migration: Add OTA update tracking columns to spoolbuddy_devices
  2327. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_status VARCHAR(20)")
  2328. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_message VARCHAR(255)")
  2329. # Migration: Persist SpoolBuddy backend URL and queued system payload
  2330. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)")
  2331. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT")
  2332. # Migration: Add system_stats JSON blob column to spoolbuddy_devices
  2333. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
  2334. # Migration: Add SSH host key for TOFU verification (H1 security fix)
  2335. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)")
  2336. # Migration: Widen ssh_host_key from VARCHAR(500) to TEXT — RSA-3072+ host keys
  2337. # in OpenSSH format exceed 500 chars (RSA-4096 ~720 chars). PostgreSQL enforces
  2338. # the limit and rejects the UPDATE; SQLite ignores VARCHAR length so no-op there.
  2339. if not is_sqlite():
  2340. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT")
  2341. # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
  2342. # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
  2343. # PostgreSQL gets the correct schema from create_all(), so skip this
  2344. if is_sqlite():
  2345. try:
  2346. await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new"))
  2347. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'"))
  2348. row = result.fetchone()
  2349. if row and "printer_id" in (row[0] or ""):
  2350. # Old schema: rebuild the table with ams_serial_number as the unique key.
  2351. # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved.
  2352. await conn.execute(
  2353. text("""
  2354. CREATE TABLE ams_labels_new (
  2355. id INTEGER PRIMARY KEY,
  2356. ams_serial_number VARCHAR(50) NOT NULL,
  2357. ams_id INTEGER,
  2358. label VARCHAR(100) NOT NULL,
  2359. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2360. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2361. CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)
  2362. )
  2363. """)
  2364. )
  2365. await conn.execute(
  2366. text("""
  2367. INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)
  2368. SELECT id,
  2369. 'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),
  2370. ams_id,
  2371. label,
  2372. created_at,
  2373. updated_at
  2374. FROM ams_labels
  2375. """)
  2376. )
  2377. await conn.execute(text("DROP TABLE ams_labels"))
  2378. await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels"))
  2379. except (OperationalError, ProgrammingError):
  2380. pass # Already migrated or table does not exist yet
  2381. # Migration: Add auto_dispatch column to virtual_printers
  2382. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN auto_dispatch BOOLEAN DEFAULT 1")
  2383. # Migration: Fix VP model codes — convert legacy SSDP codes and display names to correct SSDP codes
  2384. # Legacy codes (from multi-VP refactor) and display names (from proxy auto-inherit)
  2385. vp_model_fixes = {
  2386. "3DPrinter-X1-Carbon": "BL-P001",
  2387. "3DPrinter-X1": "BL-P002",
  2388. "X1C": "BL-P001",
  2389. "X1": "BL-P002",
  2390. "X1E": "C13",
  2391. "X2D": "N6",
  2392. "P1P": "C11",
  2393. "P1S": "C12",
  2394. "P2S": "N7",
  2395. "A1": "N2S",
  2396. "A1 Mini": "N1",
  2397. "H2D": "O1D",
  2398. "H2C": "O1C",
  2399. "H2S": "O1S",
  2400. }
  2401. for old_val, new_val in vp_model_fixes.items():
  2402. await conn.execute(
  2403. text("UPDATE virtual_printers SET model = :new WHERE model = :old"),
  2404. {"old": old_val, "new": new_val},
  2405. )
  2406. await conn.execute(
  2407. text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_model' AND value = :old"),
  2408. {"old": old_val, "new": new_val},
  2409. )
  2410. # Migration: Rename VP mode wire values to match the user-facing labels
  2411. # (#1429 follow-up). The UI button "Archive" had always saved `immediate`
  2412. # and "Queue" had always saved `print_queue` — a mismatch that showed up
  2413. # confusingly in every support bundle. The button labels stay; the wire
  2414. # value is what changes. Idempotent: re-running the UPDATE on canonical
  2415. # values is a no-op. SQLite and Postgres both accept this statement
  2416. # unchanged (string literal comparison, no driver-specific syntax).
  2417. vp_mode_renames = [("immediate", "archive"), ("print_queue", "queue")]
  2418. for old_val, new_val in vp_mode_renames:
  2419. await conn.execute(
  2420. text("UPDATE virtual_printers SET mode = :new WHERE mode = :old"),
  2421. {"old": old_val, "new": new_val},
  2422. )
  2423. await conn.execute(
  2424. text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_mode' AND value = :old"),
  2425. {"old": old_val, "new": new_val},
  2426. )
  2427. # Migration: Auto-sync VP access codes from their target printer.
  2428. # Non-proxy VPs with a target printer (the live-mirror bridge) forward the
  2429. # slicer's MQTT/RTSPS auth bytes through to the real printer, so the VP's
  2430. # access code MUST equal the target's — earlier UIs let them diverge,
  2431. # producing a VP that the slicer could bind but whose bridge silently
  2432. # failed to authenticate against the real printer. The route layer now
  2433. # auto-inherits on every create/update; this backfill corrects any rows
  2434. # that pre-date that change. Idempotent (re-running on synced rows is a
  2435. # no-op because the WHERE clause excludes them). SQLite and Postgres both
  2436. # accept correlated subqueries in UPDATE — no driver-specific syntax.
  2437. mismatch_result = await conn.execute(
  2438. text(
  2439. "SELECT vp.id AS vp_id, vp.name AS vp_name, p.name AS target_name "
  2440. "FROM virtual_printers vp "
  2441. "JOIN printers p ON vp.target_printer_id = p.id "
  2442. "WHERE vp.mode != 'proxy' "
  2443. " AND (vp.access_code IS NULL OR vp.access_code != p.access_code)"
  2444. )
  2445. )
  2446. for row in mismatch_result.fetchall():
  2447. logger.info(
  2448. "VP %r (id=%d) access code synced from target printer %r",
  2449. row.vp_name,
  2450. row.vp_id,
  2451. row.target_name,
  2452. )
  2453. await conn.execute(
  2454. text(
  2455. "UPDATE virtual_printers "
  2456. "SET access_code = ("
  2457. " SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
  2458. ") "
  2459. "WHERE virtual_printers.target_printer_id IS NOT NULL "
  2460. " AND virtual_printers.mode != 'proxy' "
  2461. " AND (virtual_printers.access_code IS NULL OR virtual_printers.access_code != ("
  2462. " SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
  2463. " ))"
  2464. )
  2465. )
  2466. # Migration: Recover queue items that got stuck in `skipped` because of
  2467. # the cancellation-cascade bug (#1667). Pre-fix, the scheduler's
  2468. # `_check_previous_success` lookback excluded `cancelled` but included
  2469. # `skipped`, so a single user-cancelled print poisoned every downstream
  2470. # item with `require_previous_success=True` indefinitely. The reporter saw
  2471. # 18 items blocked over 3 days from one cancellation.
  2472. #
  2473. # Conservative reversal: ONLY reset rows whose immediate predecessor on
  2474. # the same printer (by completed_at desc, excluding the skipped-bug
  2475. # cascade) was `cancelled`. Skipped items whose true predecessor was a
  2476. # real `failed` or `aborted` print stay skipped — those were legitimate.
  2477. # Genuine failure-skips share the same status + error_message + completed_at
  2478. # fingerprint as bug-skips, so the predecessor check is what distinguishes
  2479. # them. Idempotent (post-reset rows no longer match the WHERE clause).
  2480. #
  2481. # Correlated subquery is portable across SQLite and Postgres. The
  2482. # `error_message` literal matches the exact string the buggy scheduler
  2483. # wrote — narrowing further on intent.
  2484. stuck_skipped_result = await conn.execute(
  2485. text(
  2486. "SELECT pq.id, pq.printer_id "
  2487. "FROM print_queue pq "
  2488. "WHERE pq.status = 'skipped' "
  2489. " AND pq.error_message = 'Previous print failed or was aborted' "
  2490. " AND pq.completed_at IS NOT NULL "
  2491. " AND ("
  2492. " SELECT prev.status FROM print_queue prev "
  2493. " WHERE prev.printer_id = pq.printer_id "
  2494. " AND prev.id != pq.id "
  2495. " AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
  2496. " AND prev.completed_at IS NOT NULL "
  2497. " AND prev.completed_at < pq.completed_at "
  2498. " ORDER BY prev.completed_at DESC LIMIT 1"
  2499. " ) = 'cancelled'"
  2500. )
  2501. )
  2502. stuck_ids = [row.id for row in stuck_skipped_result.fetchall()]
  2503. if stuck_ids:
  2504. logger.info(
  2505. "Queue cancellation-cascade migration (#1667): resetting %d skipped item(s) to pending",
  2506. len(stuck_ids),
  2507. )
  2508. await conn.execute(
  2509. text(
  2510. "UPDATE print_queue "
  2511. "SET status = 'pending', error_message = NULL, completed_at = NULL "
  2512. "WHERE id IN ("
  2513. " SELECT pq.id FROM print_queue pq "
  2514. " WHERE pq.status = 'skipped' "
  2515. " AND pq.error_message = 'Previous print failed or was aborted' "
  2516. " AND pq.completed_at IS NOT NULL "
  2517. " AND ("
  2518. " SELECT prev.status FROM print_queue prev "
  2519. " WHERE prev.printer_id = pq.printer_id "
  2520. " AND prev.id != pq.id "
  2521. " AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
  2522. " AND prev.completed_at IS NOT NULL "
  2523. " AND prev.completed_at < pq.completed_at "
  2524. " ORDER BY prev.completed_at DESC LIMIT 1"
  2525. " ) = 'cancelled'"
  2526. ")"
  2527. )
  2528. )
  2529. # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
  2530. # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
  2531. # sliced outputs — the upload, ZIP-extract, and in-process paths all
  2532. # stripped to the trailing `.3mf` and stored `3mf`, so the same file
  2533. # family was split between two values depending on how it was ingested.
  2534. # Going forward `classify_file_type()` is canonical; this backfill flips
  2535. # existing legacy `3mf` rows whose filename ends in `.gcode.3mf` to the
  2536. # canonical compound name. Idempotent (post-update rows no longer match
  2537. # `file_type = '3mf'`) and dialect-neutral (`LOWER` + `LIKE` work the
  2538. # same under SQLite and Postgres).
  2539. await conn.execute(
  2540. text(
  2541. "UPDATE library_files SET file_type = 'gcode.3mf' "
  2542. "WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf'"
  2543. )
  2544. )
  2545. # Migration: Add per-user Bambu Cloud credential columns
  2546. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
  2547. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
  2548. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_region VARCHAR(10)")
  2549. # Cleanup: Remove obsolete settings keys that are no longer used
  2550. obsolete_keys = ["slicer_binary_path"]
  2551. for key in obsolete_keys:
  2552. await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
  2553. # Migration: Create user_email_preferences table for user-specific email notification settings
  2554. try:
  2555. async with conn.begin_nested():
  2556. await conn.execute(
  2557. text("""
  2558. CREATE TABLE IF NOT EXISTS user_email_preferences (
  2559. id INTEGER PRIMARY KEY,
  2560. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  2561. notify_print_start BOOLEAN NOT NULL DEFAULT 1,
  2562. notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
  2563. notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
  2564. notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
  2565. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  2566. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  2567. )
  2568. """)
  2569. )
  2570. await conn.execute(
  2571. text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
  2572. )
  2573. except (OperationalError, ProgrammingError):
  2574. pass # Already applied
  2575. # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
  2576. try:
  2577. async with conn.begin_nested():
  2578. await conn.execute(
  2579. text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
  2580. )
  2581. except (OperationalError, ProgrammingError):
  2582. pass # Column already exists or table created with full schema
  2583. # Migration: Add camera_rotation column to printers
  2584. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0")
  2585. # Migration: Add awaiting_plate_clear column to printers (#961)
  2586. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN awaiting_plate_clear BOOLEAN DEFAULT FALSE NOT NULL")
  2587. # Migration: Add REST/Webhook smart plug fields
  2588. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)")
  2589. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT")
  2590. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)")
  2591. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT")
  2592. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)")
  2593. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT")
  2594. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)")
  2595. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)")
  2596. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)")
  2597. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)")
  2598. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)")
  2599. # Migration: Add separate REST power/energy URLs and multipliers
  2600. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_url VARCHAR(500)")
  2601. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_multiplier REAL DEFAULT 1.0")
  2602. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
  2603. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
  2604. # Migration (#2539): a REST plug's lifetime energy counter, separate from its
  2605. # today counter. Devices differ in which they expose — a Shelly reports only
  2606. # a cumulative `aenergy.total`, a Tasmota behind a REST bridge reports both —
  2607. # and conflating the two made the cumulative value read as "today", so it
  2608. # never reset at midnight and "Total" stayed empty forever.
  2609. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_path VARCHAR(200)")
  2610. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_multiplier REAL DEFAULT 1.0")
  2611. # Migration: Add batch_id column to print_queue for batch grouping
  2612. try:
  2613. async with conn.begin_nested():
  2614. await conn.execute(
  2615. text(
  2616. "ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL"
  2617. )
  2618. )
  2619. except (OperationalError, ProgrammingError):
  2620. pass
  2621. # Migration: Shortest-job-first scheduling columns on print_queue
  2622. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
  2623. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
  2624. # Migration: Auto-print G-code injection (#422)
  2625. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
  2626. # Migration: Store estimated print cost for budget checks before queued jobs start
  2627. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
  2628. # Migration: Persist active budget reservations for accepted background dispatch jobs.
  2629. if is_sqlite():
  2630. await _safe_execute(
  2631. conn,
  2632. """
  2633. CREATE TABLE IF NOT EXISTS budget_reservations (
  2634. id INTEGER PRIMARY KEY,
  2635. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  2636. amount FLOAT NOT NULL,
  2637. status VARCHAR(20),
  2638. source_type VARCHAR(50),
  2639. source_id INTEGER,
  2640. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  2641. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  2642. released_at DATETIME
  2643. )
  2644. """,
  2645. )
  2646. else:
  2647. await _safe_execute(
  2648. conn,
  2649. """
  2650. CREATE TABLE IF NOT EXISTS budget_reservations (
  2651. id SERIAL PRIMARY KEY,
  2652. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  2653. amount FLOAT NOT NULL,
  2654. status VARCHAR(20),
  2655. source_type VARCHAR(50),
  2656. source_id INTEGER,
  2657. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  2658. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  2659. released_at TIMESTAMP
  2660. )
  2661. """,
  2662. )
  2663. await _safe_execute(
  2664. conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)"
  2665. )
  2666. await _safe_execute(
  2667. conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)"
  2668. )
  2669. await _safe_execute(
  2670. conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)"
  2671. )
  2672. await _safe_execute(
  2673. conn, "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)"
  2674. )
  2675. await _safe_execute(
  2676. conn,
  2677. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
  2678. )
  2679. # Migration: Add backup_spools and backup_archives columns to github_backup_config
  2680. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
  2681. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
  2682. # Migration: Widen columns where SQLite allowed data beyond the declared VARCHAR limit
  2683. if not is_sqlite():
  2684. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_hash TYPE VARCHAR(255)")
  2685. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_prefix TYPE VARCHAR(20)")
  2686. await _safe_execute(conn, "ALTER TABLE print_archives ALTER COLUMN filament_color TYPE VARCHAR(200)")
  2687. # Migration: Create GIN index for full-text search on PostgreSQL
  2688. # (SQLite uses FTS5 virtual table instead, set up above)
  2689. if not is_sqlite():
  2690. try:
  2691. await conn.execute(
  2692. text("""
  2693. CREATE INDEX IF NOT EXISTS idx_archives_fulltext
  2694. ON print_archives
  2695. USING GIN (to_tsvector('simple',
  2696. COALESCE(print_name, '') || ' ' ||
  2697. COALESCE(filename, '') || ' ' ||
  2698. COALESCE(tags, '') || ' ' ||
  2699. COALESCE(notes, '') || ' ' ||
  2700. COALESCE(designer, '') || ' ' ||
  2701. COALESCE(filament_type, '')
  2702. ))
  2703. """)
  2704. )
  2705. except (OperationalError, ProgrammingError):
  2706. pass # Already applied
  2707. # Migration: Normalize empty printer_ids [] to NULL (global access) on API keys
  2708. # Previously both None and [] meant "all printers"; now [] means "no printers"
  2709. # PostgreSQL stores printer_ids as JSONB; comparing JSONB to a string literal fails
  2710. # with "operator does not exist: jsonb = unknown" — cast the literal to jsonb explicitly.
  2711. await _migrate_normalize_printer_ids(conn)
  2712. # Migration: Add auth_source column to users for LDAP support (#794)
  2713. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
  2714. # Migration: Make password_hash nullable for LDAP users (#794)
  2715. # LDAP users have no local password — the column must allow NULL so auto-provisioning
  2716. # doesn't hit a NOT NULL constraint failure on upgraded installs whose users table was
  2717. # originally created before LDAP support landed.
  2718. if is_sqlite():
  2719. # SQLite can't ALTER COLUMN; patch sqlite_master directly via writable_schema.
  2720. # Bump schema_version afterwards so SQLite reloads the table definition from disk —
  2721. # without that bump, the current connection keeps enforcing the old NOT NULL from
  2722. # its cached schema. Safe because row data is untouched and the replace() is a
  2723. # no-op if the constraint has already been removed.
  2724. try:
  2725. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'"))
  2726. users_sql = result.scalar()
  2727. if users_sql and "password_hash VARCHAR(255) NOT NULL" in users_sql:
  2728. version_result = await conn.execute(text("PRAGMA schema_version"))
  2729. schema_version = version_result.scalar() or 0
  2730. await conn.execute(text("PRAGMA writable_schema = ON"))
  2731. await conn.execute(
  2732. text(
  2733. "UPDATE sqlite_master "
  2734. "SET sql = replace(sql, 'password_hash VARCHAR(255) NOT NULL', 'password_hash VARCHAR(255)') "
  2735. "WHERE type = 'table' AND name = 'users'"
  2736. )
  2737. )
  2738. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  2739. await conn.execute(text("PRAGMA writable_schema = OFF"))
  2740. except (OperationalError, ProgrammingError) as exc:
  2741. logger.error(
  2742. "Failed to remove NOT NULL from users.password_hash via writable_schema — "
  2743. "OIDC/LDAP user creation will fail on this install: %s",
  2744. exc,
  2745. exc_info=True,
  2746. )
  2747. else:
  2748. await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
  2749. # Migration: Add energy_start_kwh to print_archives (#941)
  2750. # Persists the smart plug lifetime counter captured at print start, so per-print
  2751. # energy tracking survives a backend restart mid-print.
  2752. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_kwh REAL")
  2753. # Migration: Add subtask_id to print_archives (#972)
  2754. # MQTT-provided task identifier used to resume the same archive row across a
  2755. # backend restart mid-print. Without it, a long print (e.g. 13h) triggers
  2756. # stale-cancel + new-archive, losing started_at continuity.
  2757. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN subtask_id VARCHAR(64)")
  2758. # Migration: Add bed_type to print_archives (#1253)
  2759. # Build plate type extracted from 3MF (curr_bed_type), drives the bed icon
  2760. # rendered on archive cards.
  2761. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
  2762. # Migration: Add deleted_at to print_archives (#1343)
  2763. # Soft-delete sentinel so deleting an archive entry from the UI no longer
  2764. # wipes its filament / time / cost contribution from Quick Stats. Listings
  2765. # hide rows where deleted_at IS NOT NULL; the stats endpoint counts them all.
  2766. # DATETIME on SQLite, TIMESTAMP on PostgreSQL (PG doesn't accept DATETIME on
  2767. # ALTER TABLE the same way it tolerates it inside CREATE TABLE).
  2768. _deleted_at_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
  2769. await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN deleted_at {_deleted_at_type}")
  2770. await _safe_execute(
  2771. conn,
  2772. "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
  2773. )
  2774. # Migration: Add bambuddy_forced_timelapse to print_archives (#1397)
  2775. # Tracks prints where Bambuddy forced the firmware to record a timelapse
  2776. # so the finish-photo extractor could pull the post-park-pre-drop frame.
  2777. # The cleanup path uses this to delete the timelapse both locally and on
  2778. # the printer's SD after extraction — the user didn't opt in to a
  2779. # timelapse recording. Postgres rejects `DEFAULT 0` for BOOLEAN; SQLite
  2780. # accepts both 0/FALSE — branch the literal.
  2781. _bool_false_literal = "0" if is_sqlite() else "FALSE"
  2782. await _safe_execute(
  2783. conn,
  2784. f"ALTER TABLE print_archives ADD COLUMN bambuddy_forced_timelapse BOOLEAN DEFAULT {_bool_false_literal}",
  2785. )
  2786. # Migration: Create smart_plug_energy_snapshots table (#941)
  2787. # Hourly snapshots of each plug's lifetime counter, so date-range queries in
  2788. # "total consumption" energy mode can compute (last - first) deltas.
  2789. await _safe_execute(
  2790. conn,
  2791. """
  2792. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  2793. id INTEGER PRIMARY KEY AUTOINCREMENT,
  2794. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  2795. recorded_at DATETIME NOT NULL,
  2796. lifetime_kwh REAL NOT NULL
  2797. )
  2798. """
  2799. if is_sqlite()
  2800. else """
  2801. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  2802. id SERIAL PRIMARY KEY,
  2803. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  2804. recorded_at TIMESTAMP NOT NULL,
  2805. lifetime_kwh REAL NOT NULL
  2806. )
  2807. """,
  2808. )
  2809. await _safe_execute(
  2810. conn,
  2811. "CREATE INDEX IF NOT EXISTS ix_plug_energy_snapshots_plug_time "
  2812. "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
  2813. )
  2814. # Migration: Add PKCE code_verifier column to auth_ephemeral_tokens
  2815. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)")
  2816. # Migration: Add TOTP replay-protection counter to user_totp
  2817. await _safe_execute(conn, "ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT")
  2818. # Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)
  2819. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
  2820. # Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
  2821. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  2822. if is_sqlite():
  2823. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0")
  2824. else:
  2825. await _safe_execute(
  2826. conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
  2827. )
  2828. # Migration: Azure Entra ID support — configurable email claim and verification requirement
  2829. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN email_claim VARCHAR(64) DEFAULT 'email'")
  2830. # Postgres rejects `DEFAULT 1` for BOOLEAN columns.
  2831. if is_sqlite():
  2832. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
  2833. else:
  2834. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true")
  2835. # SEC-1 backfill: reset auto_link only for Fall B (email_claim='email' + require_email_verified=False).
  2836. # Fall C (custom claim) is now allowed to use auto_link — do NOT reset those rows.
  2837. # Runs BEFORE the CHECK constraint below so Fall B rows self-heal rather than failing
  2838. # PostgreSQL's "check constraint is violated by some row" on ADD CONSTRAINT.
  2839. # On fresh installs the column defaults guarantee this UPDATE matches zero rows.
  2840. # TRUE/FALSE literals are accepted by both SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
  2841. try:
  2842. async with conn.begin_nested():
  2843. await conn.execute(
  2844. text(
  2845. "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
  2846. "WHERE auto_link_existing_accounts = TRUE "
  2847. "AND email_claim = 'email' AND require_email_verified = FALSE"
  2848. )
  2849. )
  2850. except Exception as exc:
  2851. logger.error(
  2852. "SEC-1 safety backfill FAILED — auto_link_existing_accounts may remain enabled "
  2853. "on providers with unsafe email settings: %s",
  2854. exc,
  2855. exc_info=True,
  2856. )
  2857. raise
  2858. # SEC-1: Add DB-level CHECK constraint for existing PostgreSQL installs.
  2859. # SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
  2860. # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
  2861. if not is_sqlite():
  2862. try:
  2863. async with conn.begin_nested():
  2864. await conn.execute(
  2865. text(
  2866. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
  2867. "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
  2868. )
  2869. )
  2870. except (OperationalError, ProgrammingError) as exc:
  2871. msg = str(exc).lower()
  2872. if "already exists" not in msg:
  2873. logger.error(
  2874. "Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
  2875. exc,
  2876. exc_info=True,
  2877. )
  2878. raise
  2879. # Migration: Update auto_link CHECK constraint formula (existing installs).
  2880. # Existing PostgreSQL installs that ran the ADD CONSTRAINT above with the old formula
  2881. # (or a previous version of this code) need an explicit DROP + ADD to update it.
  2882. # For SQLite, the table is recreated with the new constraint formula if the old formula
  2883. # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
  2884. await _migrate_update_auto_link_constraint(conn)
  2885. # Migration: Add default_group_id to oidc_providers.
  2886. # Must run AFTER _migrate_update_auto_link_constraint to avoid being dropped during
  2887. # the SQLite table recreation that function performs on stale-formula databases.
  2888. await _safe_execute(
  2889. conn,
  2890. "ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
  2891. )
  2892. # Migration: Add cached-icon columns to oidc_providers (#1333).
  2893. # SPA's strict CSP (img-src 'self' data: blob:) blocks hotlinking external
  2894. # icon hosts, so we proxy them: admin sets icon_url, backend fetches and
  2895. # caches the bytes here, the SPA renders <img src="/api/v1/auth/oidc/providers/{id}/icon">.
  2896. # Must run AFTER _migrate_update_auto_link_constraint for the same reason as
  2897. # default_group_id above (SQLite table recreation drops unknown columns).
  2898. # Dialect-conditional type: BLOB on SQLite, BYTEA on PostgreSQL.
  2899. _blob_type = "BLOB" if is_sqlite() else "BYTEA"
  2900. await _safe_execute(conn, f"ALTER TABLE oidc_providers ADD COLUMN icon_data {_blob_type}")
  2901. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_content_type VARCHAR(20)")
  2902. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_etag VARCHAR(64)")
  2903. # PostgreSQL-only: enforce the all-or-nothing triplet at the DB layer.
  2904. # SQLite cannot ADD CONSTRAINT to an existing table — fresh SQLite
  2905. # installs get the CHECK via metadata.create_all (model __table_args__);
  2906. # stale SQLite installs rely on the application layer, same trade-off
  2907. # as the default_group_id FK ON DELETE SET NULL above.
  2908. if not is_sqlite():
  2909. await _safe_execute(
  2910. conn,
  2911. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_oidc_icon_triplet_co_null "
  2912. "CHECK ((icon_data IS NULL) = (icon_content_type IS NULL) "
  2913. "AND (icon_content_type IS NULL) = (icon_etag IS NULL))",
  2914. )
  2915. # Migration: Add password_changed_at to users (M-R7-B)
  2916. # Tracks the last time a user's password was changed/reset. JWTs whose iat
  2917. # predates this timestamp are rejected in all six auth validation paths.
  2918. # R4 fix: TIMESTAMP is accepted by both SQLite and PostgreSQL; DATETIME
  2919. # is rejected by Postgres ("type 'datetime' does not exist"), which made
  2920. # _safe_execute swallow the error and leave existing Postgres installs
  2921. # without the column — causing UndefinedColumnError on every User query.
  2922. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN password_changed_at TIMESTAMP")
  2923. # Migration: Back-fill password_changed_at = created_at for existing users (I2).
  2924. # Users who never changed their password would have NULL here, meaning old
  2925. # tokens could never be invalidated via the freshness check. Setting it to
  2926. # created_at is conservative: any token issued before the account was created
  2927. # is always invalid, so this is a safe lower bound.
  2928. async with conn.begin_nested():
  2929. await conn.execute(text("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL"))
  2930. # Migration: Provenance columns on library_files for MakerWorld imports.
  2931. # source_url is indexed so "already imported" dedupe lookups stay O(log N)
  2932. # as the library grows.
  2933. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_type VARCHAR(32)")
  2934. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_url VARCHAR(512)")
  2935. await _safe_execute(
  2936. conn,
  2937. "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
  2938. )
  2939. # Migration: Cache metadata title on pending uploads (#1152 follow-up).
  2940. # Without this column the review card always shows the FTP filename while
  2941. # the eventual archive's print_name comes from the 3MF metadata title,
  2942. # creating a confusing review→archive name mismatch. Captured at upload
  2943. # time so /pending-uploads/ list calls don't have to reopen each 3MF.
  2944. await _safe_execute(
  2945. conn,
  2946. "ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)",
  2947. )
  2948. # Migration: Per-user API key ownership + cloud-access scope (#1182).
  2949. # user_id is nullable so legacy keys (created before #1182) survive the
  2950. # migration; cloud routes reject calls from keys without an owner so the
  2951. # operator is forced to recreate them. ON DELETE CASCADE so deleting a user
  2952. # takes their keys with them — orphan keys must never authenticate.
  2953. # SQLite ignores REFERENCES on ADD COLUMN (not enforced but not an error);
  2954. # PostgreSQL enforces the FK from this point forward. Indexed for the
  2955. # auth-gate's owner→keys lookup that runs on every API-keyed request.
  2956. await _safe_execute(
  2957. conn,
  2958. "ALTER TABLE api_keys ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
  2959. )
  2960. await _safe_execute(
  2961. conn,
  2962. "CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys(user_id)",
  2963. )
  2964. # ``DEFAULT 0`` works on SQLite (boolean is just integer-coerced) but
  2965. # asyncpg's strict type-check rejects it: "column is of type boolean but
  2966. # default expression is of type integer". Use ``DEFAULT FALSE`` so both
  2967. # dialects accept the same statement — same pattern as the print_queue
  2968. # gcode_injection migration above.
  2969. await _safe_execute(
  2970. conn,
  2971. "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
  2972. )
  2973. # Narrowly-scoped settings-write toggle for the dynamic-tariff push case
  2974. # documented in wiki/features/energy.md (#1356). Defaults FALSE so existing
  2975. # keys never silently gain settings-write capability on upgrade.
  2976. await _safe_execute(
  2977. conn,
  2978. "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
  2979. )
  2980. # GHSA-r2qv-8222-hqg3 (CVE-2026-pending, CVSS 9.9): split file-management out
  2981. # of the implicit "any API key" grant into an explicit scope flag. The
  2982. # allowlist-based ``_check_apikey_permissions`` (see ``core/auth.py``) routes
  2983. # LIBRARY_UPLOAD / LIBRARY_UPDATE_OWN / LIBRARY_DELETE_OWN / MAKERWORLD_IMPORT
  2984. # through this flag. DEFAULT TRUE matches the existing "queue + read" trust
  2985. # baseline; backfill mirrors can_queue so a key the user previously created as
  2986. # "queue-only" retains the file-upload step its queue workflow already used,
  2987. # while a hardened "read-only" key (can_queue=False) does not silently gain a
  2988. # new write capability on upgrade. Backfill is gated on column non-existence
  2989. # so user-edited values are never overwritten on subsequent startup.
  2990. column_existed = await _api_keys_column_exists(conn, "can_manage_library")
  2991. await _safe_execute(
  2992. conn,
  2993. "ALTER TABLE api_keys ADD COLUMN can_manage_library BOOLEAN DEFAULT TRUE",
  2994. )
  2995. if not column_existed:
  2996. async with conn.begin_nested():
  2997. await conn.execute(text("UPDATE api_keys SET can_manage_library = can_queue"))
  2998. # Same shape: SpoolBuddy NFC/scale/system endpoints plus manual inventory
  2999. # writes split out of the implicit "any API key" grant. Backfill mirrors
  3000. # ``can_queue`` so the bundled SpoolBuddy kiosk key (created via the CLI
  3001. # with can_queue=False) does NOT silently gain inventory writes — but
  3002. # the CLI override sets the new flag True explicitly, since the kiosk
  3003. # itself is the legitimate writer (see ``cli.py``).
  3004. column_existed = await _api_keys_column_exists(conn, "can_manage_inventory")
  3005. await _safe_execute(
  3006. conn,
  3007. "ALTER TABLE api_keys ADD COLUMN can_manage_inventory BOOLEAN DEFAULT TRUE",
  3008. )
  3009. if not column_existed:
  3010. async with conn.begin_nested():
  3011. await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
  3012. # #1832 follow-up: carve maintenance CRUD out of the admin denylist so
  3013. # HA-style automations can log "cleaned nozzle" via API key. Distinct
  3014. # from the two backfills above: MAINTENANCE_CREATE / _UPDATE / _DELETE
  3015. # were EXPLICITLY denied for every API key under the pre-migration model
  3016. # (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no existing
  3017. # integration relies on them. Column default TRUE matches the "safe,
  3018. # on-by-default" pattern for keys created via the UI going forward;
  3019. # existing rows backfill to FALSE so the upgrade path does not silently
  3020. # widen scope for keys created before this flag existed. Users opt in
  3021. # via Settings → API Keys per key.
  3022. column_existed = await _api_keys_column_exists(conn, "can_manage_maintenance")
  3023. await _safe_execute(
  3024. conn,
  3025. "ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE",
  3026. )
  3027. if not column_existed:
  3028. async with conn.begin_nested():
  3029. await conn.execute(text("UPDATE api_keys SET can_manage_maintenance = FALSE"))
  3030. # #1888: carve archive CRUD (create/update/delete — NOT purge) out of the
  3031. # admin denylist so automations can prune old prints via API key. Same
  3032. # shape and reasoning as can_manage_maintenance above: ARCHIVES_CREATE /
  3033. # _UPDATE_* / _DELETE_* were EXPLICITLY denied for every API key under the
  3034. # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
  3035. # existing integration relies on them. Column default TRUE for keys created
  3036. # via the UI going forward; existing rows backfill to FALSE so the upgrade
  3037. # path does not silently widen scope for keys created before this flag
  3038. # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid
  3039. # on both SQLite and Postgres, so no dialect branch is needed.
  3040. column_existed = await _api_keys_column_exists(conn, "can_manage_archives")
  3041. await _safe_execute(
  3042. conn,
  3043. "ALTER TABLE api_keys ADD COLUMN can_manage_archives BOOLEAN DEFAULT TRUE",
  3044. )
  3045. if not column_existed:
  3046. async with conn.begin_nested():
  3047. await conn.execute(text("UPDATE api_keys SET can_manage_archives = FALSE"))
  3048. # #1893: carve project CRUD + membership (create/update/delete, add-archives)
  3049. # out of the admin denylist so automations can manage projects via API key.
  3050. # Identical shape and reasoning to can_manage_archives above: PROJECTS_CREATE
  3051. # / _UPDATE / _DELETE were EXPLICITLY denied for every API key under the
  3052. # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
  3053. # existing integration relies on them. Column default TRUE for keys created
  3054. # via the UI going forward; existing rows backfill to FALSE so the upgrade
  3055. # path does not silently widen scope for keys created before this flag
  3056. # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid on
  3057. # both SQLite and Postgres, so no dialect branch is needed.
  3058. column_existed = await _api_keys_column_exists(conn, "can_manage_projects")
  3059. await _safe_execute(
  3060. conn,
  3061. "ALTER TABLE api_keys ADD COLUMN can_manage_projects BOOLEAN DEFAULT TRUE",
  3062. )
  3063. if not column_existed:
  3064. async with conn.begin_nested():
  3065. await conn.execute(text("UPDATE api_keys SET can_manage_projects = FALSE"))
  3066. # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
  3067. # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
  3068. # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
  3069. #
  3070. # ``DATETIME`` is a SQLite-only type alias — PostgreSQL rejects it as
  3071. # invalid syntax, _safe_execute swallows the error, and the column is
  3072. # never added (breaking every query that references it). Emit
  3073. # dialect-appropriate SQL so both backends get the column.
  3074. if is_sqlite():
  3075. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at DATETIME")
  3076. else:
  3077. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at TIMESTAMP")
  3078. await _safe_execute(
  3079. conn,
  3080. "CREATE INDEX IF NOT EXISTS ix_library_files_deleted_at ON library_files(deleted_at)",
  3081. )
  3082. # Legacy SQLite installs created `settings` without a UNIQUE constraint on `key`,
  3083. # so `INSERT OR IGNORE` below silently degrades to a plain INSERT and dupes rows on
  3084. # every restart. Dedupe (keep lowest id per key) and add the missing unique index
  3085. # before seeding. Safe/idempotent on both dialects — fresh installs already have
  3086. # no dupes and `create_all` already emits the index.
  3087. async with conn.begin_nested():
  3088. await conn.execute(text("DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)"))
  3089. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_key ON settings(key)")
  3090. # Migration: Normalise provider_email to lowercase (SEC-3).
  3091. # Required for Entra ID where UPN/email claims may arrive in mixed case.
  3092. # LOWER() is supported by both SQLite and PostgreSQL; the UPDATE is idempotent.
  3093. # Executed directly (not via _safe_execute) so any column-reference failure
  3094. # is always fatal and never silently swallowed.
  3095. async with conn.begin_nested():
  3096. await conn.execute(
  3097. text(
  3098. "UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
  3099. "WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
  3100. )
  3101. )
  3102. # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
  3103. # Replaces the pattern of writing spool.location in Spoolman (which polluted the
  3104. # user-editable storage_location field in the UI).
  3105. # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
  3106. await _safe_execute(
  3107. conn,
  3108. """
  3109. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  3110. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3111. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3112. 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),
  3113. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  3114. spoolman_spool_id INTEGER NOT NULL,
  3115. assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3116. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  3117. )
  3118. """
  3119. if is_sqlite()
  3120. else """
  3121. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  3122. id SERIAL PRIMARY KEY,
  3123. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3124. 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),
  3125. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  3126. spoolman_spool_id INTEGER NOT NULL,
  3127. assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3128. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  3129. )
  3130. """,
  3131. )
  3132. await _safe_execute(
  3133. conn,
  3134. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)",
  3135. )
  3136. # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
  3137. # AMS-HT ids (128-191). Existing installs created before #1274 carry the
  3138. # stale formula which rejects every AMS-HT slot link with a CHECK violation.
  3139. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  3140. # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
  3141. await _safe_execute(
  3142. conn,
  3143. """
  3144. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  3145. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3146. spoolman_spool_id INTEGER NOT NULL,
  3147. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3148. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  3149. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  3150. nozzle_type VARCHAR(50),
  3151. k_value REAL NOT NULL,
  3152. name VARCHAR(100),
  3153. cali_idx INTEGER,
  3154. setting_id VARCHAR(50),
  3155. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3156. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  3157. )
  3158. """
  3159. if is_sqlite()
  3160. else """
  3161. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  3162. id SERIAL PRIMARY KEY,
  3163. spoolman_spool_id INTEGER NOT NULL,
  3164. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3165. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  3166. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  3167. nozzle_type VARCHAR(50),
  3168. k_value DOUBLE PRECISION NOT NULL,
  3169. name VARCHAR(100),
  3170. cali_idx INTEGER,
  3171. setting_id VARCHAR(50),
  3172. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3173. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  3174. )
  3175. """,
  3176. )
  3177. await _safe_execute(
  3178. conn,
  3179. "CREATE INDEX IF NOT EXISTS ix_spoolman_k_profile_spool ON spoolman_k_profile (spoolman_spool_id)",
  3180. )
  3181. # Migration: Add provider column to github_backup_config for multi-provider support
  3182. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN provider VARCHAR(30) DEFAULT 'github'")
  3183. # Migration: Add allow_insecure_http column to github_backup_config for self-hosted HTTP instances
  3184. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN allow_insecure_http BOOLEAN DEFAULT FALSE")
  3185. # Seed default settings keys that must exist on fresh install
  3186. default_settings = [
  3187. ("advanced_auth_enabled", "false"),
  3188. ("smtp_auth_enabled", "true"),
  3189. ]
  3190. for key, value in default_settings:
  3191. try:
  3192. if is_sqlite():
  3193. await conn.execute(
  3194. text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
  3195. {"key": key, "value": value},
  3196. )
  3197. else:
  3198. await conn.execute(
  3199. text("INSERT INTO settings (key, value) VALUES (:key, :value) ON CONFLICT (key) DO NOTHING"),
  3200. {"key": key, "value": value},
  3201. )
  3202. except (OperationalError, ProgrammingError):
  3203. pass
  3204. # Migration: Create filament_sku_settings table for reorder forecasting
  3205. if is_sqlite():
  3206. await _safe_execute(
  3207. conn,
  3208. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  3209. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3210. material VARCHAR(50) NOT NULL,
  3211. subtype VARCHAR(50),
  3212. brand VARCHAR(100),
  3213. lead_time_days INTEGER NOT NULL DEFAULT 0,
  3214. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  3215. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  3216. color_name VARCHAR(100),
  3217. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3218. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3219. UNIQUE (material, subtype, brand, color_name)
  3220. )""",
  3221. )
  3222. async with conn.begin_nested():
  3223. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  3224. await _safe_execute(
  3225. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_value INTEGER NOT NULL DEFAULT 14"
  3226. )
  3227. await _safe_execute(
  3228. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'"
  3229. )
  3230. await _safe_execute(
  3231. conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
  3232. )
  3233. # Migration: add color_name to filament_sku_settings so forecasts
  3234. # distinguish colours within a SKU. The matching ALTER for
  3235. # filament_shopping_list runs AFTER that table's CREATE below — on
  3236. # fresh installs the table doesn't exist yet at this point and
  3237. # _safe_execute does not swallow "no such table".
  3238. await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN color_name VARCHAR(100)")
  3239. # Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
  3240. # Only run if the stale column still exists.
  3241. cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
  3242. col_names = [row[1] for row in cols_result.fetchall()]
  3243. if "safety_margin_days" in col_names:
  3244. async with conn.begin_nested():
  3245. # Defensive: a previous startup may have crashed mid-rebuild leaving
  3246. # filament_sku_settings_new behind, which would break the CREATE below.
  3247. await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_new"))
  3248. await conn.execute(
  3249. text(
  3250. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  3251. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  3252. )
  3253. )
  3254. await conn.execute(
  3255. text(
  3256. """CREATE TABLE filament_sku_settings_new (
  3257. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3258. material VARCHAR(50) NOT NULL,
  3259. subtype VARCHAR(50),
  3260. brand VARCHAR(100),
  3261. color_name VARCHAR(100),
  3262. lead_time_days INTEGER NOT NULL DEFAULT 0,
  3263. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  3264. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  3265. alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
  3266. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3267. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3268. UNIQUE (material, subtype, brand, color_name)
  3269. )"""
  3270. )
  3271. )
  3272. await conn.execute(
  3273. text(
  3274. """INSERT INTO filament_sku_settings_new
  3275. (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  3276. safety_margin_unit, alerts_snoozed, created_at, updated_at)
  3277. SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  3278. safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
  3279. FROM filament_sku_settings"""
  3280. )
  3281. )
  3282. await conn.execute(text("DROP TABLE filament_sku_settings"))
  3283. await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
  3284. # Widen the unique key to include color_name on pre-existing tables. The
  3285. # auto-created UNIQUE index still covers only (material, subtype, brand)
  3286. # after the ADD COLUMN above, so rebuild the table to refresh it (#forecast
  3287. # -color-grouping). Detected by inspecting the index columns; skipped once
  3288. # color_name is already part of the key.
  3289. idx_rows = await conn.execute(text("PRAGMA index_list(filament_sku_settings)"))
  3290. needs_uq_rebuild = False
  3291. for idx in idx_rows.fetchall():
  3292. if idx[3] != "u": # origin col: 'u' = UNIQUE constraint, 'c' = CREATE INDEX, 'pk' = primary key
  3293. continue
  3294. info = await conn.execute(text(f"PRAGMA index_info({idx[1]})"))
  3295. cols = {row[2] for row in info.fetchall()}
  3296. if "material" in cols and "color_name" not in cols:
  3297. needs_uq_rebuild = True
  3298. break
  3299. if needs_uq_rebuild:
  3300. async with conn.begin_nested():
  3301. await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_uqfix"))
  3302. await conn.execute(
  3303. text(
  3304. """CREATE TABLE filament_sku_settings_uqfix (
  3305. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3306. material VARCHAR(50) NOT NULL,
  3307. subtype VARCHAR(50),
  3308. brand VARCHAR(100),
  3309. color_name VARCHAR(100),
  3310. lead_time_days INTEGER NOT NULL DEFAULT 0,
  3311. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  3312. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  3313. alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
  3314. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3315. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3316. UNIQUE (material, subtype, brand, color_name)
  3317. )"""
  3318. )
  3319. )
  3320. await conn.execute(
  3321. text(
  3322. """INSERT INTO filament_sku_settings_uqfix
  3323. (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  3324. safety_margin_unit, alerts_snoozed, created_at, updated_at)
  3325. SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  3326. safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
  3327. FROM filament_sku_settings"""
  3328. )
  3329. )
  3330. await conn.execute(text("DROP TABLE filament_sku_settings"))
  3331. await conn.execute(text("ALTER TABLE filament_sku_settings_uqfix RENAME TO filament_sku_settings"))
  3332. await _safe_execute(
  3333. conn,
  3334. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  3335. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3336. material VARCHAR(50) NOT NULL,
  3337. subtype VARCHAR(50),
  3338. brand VARCHAR(100),
  3339. color_name VARCHAR(100),
  3340. quantity_spools INTEGER NOT NULL DEFAULT 1,
  3341. note VARCHAR(500),
  3342. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  3343. purchased_at DATETIME,
  3344. added_at DATETIME DEFAULT CURRENT_TIMESTAMP
  3345. )""",
  3346. )
  3347. # Backfill color_name on pre-#1814 upgrades — the CREATE above already
  3348. # has it for fresh installs; the ALTER is the upgrade path. "duplicate
  3349. # column name" is swallowed by _safe_execute, so re-runs are no-ops.
  3350. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN color_name VARCHAR(100)")
  3351. # SQLite has no implicit updated_at trigger — add one so the column stays current.
  3352. await _safe_execute(
  3353. conn,
  3354. """CREATE TRIGGER IF NOT EXISTS trg_filament_sku_settings_updated_at
  3355. AFTER UPDATE ON filament_sku_settings FOR EACH ROW
  3356. BEGIN
  3357. UPDATE filament_sku_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
  3358. END""",
  3359. )
  3360. else:
  3361. await _safe_execute(
  3362. conn,
  3363. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  3364. id SERIAL PRIMARY KEY,
  3365. material VARCHAR(50) NOT NULL,
  3366. subtype VARCHAR(50),
  3367. brand VARCHAR(100),
  3368. lead_time_days INTEGER NOT NULL DEFAULT 0,
  3369. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  3370. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  3371. color_name VARCHAR(100),
  3372. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  3373. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  3374. UNIQUE (material, subtype, brand, color_name)
  3375. )""",
  3376. )
  3377. async with conn.begin_nested():
  3378. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  3379. await _safe_execute(
  3380. conn,
  3381. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_value INTEGER NOT NULL DEFAULT 14",
  3382. )
  3383. await _safe_execute(
  3384. conn,
  3385. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'",
  3386. )
  3387. await _safe_execute(
  3388. conn,
  3389. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
  3390. )
  3391. # Migration: add color_name to filament_sku_settings and widen the
  3392. # unique key to include it so forecasts distinguish colours within a
  3393. # SKU (#forecast-color-grouping). The matching ALTER for
  3394. # filament_shopping_list runs AFTER that table's CREATE below — on
  3395. # fresh installs the table doesn't exist yet at this point.
  3396. await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
  3397. # Widen UNIQUE (material, subtype, brand) → (material, subtype, brand, color_name).
  3398. # The original constraint was declared with name="uq_filament_sku" in the
  3399. # model, so we drop/re-add by that name. Gated on a pg_constraint lookup so
  3400. # the rebuild only runs when color_name is missing from the key — without
  3401. # the gate, every startup would take an ACCESS EXCLUSIVE lock on the table
  3402. # and churn the constraint.
  3403. uq_check = await conn.execute(
  3404. text(
  3405. "SELECT 1 FROM pg_constraint c "
  3406. "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) "
  3407. "WHERE c.conname = 'uq_filament_sku' AND a.attname = 'color_name' LIMIT 1"
  3408. )
  3409. )
  3410. if uq_check.scalar_one_or_none() is None:
  3411. await _safe_execute(
  3412. conn,
  3413. "ALTER TABLE filament_sku_settings DROP CONSTRAINT IF EXISTS uq_filament_sku",
  3414. )
  3415. await _safe_execute(
  3416. conn,
  3417. "ALTER TABLE filament_sku_settings ADD CONSTRAINT uq_filament_sku "
  3418. "UNIQUE (material, subtype, brand, color_name)",
  3419. )
  3420. # Only backfill from safety_margin_days if that column still exists (PostgreSQL).
  3421. col_check = await conn.execute(
  3422. text(
  3423. "SELECT 1 FROM information_schema.columns "
  3424. "WHERE table_name = 'filament_sku_settings' AND column_name = 'safety_margin_days'"
  3425. )
  3426. )
  3427. if col_check.fetchone():
  3428. async with conn.begin_nested():
  3429. await conn.execute(
  3430. text(
  3431. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  3432. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  3433. )
  3434. )
  3435. await _safe_execute(
  3436. conn,
  3437. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  3438. id SERIAL PRIMARY KEY,
  3439. material VARCHAR(50) NOT NULL,
  3440. subtype VARCHAR(50),
  3441. brand VARCHAR(100),
  3442. color_name VARCHAR(100),
  3443. quantity_spools INTEGER NOT NULL DEFAULT 1,
  3444. note VARCHAR(500),
  3445. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  3446. purchased_at TIMESTAMP,
  3447. added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  3448. )""",
  3449. )
  3450. await _safe_execute(
  3451. conn,
  3452. "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'",
  3453. )
  3454. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMP")
  3455. # Backfill color_name on pre-#1814 upgrades — the CREATE above already
  3456. # has it for fresh installs; the ALTER is the upgrade path.
  3457. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
  3458. # Migration: Add inventory stock alert columns to notification_providers.
  3459. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  3460. if is_sqlite():
  3461. await _safe_execute(
  3462. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT 0"
  3463. )
  3464. await _safe_execute(
  3465. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT 0"
  3466. )
  3467. else:
  3468. await _safe_execute(
  3469. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT false"
  3470. )
  3471. await _safe_execute(
  3472. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
  3473. )
  3474. # Migration: Heal orphan auth-related rows left behind by user-delete
  3475. # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
  3476. # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
  3477. # CASCADE on user_id — both predate the explicit APIKey-cleanup
  3478. # pattern in PR #1182. PostgreSQL enforces the cascade, but SQLite
  3479. # ships with FK enforcement off, so rows pointing to a deleted user
  3480. # persisted — blocking SSO re-login (the OIDC callback finds the
  3481. # orphan link, fails to resolve the missing user, and falls through
  3482. # to "account_inactive" instead of triggering auto_create), leaking
  3483. # MFA secrets, and leaving camera-stream tokens whose secret_hash is
  3484. # still verify()-able by lookup_prefix. See issue #1285 (#1295 review
  3485. # extended the cleanup to long_lived_tokens). This migration is a
  3486. # no-op on PostgreSQL and idempotent on SQLite.
  3487. async with conn.begin_nested():
  3488. oidc_result = await conn.execute(
  3489. text("DELETE FROM user_oidc_links WHERE user_id NOT IN (SELECT id FROM users)")
  3490. )
  3491. totp_result = await conn.execute(text("DELETE FROM user_totp WHERE user_id NOT IN (SELECT id FROM users)"))
  3492. otp_result = await conn.execute(text("DELETE FROM user_otp_codes WHERE user_id NOT IN (SELECT id FROM users)"))
  3493. llt_result = await conn.execute(
  3494. text("DELETE FROM long_lived_tokens WHERE user_id NOT IN (SELECT id FROM users)")
  3495. )
  3496. oidc_n = oidc_result.rowcount or 0
  3497. totp_n = totp_result.rowcount or 0
  3498. otp_n = otp_result.rowcount or 0
  3499. llt_n = llt_result.rowcount or 0
  3500. if oidc_n or totp_n or otp_n or llt_n:
  3501. logger.info(
  3502. "Cleaned up orphan auth rows: %d OIDC links, %d TOTP, %d OTP codes, %d long-lived tokens",
  3503. oidc_n,
  3504. totp_n,
  3505. otp_n,
  3506. llt_n,
  3507. )
  3508. # Migration: extend print_log_entries with archive_id, cost, energy, failure_reason,
  3509. # created_by_id (#1378). Statistics queries shift from PrintArchive to PrintLogEntry
  3510. # so reprints contribute new rows instead of overwriting the source archive's data.
  3511. if is_sqlite():
  3512. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN archive_id INTEGER")
  3513. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN cost REAL")
  3514. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_kwh REAL")
  3515. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_cost REAL")
  3516. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN failure_reason VARCHAR(100)")
  3517. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN created_by_id INTEGER")
  3518. else:
  3519. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS archive_id INTEGER")
  3520. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION")
  3521. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_kwh DOUBLE PRECISION")
  3522. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_cost DOUBLE PRECISION")
  3523. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(100)")
  3524. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS created_by_id INTEGER")
  3525. await _safe_execute(
  3526. conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_archive_id ON print_log_entries (archive_id)"
  3527. )
  3528. # Backfill PrintLogEntry → PrintArchive linkage and per-event cost/energy
  3529. # for pre-#1378 rows the column-add migration left NULL (#1390).
  3530. #
  3531. # Without this backfill the user's Quick Stats show Filament Cost = 0 and
  3532. # Time Accuracy empty even though their archives carry both, because:
  3533. #
  3534. # - the new stats queries SUM PrintLogEntry.cost (NULL for old rows)
  3535. # - the time-accuracy query JOINs PrintArchive ON archive_id (NULL for
  3536. # old rows, so old runs get excluded from the average)
  3537. #
  3538. # Pre-#1378, archive.cost / energy_kwh / energy_cost were overwritten by
  3539. # each rerun, so the current archive values represent the *latest* run.
  3540. # Backfilling them onto the latest matching PrintLogEntry per archive
  3541. # reconstructs the pre-fix total exactly (sum across archives stays
  3542. # unchanged), and leaves earlier reprints with NULL cost so they
  3543. # contribute zero — matching the "first/latest writes, rest stay NULL"
  3544. # convention #1378 introduced for new prints.
  3545. #
  3546. # DML, not DDL — use conn.execute() inside a savepoint per _safe_execute's
  3547. # own docstring. SQL is plain ANSI (correlated UPDATE, MAX/GROUP BY/HAVING,
  3548. # CASE in HAVING) and runs unchanged on SQLite + PostgreSQL; verified
  3549. # against postgres:16-alpine + asyncpg.
  3550. #
  3551. # Step 1: link old log entries to their archive via print_name + printer_id.
  3552. # Picks the highest-id matching archive when multiple share the same key
  3553. # (newest archive wins — closest to the log's overwrite-then-leave shape).
  3554. from sqlalchemy import text as _text
  3555. async with conn.begin_nested():
  3556. await conn.execute(
  3557. _text("""
  3558. UPDATE print_log_entries
  3559. SET archive_id = (
  3560. SELECT a.id
  3561. FROM print_archives a
  3562. WHERE a.print_name = print_log_entries.print_name
  3563. AND (
  3564. a.printer_id = print_log_entries.printer_id
  3565. OR (a.printer_id IS NULL AND print_log_entries.printer_id IS NULL)
  3566. )
  3567. ORDER BY a.id DESC
  3568. LIMIT 1
  3569. )
  3570. WHERE archive_id IS NULL AND print_name IS NOT NULL
  3571. """)
  3572. )
  3573. # Step 2: backfill cost / energy_kwh / energy_cost onto the latest linked
  3574. # log entry per archive — the row whose creation time best matches the
  3575. # value currently stored on the archive (overwrite-on-reprint semantics
  3576. # under the old design). Only fires for archives where NO log entry has
  3577. # cost set yet, which gives the migration a clean idempotency property:
  3578. # the second pass sees the archive already has a cost-bearing run and
  3579. # leaves the rest of its history NULL (instead of marching up the
  3580. # ID-ordered list of NULL runs on every pass).
  3581. async with conn.begin_nested():
  3582. await conn.execute(
  3583. _text("""
  3584. UPDATE print_log_entries
  3585. SET cost = (SELECT cost FROM print_archives WHERE id = print_log_entries.archive_id),
  3586. energy_kwh = (SELECT energy_kwh FROM print_archives WHERE id = print_log_entries.archive_id),
  3587. energy_cost = (SELECT energy_cost FROM print_archives WHERE id = print_log_entries.archive_id)
  3588. WHERE id IN (
  3589. SELECT MAX(id)
  3590. FROM print_log_entries
  3591. WHERE archive_id IS NOT NULL
  3592. GROUP BY archive_id
  3593. HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0
  3594. )
  3595. """)
  3596. )
  3597. # Migration: smart_plugs gets per-plug auto-off-after-drying toggle and
  3598. # delay (#1349). Fires whenever any AMS attached to the linked printer
  3599. # finishes a dry cycle. Plain ANSI ALTER TABLE works on both SQLite and
  3600. # Postgres for INTEGER/BOOLEAN with simple defaults.
  3601. if is_sqlite():
  3602. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_after_drying BOOLEAN DEFAULT 0")
  3603. await _safe_execute(
  3604. conn, "ALTER TABLE smart_plugs ADD COLUMN off_delay_after_drying_minutes INTEGER DEFAULT 10"
  3605. )
  3606. else:
  3607. await _safe_execute(
  3608. conn,
  3609. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS auto_off_after_drying BOOLEAN DEFAULT false",
  3610. )
  3611. await _safe_execute(
  3612. conn,
  3613. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
  3614. )
  3615. # Migration: Add per-user Orca Cloud credential columns. Mirrors the Bambu
  3616. # Cloud columns but adds refresh_token + expires_at (Supabase PKCE issues
  3617. # short-lived access tokens with rotating refresh tokens), plus three
  3618. # transient PKCE state columns held during the auth handshake. DATETIME
  3619. # is SQLite-only — Postgres uses TIMESTAMP, so the datetime columns are
  3620. # dialect-branched per project convention.
  3621. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_token VARCHAR(2000)")
  3622. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_refresh_token VARCHAR(128)")
  3623. if is_sqlite():
  3624. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_expires_at DATETIME")
  3625. else:
  3626. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_expires_at TIMESTAMP")
  3627. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_email VARCHAR(255)")
  3628. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_user_id VARCHAR(64)")
  3629. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_verifier VARCHAR(64)")
  3630. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_state VARCHAR(32)")
  3631. if is_sqlite():
  3632. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_at DATETIME")
  3633. else:
  3634. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
  3635. # Migration: record when Bambu rejects a stored cloud token. Until now the
  3636. # only state we kept was the token string itself, so a dead credential was
  3637. # indistinguishable from a live one and the UI reported "connected" forever
  3638. # while every cloud call 401'd. DATETIME is SQLite-only — Postgres uses
  3639. # TIMESTAMP, so the column is dialect-branched per project convention.
  3640. if is_sqlite():
  3641. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token_invalid_at DATETIME")
  3642. else:
  3643. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS cloud_token_invalid_at TIMESTAMP")
  3644. # Data migration: drop the embedded 3MF Title (`print_name`) from library
  3645. # file metadata so the FileManager displays the filename, not the title (#1489).
  3646. await _migrate_drop_library_print_name(conn)
  3647. # Data migration: queue items written before #2551 carry every selected plate's
  3648. # filament overrides, so a force-colour plate waits on colours it never prints.
  3649. await _migrate_scope_force_color_overrides_to_plate(conn)
  3650. # Backfill NULL print_archives.created_at — older rows (and rows imported
  3651. # via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
  3652. # because the column was originally created without a DEFAULT clause and
  3653. # server_default=func.now() only fires at table creation, not column
  3654. # population. The list_archives response model requires a datetime, so a
  3655. # single NULL row 500s the whole endpoint (#1732).
  3656. async with conn.begin_nested():
  3657. if is_sqlite():
  3658. await conn.execute(
  3659. text(
  3660. "UPDATE print_archives "
  3661. "SET created_at = COALESCE(completed_at, started_at, datetime('now')) "
  3662. "WHERE created_at IS NULL"
  3663. )
  3664. )
  3665. else:
  3666. await conn.execute(
  3667. text(
  3668. "UPDATE print_archives "
  3669. "SET created_at = COALESCE(completed_at, started_at, NOW()) "
  3670. "WHERE created_at IS NULL"
  3671. )
  3672. )
  3673. # Migration: structured storage locations (#1004). Flat catalog of physical
  3674. # shelves/drawers; spool.location_id FK with storage_location kept denormalized.
  3675. await _safe_execute(
  3676. conn,
  3677. """
  3678. CREATE TABLE IF NOT EXISTS locations (
  3679. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3680. name VARCHAR(255) NOT NULL UNIQUE,
  3681. name_key VARCHAR(255),
  3682. identifier VARCHAR(100),
  3683. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3684. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  3685. )
  3686. """
  3687. if is_sqlite()
  3688. else """
  3689. CREATE TABLE IF NOT EXISTS locations (
  3690. id SERIAL PRIMARY KEY,
  3691. name VARCHAR(255) NOT NULL UNIQUE,
  3692. name_key VARCHAR(255),
  3693. identifier VARCHAR(100),
  3694. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3695. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  3696. )
  3697. """,
  3698. )
  3699. await _safe_execute(conn, "ALTER TABLE locations ADD COLUMN name_key VARCHAR(255)")
  3700. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_locations_name_key ON locations (name_key)")
  3701. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN location_id INTEGER REFERENCES locations(id)")
  3702. await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_location_id ON spool (location_id)")
  3703. # Backfill name_key on legacy rows FIRST. If a pre-existing locations
  3704. # row was manually inserted before this migration ran, its name_key is
  3705. # NULL. The dedup INSERT below would then be silently skipped by
  3706. # UNIQUE(name) (legacy row already has the name), AND the spool-link
  3707. # UPDATE that joins on name_key would miss it. Doing this backfill BEFORE
  3708. # the INSERT keeps the join consistent on both branches of the migration.
  3709. async with conn.begin_nested():
  3710. await conn.execute(
  3711. text(
  3712. """
  3713. UPDATE locations
  3714. SET name_key = LOWER(TRIM(name))
  3715. WHERE name_key IS NULL OR TRIM(name_key) = ''
  3716. """
  3717. )
  3718. )
  3719. # Backfill locations from existing free-text storage_location values.
  3720. # GROUP BY name_key so case variants ("Drybox 1" / "DRYBOX 1") collapse to
  3721. # one row; INSERT OR IGNORE / ON CONFLICT keeps the migration idempotent.
  3722. _location_backfill_sql = (
  3723. """
  3724. INSERT OR IGNORE INTO locations (name, name_key, created_at, updated_at)
  3725. SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  3726. FROM spool
  3727. WHERE TRIM(COALESCE(storage_location, '')) != ''
  3728. GROUP BY LOWER(TRIM(storage_location))
  3729. """
  3730. if is_sqlite()
  3731. else """
  3732. INSERT INTO locations (name, name_key, created_at, updated_at)
  3733. SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  3734. FROM spool
  3735. WHERE TRIM(COALESCE(storage_location, '')) != ''
  3736. GROUP BY LOWER(TRIM(storage_location))
  3737. ON CONFLICT (name_key) DO NOTHING
  3738. """
  3739. )
  3740. async with conn.begin_nested():
  3741. await conn.execute(text(_location_backfill_sql))
  3742. await conn.execute(
  3743. text(
  3744. """
  3745. UPDATE spool
  3746. SET location_id = (
  3747. SELECT l.id FROM locations l
  3748. WHERE l.name_key = LOWER(TRIM(spool.storage_location))
  3749. LIMIT 1
  3750. )
  3751. WHERE TRIM(COALESCE(storage_location, '')) != ''
  3752. AND location_id IS NULL
  3753. """
  3754. )
  3755. )
  3756. # Sanity check: any spools that still have a free-text storage_location
  3757. # but no location_id link mean a row slipped through the dedup INSERT
  3758. # (most likely a pre-existing manually-inserted locations row with a
  3759. # hostile name shape that the UNIQUE(name) check tripped on). Surface
  3760. # the count so ops can investigate — the user won't see those spools in
  3761. # location-filtered queries until they're manually linked or re-saved.
  3762. orphan_count_row = await conn.execute(
  3763. text("SELECT COUNT(*) FROM spool WHERE TRIM(COALESCE(storage_location, '')) != '' AND location_id IS NULL")
  3764. )
  3765. orphan_count = orphan_count_row.scalar() or 0
  3766. if orphan_count:
  3767. logger.warning(
  3768. "Storage-location migration left %d spool(s) with free-text storage_location "
  3769. "but no location_id link. Re-save those spools or merge the orphaned location "
  3770. "names manually.",
  3771. orphan_count,
  3772. )
  3773. # Migration: Add on_ai_failure_detection column to notification_providers (#1794).
  3774. # Splits Obico AI failure detection out of the multiplexed on_printer_error
  3775. # event so users can subscribe to spaghetti alerts independently of HMS
  3776. # hardware-error alerts. Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  3777. if is_sqlite():
  3778. await _safe_execute(
  3779. conn,
  3780. "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT 0",
  3781. )
  3782. else:
  3783. await _safe_execute(
  3784. conn,
  3785. "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
  3786. )
  3787. # Migration: Add gate_acknowledged column to print_queue (#1818). Cleared
  3788. # by the per-printer "Resume after failure" action so the scheduler's
  3789. # `_check_previous_success` lookback skips this row. Postgres rejects
  3790. # `DEFAULT 0` for BOOLEAN columns.
  3791. if is_sqlite():
  3792. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0")
  3793. else:
  3794. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
  3795. # Migration: Add is_autologin column to oidc_providers (#1589). Postgres
  3796. # rejects ``DEFAULT 0`` for BOOLEAN columns.
  3797. if is_sqlite():
  3798. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
  3799. else:
  3800. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
  3801. # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
  3802. # the start-watchdog reverted the row from 'printing' back to 'pending' so a
  3803. # printer that never actually starts stops being retried forever. INTEGER
  3804. # DEFAULT 0 is spelled identically on SQLite and Postgres — no dialect branch.
  3805. # Verified on both dialects: ADD COLUMN ... DEFAULT 0 backfills existing rows,
  3806. # so no separate UPDATE is needed (and _safe_execute is DDL-only — see its
  3807. # docstring). The scheduler reads it as `(item.dispatch_attempts or 0) + 1`
  3808. # regardless, so even a NULL row could not disable the retry cap.
  3809. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
  3810. # Backfill: copy the selected plate from linked queue rows onto their archives
  3811. # (#2603). Recovers the plate for archives created before print_archives had a
  3812. # plate_id column, wherever the queue row still points at the archive and
  3813. # carries a plate. Runs here — after every print_queue column migration
  3814. # (plate_id, archive_id) — because it reads print_queue.plate_id, which is
  3815. # added far earlier in this function but must exist before this DML runs on a
  3816. # first-ever migration pass. Correlated-subquery form so the DML is identical
  3817. # on SQLite and Postgres; the WHERE plate_id IS NULL guard makes it idempotent
  3818. # and keeps it from clobbering values set on later runs.
  3819. async with conn.begin_nested():
  3820. # Only do any work (and, on SQLite, the FTS rebuild below) when there is
  3821. # actually a plate to recover — so this is a one-off cost on the upgrade
  3822. # boot, not an every-boot tax once every archive is backfilled.
  3823. has_work = (
  3824. await conn.execute(
  3825. text(
  3826. "SELECT 1 FROM print_archives a "
  3827. "JOIN print_queue q ON q.archive_id = a.id "
  3828. "WHERE a.plate_id IS NULL AND q.plate_id IS NOT NULL "
  3829. "LIMIT 1"
  3830. )
  3831. )
  3832. ).first() is not None
  3833. if has_work:
  3834. # SQLite: print_archives has an external-content FTS index (archive_fts,
  3835. # created above) whose AFTER UPDATE trigger issues an FTS 'delete' for
  3836. # the row. Archives created before that table existed were never indexed
  3837. # (its creation runs no rebuild), and updating an un-indexed row trips
  3838. # "database disk image is malformed". plate_id isn't even an FTS column,
  3839. # so the trigger's re-index is pointless here — but it still fires. Rebuild
  3840. # the index from the content table first so every row is present and the
  3841. # trigger's 'delete' is well-defined. Postgres has no such FTS table.
  3842. if is_sqlite():
  3843. await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
  3844. await conn.execute(
  3845. text(
  3846. "UPDATE print_archives "
  3847. "SET plate_id = ("
  3848. " SELECT pq.plate_id FROM print_queue pq "
  3849. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
  3850. " LIMIT 1"
  3851. ") "
  3852. "WHERE plate_id IS NULL "
  3853. "AND EXISTS ("
  3854. " SELECT 1 FROM print_queue pq "
  3855. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
  3856. ")"
  3857. )
  3858. )
  3859. # Migration: repair completed print-log rows that stored a multi-plate 3MF's
  3860. # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
  3861. # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
  3862. await _migrate_scope_run_filament_to_plate(conn)
  3863. # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
  3864. # whether a plug actually feeds the printer's own power — only then may an
  3865. # auto-off mark the printer offline. Defaults to true so existing plugs
  3866. # keep the previous behaviour; accessory plugs (filter fan, lights) are
  3867. # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
  3868. # no true/false keyword), so the default is dialect-branched.
  3869. if is_sqlite():
  3870. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
  3871. else:
  3872. await _safe_execute(
  3873. conn,
  3874. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
  3875. )
  3876. # Migration: real filesystem mtime for library files/folders (#2680). The
  3877. # folder tree's "sort by recent activity" and the file pane's date sort must
  3878. # track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
  3879. # a bulk external scan every row's ``updated_at`` is the same scan instant, so
  3880. # ordering was arbitrary. Nullable; the timestamp type differs by dialect
  3881. # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
  3882. # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
  3883. # built the column, so the ALTER is swallowed as "already exists".
  3884. if is_sqlite():
  3885. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
  3886. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
  3887. else:
  3888. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
  3889. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
  3890. # Migration: Disambiguate the four ``user_print_*`` notification template
  3891. # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
  3892. await _migrate_rename_user_print_template_names(conn)
  3893. # Migration: per-file print progress inside a project (#1897).
  3894. # - print_archives.library_file_id: which library file a queued run was
  3895. # dispatched from; nullable, no FK constraint added to existing tables
  3896. # (SQLite can't ADD CONSTRAINT; the application uses SET NULL semantics
  3897. # via the ORM on fresh installs and tolerates dangling ids by matching
  3898. # hash/filename as fallback anyway).
  3899. # - projects.target_sets: optional copies-per-file target. INTEGER is
  3900. # spelled identically on SQLite and Postgres — no dialect branch.
  3901. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
  3902. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
  3903. # Migration: persist the timelapse snapshot-diff baseline (#2704).
  3904. # The list of video filenames present on the printer when the print began,
  3905. # so the diff survives a restart and the manual scan can use it instead of
  3906. # the clock-based matching that a LAN-only printer defeats. No dialect
  3907. # branch: SQLAlchemy renders this column as `JSON` on both SQLite and
  3908. # Postgres for a fresh install (checked with CreateTable against each
  3909. # dialect), so spelling the ALTER the same way keeps a migrated database
  3910. # identical to a new one. Matching matters on Postgres in particular —
  3911. # asyncpg binds the serialised value as json and would reject a TEXT column
  3912. # (mirrors the `projects.attachments JSON` migration above).
  3913. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
  3914. # Migration: plate-clear-required notification opt-in (#2525). Off by
  3915. # default — it fires after every print, at the same moment as the
  3916. # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
  3917. if is_sqlite():
  3918. await _safe_execute(
  3919. conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
  3920. )
  3921. else:
  3922. await _safe_execute(
  3923. conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
  3924. )
  3925. _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
  3926. ("user_print_start", "User Print Started", "User Print Started Email"),
  3927. ("user_print_complete", "User Print Completed", "User Print Completed Email"),
  3928. ("user_print_failed", "User Print Failed", "User Print Failed Email"),
  3929. ("user_print_stopped", "User Print Stopped", "User Print Stopped Email"),
  3930. )
  3931. async def _migrate_rename_user_print_template_names(conn) -> None:
  3932. """Append " Email" to the four ``user_print_*`` notification template names (#1792).
  3933. The provider-level "Print Completed" and the per-user "User Print Completed"
  3934. rows were visually indistinguishable in the Message Templates list because
  3935. the seed name lacked the suffix that the EVENT_NAMES display map in
  3936. routes/notification_templates.py already uses ("User Print Completed Email").
  3937. Renames only rows where ``name`` is still the old default — admins who
  3938. renamed the template themselves keep their custom name. Standard SQL
  3939. UPDATE works on both SQLite and Postgres.
  3940. """
  3941. from sqlalchemy import text
  3942. async with conn.begin_nested():
  3943. for event_type, old_name, new_name in _USER_PRINT_TEMPLATE_RENAMES:
  3944. await conn.execute(
  3945. text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
  3946. {"new": new_name, "et": event_type, "old": old_name},
  3947. )
  3948. async def seed_notification_templates():
  3949. """Seed default notification templates if they don't exist."""
  3950. from sqlalchemy import select
  3951. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  3952. async with async_session() as session:
  3953. # Get existing template event types
  3954. result = await session.execute(select(NotificationTemplate.event_type))
  3955. existing_types = {row[0] for row in result.fetchall()}
  3956. if not existing_types:
  3957. # No templates exist - insert all defaults
  3958. for template_data in DEFAULT_TEMPLATES:
  3959. template = NotificationTemplate(
  3960. event_type=template_data["event_type"],
  3961. name=template_data["name"],
  3962. title_template=template_data["title_template"],
  3963. body_template=template_data["body_template"],
  3964. is_default=True,
  3965. )
  3966. session.add(template)
  3967. else:
  3968. # Templates exist - only add missing ones
  3969. for template_data in DEFAULT_TEMPLATES:
  3970. if template_data["event_type"] not in existing_types:
  3971. template = NotificationTemplate(
  3972. event_type=template_data["event_type"],
  3973. name=template_data["name"],
  3974. title_template=template_data["title_template"],
  3975. body_template=template_data["body_template"],
  3976. is_default=True,
  3977. )
  3978. session.add(template)
  3979. await session.commit()
  3980. async def seed_default_groups():
  3981. """Seed default groups and migrate existing users to appropriate groups.
  3982. Creates the default system groups (Administrators, Operators, Viewers) if they
  3983. don't exist, then migrates existing users:
  3984. - Users with role='admin' -> Administrators group
  3985. - Users with role='user' -> Operators group
  3986. Also migrates old permissions to new ownership-based permissions (Issue #205).
  3987. """
  3988. import logging
  3989. from sqlalchemy import select
  3990. from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
  3991. from backend.app.models.group import Group
  3992. from backend.app.models.user import User
  3993. logger = logging.getLogger(__name__)
  3994. # Map old permissions to new ones for migration
  3995. # Administrators get *_all permissions, Operators get *_own permissions.
  3996. #
  3997. # NOTE on the read-flag asymmetry: write permissions (`update`, `delete`,
  3998. # `reprint`) are removed from the legacy flag and remapped to the OWN/ALL
  3999. # split — the legacy flag is dead on the API side. Read permissions are
  4000. # different: the frontend still gates UI actions (download buttons in
  4001. # ArchivesPage, preview button in FileManagerPage) on the LEGACY
  4002. # `archives:read` / `library:read` / `queue:read` strings. For admin we
  4003. # therefore keep the legacy flag (the `*_all` companion gets added via the
  4004. # backfill block below). For non-admin roles the legacy IS renamed to
  4005. # `_own` — that closes the IDOR (operators with a custom `archives:read`
  4006. # row can no longer read cross-user data) and the UI gates degrade to
  4007. # disabled-button state until the frontend is migrated to also accept
  4008. # `_own` (separate change). See maziggy/bambuddy-security #2.
  4009. PERMISSION_MIGRATION_ALL = {
  4010. "queue:update": "queue:update_all",
  4011. "queue:delete": "queue:delete_all",
  4012. "archives:update": "archives:update_all",
  4013. "archives:delete": "archives:delete_all",
  4014. "archives:reprint": "archives:reprint_all",
  4015. "library:update": "library:update_all",
  4016. "library:delete": "library:delete_all",
  4017. }
  4018. PERMISSION_MIGRATION_OWN = {
  4019. "queue:update": "queue:update_own",
  4020. "queue:delete": "queue:delete_own",
  4021. # Read permissions: any role NOT flagged as Administrator gets
  4022. # ownership-scoped reads. Pre-existing custom roles with the legacy
  4023. # `*:read` flag silently saw every user's items; the OWN variant
  4024. # closes that IDOR. Roles that genuinely need cross-user visibility
  4025. # must be re-granted `*:read_all` explicitly by an administrator
  4026. # after upgrade — fail-closed by default (per CWE-636).
  4027. "queue:read": "queue:read_own",
  4028. "archives:update": "archives:update_own",
  4029. "archives:delete": "archives:delete_own",
  4030. "archives:reprint": "archives:reprint_own",
  4031. "archives:read": "archives:read_own",
  4032. "library:update": "library:update_own",
  4033. "library:delete": "library:delete_own",
  4034. "library:read": "library:read_own",
  4035. }
  4036. FINANCE_PERMISSION_MIGRATION = {
  4037. "finance:read_own": "cost_centers:read_own",
  4038. "finance:read_all": "cost_centers:read_all",
  4039. "finance:transactions:create": "cost_centers:modify",
  4040. "finance:create_transactions": "cost_centers:modify",
  4041. "finance:createTransactions:create": "cost_centers:modify",
  4042. "finance:cost_centers:create": "cost_centers:create",
  4043. "finance:cost_centers:update": "cost_centers:modify",
  4044. "finance:cost_centers:assign_users": "cost_centers:modify",
  4045. "finance:budgets:update": "cost_centers:modify",
  4046. }
  4047. async with async_session() as session:
  4048. # Get existing groups
  4049. result = await session.execute(select(Group))
  4050. existing_groups = {group.name: group for group in result.scalars().all()}
  4051. # Create default groups if they don't exist
  4052. groups_created = []
  4053. for group_name, group_config in DEFAULT_GROUPS.items():
  4054. if group_name not in existing_groups:
  4055. group = Group(
  4056. name=group_name,
  4057. description=group_config["description"],
  4058. permissions=group_config["permissions"],
  4059. is_system=group_config["is_system"],
  4060. )
  4061. session.add(group)
  4062. groups_created.append(group_name)
  4063. logger.info("Created default group: %s", group_name)
  4064. else:
  4065. # Migrate existing group's permissions from old to new format
  4066. group = existing_groups[group_name]
  4067. if group.permissions:
  4068. updated = False
  4069. new_permissions = list(group.permissions)
  4070. # Determine which migration map to use based on group
  4071. migration_map = (
  4072. PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
  4073. )
  4074. for old_perm, new_perm in migration_map.items():
  4075. if old_perm in new_permissions:
  4076. new_permissions.remove(old_perm)
  4077. if new_perm not in new_permissions:
  4078. new_permissions.append(new_perm)
  4079. updated = True
  4080. logger.info(
  4081. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  4082. )
  4083. for old_perm, new_perm in FINANCE_PERMISSION_MIGRATION.items():
  4084. if old_perm in new_permissions:
  4085. new_permissions.remove(old_perm)
  4086. if new_perm not in new_permissions:
  4087. new_permissions.append(new_perm)
  4088. updated = True
  4089. logger.info(
  4090. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  4091. )
  4092. # For Administrators, also ensure they get *_all permissions if they have any new *_own
  4093. if group_name == "Administrators":
  4094. for _own_perm, all_perm in [
  4095. ("queue:update_own", "queue:update_all"),
  4096. ("queue:delete_own", "queue:delete_all"),
  4097. ("queue:read_own", "queue:read_all"),
  4098. ("archives:update_own", "archives:update_all"),
  4099. ("archives:delete_own", "archives:delete_all"),
  4100. ("archives:reprint_own", "archives:reprint_all"),
  4101. ("archives:read_own", "archives:read_all"),
  4102. ("library:update_own", "library:update_all"),
  4103. ("library:delete_own", "library:delete_all"),
  4104. ("library:read_own", "library:read_all"),
  4105. ]:
  4106. # Add *_all if not present
  4107. if all_perm not in new_permissions:
  4108. new_permissions.append(all_perm)
  4109. updated = True
  4110. if updated:
  4111. group.permissions = new_permissions
  4112. await session.commit()
  4113. # Migrate new permissions: grant printers:clear_plate to all groups with printers:control
  4114. result = await session.execute(select(Group))
  4115. all_groups = result.scalars().all()
  4116. for group in all_groups:
  4117. if (
  4118. group.permissions
  4119. and "printers:control" in group.permissions
  4120. and "printers:clear_plate" not in group.permissions
  4121. ):
  4122. group.permissions = [*group.permissions, "printers:clear_plate"]
  4123. logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name)
  4124. await session.commit()
  4125. # Migrate new permissions for MakerWorld integration: groups that
  4126. # already have library:upload (i.e. can write to the library) are
  4127. # the correct audience for makerworld:view + makerworld:import, and
  4128. # groups that only have library:read get makerworld:view (browse
  4129. # only). Matches the intent of DEFAULT_GROUPS without clobbering
  4130. # any user-customised permission lists.
  4131. result = await session.execute(select(Group))
  4132. for group in result.scalars().all():
  4133. if not group.permissions:
  4134. continue
  4135. perms = list(group.permissions)
  4136. changed = False
  4137. if "library:upload" in perms:
  4138. for new_perm in ("makerworld:view", "makerworld:import"):
  4139. if new_perm not in perms:
  4140. perms.append(new_perm)
  4141. changed = True
  4142. logger.info("Added %s to group '%s' (has library:upload)", new_perm, group.name)
  4143. elif "library:read" in perms and "makerworld:view" not in perms:
  4144. perms.append("makerworld:view")
  4145. changed = True
  4146. logger.info("Added makerworld:view to group '%s' (has library:read)", group.name)
  4147. if changed:
  4148. group.permissions = perms
  4149. await session.commit()
  4150. # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
  4151. # Administrators' contract is full access to every feature — fresh
  4152. # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
  4153. # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
  4154. # whatever permission set existed when they were first seeded, so a
  4155. # newly-added Permission enum member silently leaves admins gated out
  4156. # of the feature it controls.
  4157. #
  4158. # Generalises the previous one-off admin backfills (library:purge,
  4159. # archives:purge, the OWN/ALL read-flag set + legacy read flags,
  4160. # orca_cloud:auth, printer_sensor_history:read, …): every current
  4161. # Permission enum value is appended to the admin group if missing.
  4162. # Additive only — never removes a permission an operator added by
  4163. # hand. Run AFTER the legacy-rename migration above so the renamed
  4164. # OWN/ALL variants land in the group before the sync sees them.
  4165. result = await session.execute(select(Group).where(Group.name == "Administrators"))
  4166. admin_group = result.scalar_one_or_none()
  4167. if admin_group and admin_group.permissions is not None:
  4168. perms = list(admin_group.permissions)
  4169. added = False
  4170. for new_perm in ALL_PERMISSIONS:
  4171. if new_perm not in perms:
  4172. perms.append(new_perm)
  4173. added = True
  4174. logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
  4175. if added:
  4176. admin_group.permissions = perms
  4177. await session.commit()
  4178. # Same OWN-tier backfill for non-admin system groups. Operators and
  4179. # Viewers are seeded with _own on fresh installs (see DEFAULT_GROUPS),
  4180. # but the legacy-rename migration above won't run on a role that
  4181. # didn't carry the legacy `archives:read` flag. Without this block,
  4182. # an existing Operators row whose permissions list lacks the legacy
  4183. # flag would never get archives:read_own and operators would lose
  4184. # read access after upgrade. Re-check by group name so customised
  4185. # rows still get the correct OWN tier on next startup.
  4186. #
  4187. # Operators also get orca_cloud:auth backfilled — fresh installs now
  4188. # include it in the DEFAULT_GROUPS bootstrap, so this keeps upgrades
  4189. # consistent. Viewers do NOT get orca_cloud:auth (read-only role,
  4190. # not expected to author slicer presets / sync to Orca Cloud).
  4191. for non_admin_group_name in ("Operators", "Viewers"):
  4192. grp = (await session.execute(select(Group).where(Group.name == non_admin_group_name))).scalar_one_or_none()
  4193. if grp is None or grp.permissions is None:
  4194. continue
  4195. perms = list(grp.permissions)
  4196. changed = False
  4197. for own_perm in ("archives:read_own", "library:read_own", "queue:read_own"):
  4198. if own_perm not in perms:
  4199. perms.append(own_perm)
  4200. changed = True
  4201. logger.info("Added %s to %s group (backfill)", own_perm, non_admin_group_name)
  4202. if non_admin_group_name == "Operators" and "orca_cloud:auth" not in perms:
  4203. perms.append("orca_cloud:auth")
  4204. changed = True
  4205. logger.info("Added orca_cloud:auth to Operators group (backfill)")
  4206. if changed:
  4207. grp.permissions = perms
  4208. await session.commit()
  4209. # Backfill inventory forecast permissions for existing groups.
  4210. # inventory:forecast_read was added after initial seeding, so groups
  4211. # that already have inventory:read (or inventory:update) need it added.
  4212. # inventory:forecast_write goes to any group with inventory:update.
  4213. result = await session.execute(select(Group))
  4214. for group in result.scalars().all():
  4215. if not group.permissions:
  4216. continue
  4217. perms = list(group.permissions)
  4218. changed = False
  4219. if "inventory:read" in perms and "inventory:forecast_read" not in perms:
  4220. perms.append("inventory:forecast_read")
  4221. changed = True
  4222. logger.info("Added inventory:forecast_read to group '%s' (backfill)", group.name)
  4223. if "inventory:update" in perms and "inventory:forecast_write" not in perms:
  4224. perms.append("inventory:forecast_write")
  4225. changed = True
  4226. logger.info("Added inventory:forecast_write to group '%s' (backfill)", group.name)
  4227. if changed:
  4228. group.permissions = perms
  4229. await session.commit()
  4230. # Backfill pipeline permissions (#1425) for non-admin groups.
  4231. # Administrators is handled by the ALL_PERMISSIONS sync above.
  4232. # - Operators: all three (matches fresh-install DEFAULT_GROUPS)
  4233. # - Any other group with library:read_own or settings:read:
  4234. # pipelines:read only
  4235. result = await session.execute(select(Group))
  4236. for group in result.scalars().all():
  4237. if not group.permissions or group.name == "Administrators":
  4238. continue
  4239. perms = list(group.permissions)
  4240. changed = False
  4241. if group.name == "Operators":
  4242. for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
  4243. if new_perm not in perms:
  4244. perms.append(new_perm)
  4245. changed = True
  4246. logger.info("Added %s to Operators group (backfill)", new_perm)
  4247. elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
  4248. perms.append("pipelines:read")
  4249. changed = True
  4250. logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
  4251. if changed:
  4252. group.permissions = perms
  4253. await session.commit()
  4254. # Migrate existing users to groups if they're not already in any group
  4255. if groups_created:
  4256. # Refresh to get newly created groups
  4257. admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
  4258. admin_group = admin_result.scalar_one_or_none()
  4259. operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
  4260. operators_group = operators_result.scalar_one_or_none()
  4261. # Get all users
  4262. users_result = await session.execute(select(User))
  4263. users = users_result.scalars().all()
  4264. for user in users:
  4265. # Skip if user already has groups
  4266. if user.groups:
  4267. continue
  4268. if user.role == "admin" and admin_group:
  4269. user.groups.append(admin_group)
  4270. logger.info("Migrated admin user '%s' to Administrators group", user.username)
  4271. elif operators_group:
  4272. user.groups.append(operators_group)
  4273. logger.info("Migrated user '%s' to Operators group", user.username)
  4274. await session.commit()
  4275. async def seed_spool_catalog():
  4276. """Seed the spool catalog with default entries if empty."""
  4277. import logging
  4278. from sqlalchemy import func, select
  4279. from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG
  4280. from backend.app.models.spool_catalog import SpoolCatalogEntry
  4281. logger = logging.getLogger(__name__)
  4282. async with async_session() as session:
  4283. result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))
  4284. count = result.scalar() or 0
  4285. if count > 0:
  4286. return # Already seeded
  4287. for name, weight in DEFAULT_SPOOL_CATALOG:
  4288. session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  4289. await session.commit()
  4290. logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG))
  4291. async def seed_color_catalog():
  4292. """Seed the color catalog with default entries if empty."""
  4293. import logging
  4294. from sqlalchemy import func, select
  4295. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
  4296. from backend.app.models.color_catalog import ColorCatalogEntry
  4297. logger = logging.getLogger(__name__)
  4298. async with async_session() as session:
  4299. result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))
  4300. count = result.scalar() or 0
  4301. if count > 0:
  4302. return # Already seeded
  4303. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  4304. session.add(
  4305. ColorCatalogEntry(
  4306. manufacturer=manufacturer,
  4307. color_name=color_name,
  4308. hex_color=hex_color,
  4309. material=material,
  4310. is_default=True,
  4311. )
  4312. )
  4313. await session.commit()
  4314. logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
  4315. async def repair_wallet_ledger():
  4316. """Repair wallet ledger balance_after values.
  4317. This is called during database initialization to ensure all balance_after
  4318. values are correct after code changes. Fixes old balance_after values to:
  4319. - Personal transactions: per-user running balance
  4320. - Cost-center transactions: global running balance for the entire cost center
  4321. Also updates UserWallet.balance to match final personal transaction balance.
  4322. """
  4323. from sqlalchemy import select
  4324. from backend.app.models.finance import WalletTransaction
  4325. async with async_session() as session:
  4326. # Check if there are any transactions first
  4327. result = await session.execute(select(WalletTransaction).limit(1))
  4328. if not result.scalar_one_or_none():
  4329. logger.info("No wallet transactions found, skipping ledger rebuild")
  4330. return
  4331. updated_count = await repair_wallet_ledger_internal(session)
  4332. await session.commit()
  4333. if updated_count > 0:
  4334. logger.info("Repaired wallet ledger: updated %d transactions/wallets", updated_count)
  4335. async def repair_wallet_ledger_internal(session: AsyncSession):
  4336. """Internal helper that repairs wallet ledger using an existing session.
  4337. Used by API endpoints that need to rebuild the ledger within their own transaction.
  4338. """
  4339. from sqlalchemy import select
  4340. from backend.app.models.finance import UserWallet, WalletTransaction
  4341. # Get ALL transactions sorted by timestamp
  4342. result = await session.execute(
  4343. select(WalletTransaction).order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  4344. )
  4345. all_transactions = result.scalars().all()
  4346. if not all_transactions:
  4347. return 0
  4348. # Build running balances per (user, cost_center_id) pair
  4349. cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
  4350. user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
  4351. tx_updates: list[tuple[WalletTransaction, float]] = []
  4352. for tx in all_transactions:
  4353. if tx.cost_center_id is None:
  4354. # Personal transaction: per-user running balance
  4355. current = user_personal_balances.get(tx.user_id, 0.0)
  4356. new_balance = current + float(tx.amount)
  4357. user_personal_balances[tx.user_id] = new_balance
  4358. tx_updates.append((tx, new_balance))
  4359. else:
  4360. # Cost-center transaction: global running balance for this cost center
  4361. current = cc_running_balances.get(tx.cost_center_id, 0.0)
  4362. new_balance = current + float(tx.amount)
  4363. cc_running_balances[tx.cost_center_id] = new_balance
  4364. tx_updates.append((tx, new_balance))
  4365. # Update all transactions with the new balance_after values
  4366. updated_count = 0
  4367. for tx, new_balance in tx_updates:
  4368. if tx.balance_after != new_balance:
  4369. tx.balance_after = new_balance
  4370. session.add(tx)
  4371. updated_count += 1
  4372. # Update UserWallet balances to match final personal balances
  4373. for user_id, balance in user_personal_balances.items():
  4374. wallet_result = await session.execute(select(UserWallet).where(UserWallet.user_id == user_id))
  4375. wallet = wallet_result.scalar_one_or_none()
  4376. if wallet and wallet.balance != balance:
  4377. wallet.balance = balance
  4378. session.add(wallet)
  4379. updated_count += 1
  4380. await session.flush()
  4381. return updated_count