database.py 380 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990199119921993199419951996199719981999200020012002200320042005200620072008200920102011201220132014201520162017201820192020202120222023202420252026202720282029203020312032203320342035203620372038203920402041204220432044204520462047204820492050205120522053205420552056205720582059206020612062206320642065206620672068206920702071207220732074207520762077207820792080208120822083208420852086208720882089209020912092209320942095209620972098209921002101210221032104210521062107210821092110211121122113211421152116211721182119212021212122212321242125212621272128212921302131213221332134213521362137213821392140214121422143214421452146214721482149215021512152215321542155215621572158215921602161216221632164216521662167216821692170217121722173217421752176217721782179218021812182218321842185218621872188218921902191219221932194219521962197219821992200220122022203220422052206220722082209221022112212221322142215221622172218221922202221222222232224222522262227222822292230223122322233223422352236223722382239224022412242224322442245224622472248224922502251225222532254225522562257225822592260226122622263226422652266226722682269227022712272227322742275227622772278227922802281228222832284228522862287228822892290229122922293229422952296229722982299230023012302230323042305230623072308230923102311231223132314231523162317231823192320232123222323232423252326232723282329233023312332233323342335233623372338233923402341234223432344234523462347234823492350235123522353235423552356235723582359236023612362236323642365236623672368236923702371237223732374237523762377237823792380238123822383238423852386238723882389239023912392239323942395239623972398239924002401240224032404240524062407240824092410241124122413241424152416241724182419242024212422242324242425242624272428242924302431243224332434243524362437243824392440244124422443244424452446244724482449245024512452245324542455245624572458245924602461246224632464246524662467246824692470247124722473247424752476247724782479248024812482248324842485248624872488248924902491249224932494249524962497249824992500250125022503250425052506250725082509251025112512251325142515251625172518251925202521252225232524252525262527252825292530253125322533253425352536253725382539254025412542254325442545254625472548254925502551255225532554255525562557255825592560256125622563256425652566256725682569257025712572257325742575257625772578257925802581258225832584258525862587258825892590259125922593259425952596259725982599260026012602260326042605260626072608260926102611261226132614261526162617261826192620262126222623262426252626262726282629263026312632263326342635263626372638263926402641264226432644264526462647264826492650265126522653265426552656265726582659266026612662266326642665266626672668266926702671267226732674267526762677267826792680268126822683268426852686268726882689269026912692269326942695269626972698269927002701270227032704270527062707270827092710271127122713271427152716271727182719272027212722272327242725272627272728272927302731273227332734273527362737273827392740274127422743274427452746274727482749275027512752275327542755275627572758275927602761276227632764276527662767276827692770277127722773277427752776277727782779278027812782278327842785278627872788278927902791279227932794279527962797279827992800280128022803280428052806280728082809281028112812281328142815281628172818281928202821282228232824282528262827282828292830283128322833283428352836283728382839284028412842284328442845284628472848284928502851285228532854285528562857285828592860286128622863286428652866286728682869287028712872287328742875287628772878287928802881288228832884288528862887288828892890289128922893289428952896289728982899290029012902290329042905290629072908290929102911291229132914291529162917291829192920292129222923292429252926292729282929293029312932293329342935293629372938293929402941294229432944294529462947294829492950295129522953295429552956295729582959296029612962296329642965296629672968296929702971297229732974297529762977297829792980298129822983298429852986298729882989299029912992299329942995299629972998299930003001300230033004300530063007300830093010301130123013301430153016301730183019302030213022302330243025302630273028302930303031303230333034303530363037303830393040304130423043304430453046304730483049305030513052305330543055305630573058305930603061306230633064306530663067306830693070307130723073307430753076307730783079308030813082308330843085308630873088308930903091309230933094309530963097309830993100310131023103310431053106310731083109311031113112311331143115311631173118311931203121312231233124312531263127312831293130313131323133313431353136313731383139314031413142314331443145314631473148314931503151315231533154315531563157315831593160316131623163316431653166316731683169317031713172317331743175317631773178317931803181318231833184318531863187318831893190319131923193319431953196319731983199320032013202320332043205320632073208320932103211321232133214321532163217321832193220322132223223322432253226322732283229323032313232323332343235323632373238323932403241324232433244324532463247324832493250325132523253325432553256325732583259326032613262326332643265326632673268326932703271327232733274327532763277327832793280328132823283328432853286328732883289329032913292329332943295329632973298329933003301330233033304330533063307330833093310331133123313331433153316331733183319332033213322332333243325332633273328332933303331333233333334333533363337333833393340334133423343334433453346334733483349335033513352335333543355335633573358335933603361336233633364336533663367336833693370337133723373337433753376337733783379338033813382338333843385338633873388338933903391339233933394339533963397339833993400340134023403340434053406340734083409341034113412341334143415341634173418341934203421342234233424342534263427342834293430343134323433343434353436343734383439344034413442344334443445344634473448344934503451345234533454345534563457345834593460346134623463346434653466346734683469347034713472347334743475347634773478347934803481348234833484348534863487348834893490349134923493349434953496349734983499350035013502350335043505350635073508350935103511351235133514351535163517351835193520352135223523352435253526352735283529353035313532353335343535353635373538353935403541354235433544354535463547354835493550355135523553355435553556355735583559356035613562356335643565356635673568356935703571357235733574357535763577357835793580358135823583358435853586358735883589359035913592359335943595359635973598359936003601360236033604360536063607360836093610361136123613361436153616361736183619362036213622362336243625362636273628362936303631363236333634363536363637363836393640364136423643364436453646364736483649365036513652365336543655365636573658365936603661366236633664366536663667366836693670367136723673367436753676367736783679368036813682368336843685368636873688368936903691369236933694369536963697369836993700370137023703370437053706370737083709371037113712371337143715371637173718371937203721372237233724372537263727372837293730373137323733373437353736373737383739374037413742374337443745374637473748374937503751375237533754375537563757375837593760376137623763376437653766376737683769377037713772377337743775377637773778377937803781378237833784378537863787378837893790379137923793379437953796379737983799380038013802380338043805380638073808380938103811381238133814381538163817381838193820382138223823382438253826382738283829383038313832383338343835383638373838383938403841384238433844384538463847384838493850385138523853385438553856385738583859386038613862386338643865386638673868386938703871387238733874387538763877387838793880388138823883388438853886388738883889389038913892389338943895389638973898389939003901390239033904390539063907390839093910391139123913391439153916391739183919392039213922392339243925392639273928392939303931393239333934393539363937393839393940394139423943394439453946394739483949395039513952395339543955395639573958395939603961396239633964396539663967396839693970397139723973397439753976397739783979398039813982398339843985398639873988398939903991399239933994399539963997399839994000400140024003400440054006400740084009401040114012401340144015401640174018401940204021402240234024402540264027402840294030403140324033403440354036403740384039404040414042404340444045404640474048404940504051405240534054405540564057405840594060406140624063406440654066406740684069407040714072407340744075407640774078407940804081408240834084408540864087408840894090409140924093409440954096409740984099410041014102410341044105410641074108410941104111411241134114411541164117411841194120412141224123412441254126412741284129413041314132413341344135413641374138413941404141414241434144414541464147414841494150415141524153415441554156415741584159416041614162416341644165416641674168416941704171417241734174417541764177417841794180418141824183418441854186418741884189419041914192419341944195419641974198419942004201420242034204420542064207420842094210421142124213421442154216421742184219422042214222422342244225422642274228422942304231423242334234423542364237423842394240424142424243424442454246424742484249425042514252425342544255425642574258425942604261426242634264426542664267426842694270427142724273427442754276427742784279428042814282428342844285428642874288428942904291429242934294429542964297429842994300430143024303430443054306430743084309431043114312431343144315431643174318431943204321432243234324432543264327432843294330433143324333433443354336433743384339434043414342434343444345434643474348434943504351435243534354435543564357435843594360436143624363436443654366436743684369437043714372437343744375437643774378437943804381438243834384438543864387438843894390439143924393439443954396439743984399440044014402440344044405440644074408440944104411441244134414441544164417441844194420442144224423442444254426442744284429443044314432443344344435443644374438443944404441444244434444444544464447444844494450445144524453445444554456445744584459446044614462446344644465446644674468446944704471447244734474447544764477447844794480448144824483448444854486448744884489449044914492449344944495449644974498449945004501450245034504450545064507450845094510451145124513451445154516451745184519452045214522452345244525452645274528452945304531453245334534453545364537453845394540454145424543454445454546454745484549455045514552455345544555455645574558455945604561456245634564456545664567456845694570457145724573457445754576457745784579458045814582458345844585458645874588458945904591459245934594459545964597459845994600460146024603460446054606460746084609461046114612461346144615461646174618461946204621462246234624462546264627462846294630463146324633463446354636463746384639464046414642464346444645464646474648464946504651465246534654465546564657465846594660466146624663466446654666466746684669467046714672467346744675467646774678467946804681468246834684468546864687468846894690469146924693469446954696469746984699470047014702470347044705470647074708470947104711471247134714471547164717471847194720472147224723472447254726472747284729473047314732473347344735473647374738473947404741474247434744474547464747474847494750475147524753475447554756475747584759476047614762476347644765476647674768476947704771477247734774477547764777477847794780478147824783478447854786478747884789479047914792479347944795479647974798479948004801480248034804480548064807480848094810481148124813481448154816481748184819482048214822482348244825482648274828482948304831483248334834483548364837483848394840484148424843484448454846484748484849485048514852485348544855485648574858485948604861486248634864486548664867486848694870487148724873487448754876487748784879488048814882488348844885488648874888488948904891489248934894489548964897489848994900490149024903490449054906490749084909491049114912491349144915491649174918491949204921492249234924492549264927492849294930493149324933493449354936493749384939494049414942494349444945494649474948494949504951495249534954495549564957495849594960496149624963496449654966496749684969497049714972497349744975497649774978497949804981498249834984498549864987498849894990499149924993499449954996499749984999500050015002500350045005500650075008500950105011501250135014501550165017501850195020502150225023502450255026502750285029503050315032503350345035503650375038503950405041504250435044504550465047504850495050505150525053505450555056505750585059506050615062506350645065506650675068506950705071507250735074507550765077507850795080508150825083508450855086508750885089509050915092509350945095509650975098509951005101510251035104510551065107510851095110511151125113511451155116511751185119512051215122512351245125512651275128512951305131513251335134513551365137513851395140514151425143514451455146514751485149515051515152515351545155515651575158515951605161516251635164516551665167516851695170517151725173517451755176517751785179518051815182518351845185518651875188518951905191519251935194519551965197519851995200520152025203520452055206520752085209521052115212521352145215521652175218521952205221522252235224522552265227522852295230523152325233523452355236523752385239524052415242524352445245524652475248524952505251525252535254525552565257525852595260526152625263526452655266526752685269527052715272527352745275527652775278527952805281528252835284528552865287528852895290529152925293529452955296529752985299530053015302530353045305530653075308530953105311531253135314531553165317531853195320532153225323532453255326532753285329533053315332533353345335533653375338533953405341534253435344534553465347534853495350535153525353535453555356535753585359536053615362536353645365536653675368536953705371537253735374537553765377537853795380538153825383538453855386538753885389539053915392539353945395539653975398539954005401540254035404540554065407540854095410541154125413541454155416541754185419542054215422542354245425542654275428542954305431543254335434543554365437543854395440544154425443544454455446544754485449545054515452545354545455545654575458545954605461546254635464546554665467546854695470547154725473547454755476547754785479548054815482548354845485548654875488548954905491549254935494549554965497549854995500550155025503550455055506550755085509551055115512551355145515551655175518551955205521552255235524552555265527552855295530553155325533553455355536553755385539554055415542554355445545554655475548554955505551555255535554555555565557555855595560556155625563556455655566556755685569557055715572557355745575557655775578557955805581558255835584558555865587558855895590559155925593559455955596559755985599560056015602560356045605560656075608560956105611561256135614561556165617561856195620562156225623562456255626562756285629563056315632563356345635563656375638563956405641564256435644564556465647564856495650565156525653565456555656565756585659566056615662566356645665566656675668566956705671567256735674567556765677567856795680568156825683568456855686568756885689569056915692569356945695569656975698569957005701570257035704570557065707570857095710571157125713571457155716571757185719572057215722572357245725572657275728572957305731573257335734573557365737573857395740574157425743574457455746574757485749575057515752575357545755575657575758575957605761576257635764576557665767576857695770577157725773577457755776577757785779578057815782578357845785578657875788578957905791579257935794579557965797579857995800580158025803580458055806580758085809581058115812581358145815581658175818581958205821582258235824582558265827582858295830583158325833583458355836583758385839584058415842584358445845584658475848584958505851585258535854585558565857585858595860586158625863586458655866586758685869587058715872587358745875587658775878587958805881588258835884588558865887588858895890589158925893589458955896589758985899590059015902590359045905590659075908590959105911591259135914591559165917591859195920592159225923592459255926592759285929593059315932593359345935593659375938593959405941594259435944594559465947594859495950595159525953595459555956595759585959596059615962596359645965596659675968596959705971597259735974597559765977597859795980598159825983598459855986598759885989599059915992599359945995599659975998599960006001600260036004600560066007600860096010601160126013601460156016601760186019602060216022602360246025602660276028602960306031603260336034603560366037603860396040604160426043604460456046604760486049605060516052605360546055605660576058605960606061606260636064606560666067606860696070607160726073607460756076607760786079608060816082608360846085608660876088608960906091609260936094609560966097609860996100610161026103610461056106610761086109611061116112611361146115611661176118611961206121612261236124612561266127612861296130613161326133613461356136613761386139614061416142614361446145614661476148614961506151615261536154615561566157615861596160616161626163616461656166616761686169617061716172617361746175617661776178617961806181618261836184618561866187618861896190619161926193619461956196619761986199620062016202620362046205620662076208620962106211621262136214621562166217621862196220622162226223622462256226622762286229623062316232623362346235623662376238623962406241624262436244624562466247624862496250625162526253625462556256625762586259626062616262626362646265626662676268626962706271627262736274627562766277627862796280628162826283628462856286628762886289629062916292629362946295629662976298629963006301630263036304630563066307630863096310631163126313631463156316631763186319632063216322632363246325632663276328632963306331633263336334633563366337633863396340634163426343634463456346634763486349635063516352635363546355635663576358635963606361636263636364636563666367636863696370637163726373637463756376637763786379638063816382638363846385638663876388638963906391639263936394639563966397639863996400640164026403640464056406640764086409641064116412641364146415641664176418641964206421642264236424642564266427642864296430643164326433643464356436643764386439644064416442644364446445644664476448644964506451645264536454645564566457645864596460646164626463646464656466646764686469647064716472647364746475647664776478647964806481648264836484648564866487648864896490649164926493649464956496649764986499650065016502650365046505650665076508650965106511651265136514651565166517651865196520652165226523652465256526652765286529653065316532653365346535653665376538653965406541654265436544654565466547654865496550655165526553655465556556655765586559656065616562656365646565656665676568656965706571657265736574657565766577657865796580658165826583658465856586658765886589659065916592659365946595659665976598659966006601660266036604660566066607660866096610661166126613661466156616661766186619662066216622662366246625662666276628662966306631663266336634663566366637663866396640664166426643664466456646664766486649665066516652665366546655665666576658665966606661666266636664666566666667666866696670667166726673667466756676667766786679668066816682668366846685668666876688668966906691669266936694669566966697669866996700670167026703670467056706670767086709671067116712671367146715671667176718671967206721672267236724672567266727672867296730673167326733673467356736673767386739674067416742674367446745674667476748674967506751675267536754675567566757675867596760676167626763676467656766676767686769677067716772677367746775677667776778677967806781678267836784678567866787678867896790679167926793679467956796679767986799680068016802680368046805680668076808680968106811681268136814681568166817681868196820682168226823682468256826682768286829683068316832683368346835683668376838683968406841684268436844684568466847684868496850685168526853685468556856685768586859686068616862686368646865686668676868686968706871687268736874687568766877687868796880688168826883688468856886688768886889689068916892689368946895689668976898689969006901690269036904690569066907690869096910691169126913691469156916691769186919692069216922692369246925692669276928692969306931693269336934693569366937693869396940694169426943694469456946694769486949695069516952695369546955695669576958695969606961696269636964696569666967696869696970697169726973697469756976697769786979698069816982698369846985698669876988698969906991699269936994699569966997699869997000700170027003700470057006700770087009701070117012701370147015701670177018701970207021702270237024702570267027702870297030703170327033703470357036703770387039704070417042704370447045704670477048704970507051705270537054705570567057705870597060706170627063706470657066706770687069707070717072707370747075707670777078707970807081708270837084708570867087708870897090709170927093709470957096709770987099710071017102710371047105710671077108710971107111711271137114711571167117711871197120712171227123712471257126712771287129713071317132713371347135713671377138713971407141714271437144714571467147714871497150715171527153715471557156715771587159716071617162716371647165716671677168716971707171717271737174717571767177717871797180718171827183718471857186718771887189719071917192719371947195719671977198719972007201720272037204720572067207720872097210721172127213721472157216721772187219722072217222722372247225722672277228722972307231723272337234723572367237723872397240724172427243724472457246724772487249725072517252725372547255725672577258725972607261726272637264726572667267726872697270727172727273727472757276727772787279728072817282728372847285728672877288728972907291729272937294729572967297729872997300730173027303730473057306730773087309731073117312731373147315731673177318731973207321732273237324732573267327732873297330733173327333733473357336733773387339734073417342734373447345734673477348734973507351735273537354735573567357735873597360736173627363736473657366736773687369737073717372737373747375737673777378737973807381738273837384738573867387738873897390739173927393739473957396739773987399740074017402740374047405740674077408740974107411741274137414741574167417741874197420742174227423742474257426742774287429743074317432743374347435743674377438743974407441744274437444744574467447744874497450745174527453745474557456745774587459746074617462746374647465746674677468746974707471747274737474747574767477747874797480748174827483748474857486748774887489749074917492749374947495749674977498749975007501750275037504750575067507750875097510751175127513751475157516751775187519752075217522752375247525752675277528752975307531753275337534753575367537753875397540754175427543754475457546754775487549755075517552755375547555755675577558755975607561756275637564756575667567756875697570757175727573757475757576757775787579758075817582758375847585758675877588758975907591759275937594759575967597759875997600760176027603760476057606760776087609761076117612761376147615761676177618761976207621762276237624762576267627762876297630763176327633763476357636763776387639764076417642764376447645764676477648764976507651765276537654765576567657765876597660766176627663766476657666766776687669767076717672767376747675767676777678767976807681768276837684768576867687768876897690769176927693769476957696769776987699770077017702770377047705770677077708770977107711771277137714771577167717771877197720772177227723772477257726772777287729773077317732773377347735773677377738773977407741774277437744774577467747774877497750775177527753775477557756775777587759776077617762776377647765776677677768776977707771777277737774777577767777777877797780778177827783778477857786778777887789779077917792779377947795779677977798779978007801780278037804
  1. import asyncio
  2. import functools
  3. import logging
  4. import re
  5. from sqlalchemy import event
  6. from sqlalchemy.exc import IntegrityError, OperationalError, ProgrammingError
  7. from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
  8. from sqlalchemy.orm import DeclarativeBase
  9. from backend.app.core.config import settings
  10. from backend.app.core.db_dialect import is_sqlite
  11. logger = logging.getLogger(__name__)
  12. def _set_sqlite_pragmas(dbapi_conn, connection_record):
  13. """Set SQLite pragmas on each new connection for concurrency and performance."""
  14. cursor = dbapi_conn.cursor()
  15. # WAL mode allows concurrent readers + one writer (vs default DELETE mode which locks entirely)
  16. cursor.execute("PRAGMA journal_mode = WAL")
  17. # Wait up to 15 seconds when the database is locked instead of failing immediately
  18. cursor.execute("PRAGMA busy_timeout = 15000")
  19. cursor.execute("PRAGMA synchronous = NORMAL")
  20. cursor.close()
  21. # Resolved connection-pool configuration, captured at engine creation so
  22. # /system/db-pool can report it without re-deriving the dialect defaults.
  23. _pool_config: dict = {}
  24. # What the PostgreSQL server itself will allow, read once at startup. None on
  25. # SQLite, or when the probe could not run. Reported by get_pool_status() so a
  26. # support bundle carries both sides of the comparison.
  27. _server_connection_limits: dict | None = None
  28. def _resolve_pool_kwargs() -> dict:
  29. """Build the pool kwargs for ``create_async_engine`` (issue #2572).
  30. Dialect-aware defaults, each overridable via env (``DB_POOL_SIZE`` etc.):
  31. - PostgreSQL: pool_size 20 + max_overflow 60, ``pool_pre_ping`` (recover
  32. server-dropped connections instead of erroring the request) and
  33. ``pool_recycle`` 1800s. The old hard-coded 10 + 20 exhausted on large
  34. farms while printer callbacks held connections. The 80-connection
  35. ceiling fits a stock server (max_connections 100, 3 reserved for
  36. superusers); 20 + 80 did not, and tripped the startup pool check.
  37. - SQLite: pool_size 10 + max_overflow 90 (lowered from 20 + 200, #2883 —
  38. see the comment below); no pre-ping / recycle — the connection is a
  39. local file, not a server socket.
  40. """
  41. if is_sqlite():
  42. # SQLite + WAL parks one main-db file descriptor per *closed* overflow
  43. # connection: as long as any pooled connection stays open (it always
  44. # does), SQLite's unix VFS moves the fd of every closing connection to
  45. # its per-inode "unused fd" list instead of close(2)-ing it, to avoid
  46. # the POSIX close-drops-advisory-locks trap. Those fds are reused by
  47. # later connections but only released when the LAST connection to the
  48. # file closes, which in a running server is effectively never. So the
  49. # pool's database fds stay at the PEAK concurrency it ever reached.
  50. #
  51. # An open connection holds two fds (db and -wal; the -shm fd is shared
  52. # per file), a parked one holds one. At the old 20 + 200 that is up to
  53. # ~441 fds with every connection open and ~221 parked once they close.
  54. # That alone does not reach Docker's default 1024 soft nofile; in
  55. # #2883 other descriptors made up the rest. But it is the largest
  56. # single share, and once the process hits EMFILE every new connection
  57. # fails with "disk I/O error" (the WAL/shm open in the connect-time
  58. # PRAGMAs), which in #2883 ran for 44h and ended in "database disk
  59. # image is malformed". 10 + 90 halves the pool's share (~201 / ~101).
  60. # The startup RLIMIT_NOFILE raise in main.py is the other half of the
  61. # fix: it lifts the 1024 ceiling itself on every install.
  62. #
  63. # 20 + 200 came in with b8fa2df36 (March 2026) for QueuePool exhaustion
  64. # on a 100+ printer SQLite farm, before PostgreSQL was supported. Since
  65. # then #2572 made an authenticated request use one checkout instead of
  66. # several, which is why 10 + 90 should cover a farm that size. A large
  67. # SQLite farm that still exhausts the pool can raise DB_MAX_OVERFLOW,
  68. # but is better served by moving to PostgreSQL.
  69. pool_size = settings.db_pool_size if settings.db_pool_size is not None else 10
  70. max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 90
  71. kwargs = {"pool_size": pool_size, "max_overflow": max_overflow}
  72. else:
  73. pool_size = settings.db_pool_size if settings.db_pool_size is not None else 20
  74. max_overflow = settings.db_max_overflow if settings.db_max_overflow is not None else 60
  75. kwargs = {
  76. "pool_size": pool_size,
  77. "max_overflow": max_overflow,
  78. "pool_pre_ping": True,
  79. "pool_recycle": settings.db_pool_recycle if settings.db_pool_recycle is not None else 1800,
  80. # LIFO checkout keeps a bursty farm on a small hot connection set and
  81. # lets overflow connections recycle out during quiet spells (#2572).
  82. "pool_use_lifo": settings.db_pool_use_lifo if settings.db_pool_use_lifo is not None else True,
  83. }
  84. if settings.db_pool_timeout is not None:
  85. kwargs["pool_timeout"] = settings.db_pool_timeout
  86. return kwargs
  87. def _resolve_connect_args() -> dict:
  88. """Connect args that pin a PostgreSQL session to UTC (issue #2855).
  89. Bambuddy's ``DateTime`` columns are naive and hold UTC, and the frontend's
  90. ``parseUTCDate()`` reads a timestamp with no offset as UTC. Python-side
  91. writes honour that (``utcnow_naive()``), but ~96 columns take their value
  92. from ``server_default=func.now()`` and the migration DDL has ~49 more on
  93. ``DEFAULT CURRENT_TIMESTAMP`` — those are filled by the database, not by us.
  94. On PostgreSQL ``now()`` is a ``timestamptz``, so storing it into a
  95. ``timestamp without time zone`` column casts it through the session
  96. ``TimeZone``. A Postgres container started with ``TZ=Europe/Istanbul`` bakes
  97. that zone into ``postgresql.conf`` at initdb, and every defaulted timestamp
  98. is then written as local wall-clock and rendered three hours in the future.
  99. Pinning the session makes the cast a no-op regardless of the server's own
  100. setting.
  101. SQLite needs nothing: its ``CURRENT_TIMESTAMP`` is UTC by definition and has
  102. no session timezone to get wrong. This makes Postgres match SQLite rather
  103. than introducing a third convention.
  104. """
  105. if is_sqlite():
  106. return {}
  107. # asyncpg is the documented driver and sends these in the startup packet;
  108. # anything else Postgres goes through libpq, which takes the same setting
  109. # as a command-line option.
  110. if "+asyncpg" in settings.database_url:
  111. return {"server_settings": {"timezone": "UTC"}}
  112. return {"options": "-c timezone=UTC"}
  113. def _create_engine(url: str | None = None):
  114. """Create the async engine with dialect-appropriate settings.
  115. *url* defaults to the configured database; the test suite passes its own
  116. database of the same dialect.
  117. """
  118. kwargs = _resolve_pool_kwargs()
  119. connect_args = _resolve_connect_args()
  120. if connect_args:
  121. kwargs["connect_args"] = connect_args
  122. if url is None:
  123. global _pool_config
  124. _pool_config = {
  125. "pool_size": kwargs["pool_size"],
  126. "max_overflow": kwargs["max_overflow"],
  127. # SQLAlchemy's own defaults when we don't pass the kwarg.
  128. "pool_timeout": kwargs.get("pool_timeout", 30),
  129. "pool_recycle": kwargs.get("pool_recycle", -1),
  130. "pool_pre_ping": kwargs.get("pool_pre_ping", False),
  131. "pool_use_lifo": kwargs.get("pool_use_lifo", False),
  132. }
  133. eng = create_async_engine(
  134. url or settings.database_url,
  135. echo=settings.debug,
  136. # The debug SQL echo shows statements, never their values: those
  137. # include every secret saved and every password hash.
  138. hide_parameters=True,
  139. **kwargs,
  140. )
  141. if is_sqlite():
  142. event.listen(eng.sync_engine, "connect", _set_sqlite_pragmas)
  143. else:
  144. # Make aware datetimes naive UTC before they reach asyncpg.
  145. # asyncpg rejects timezone-aware values for TIMESTAMP WITHOUT TIME ZONE columns.
  146. # The codebase uses datetime.now(timezone.utc) in many places. A value
  147. # with another offset is converted, not stripped; SQLite drops offsets,
  148. # so request schemas convert on the way in (NaiveUTCDatetime).
  149. @event.listens_for(eng.sync_engine, "before_cursor_execute", retval=True)
  150. def _strip_tz_from_params(conn, cursor, statement, parameters, context, executemany):
  151. import datetime
  152. if parameters is None:
  153. return statement, parameters
  154. # Recursive strip that walks any nesting of dict/list/tuple. Needed
  155. # because SQLAlchemy passes parameters in several shapes depending
  156. # on the path: a dict for named binds, a tuple for positional, a
  157. # list of dicts/tuples for executemany, and for insertmanyvalues
  158. # sometimes a list of tuples inside an outer list. The simplest
  159. # correct answer is "strip datetimes at any depth".
  160. def _strip(val):
  161. if isinstance(val, datetime.datetime) and val.tzinfo is not None:
  162. # Naive columns hold UTC: convert, so an offset is not lost.
  163. return val.astimezone(datetime.timezone.utc).replace(tzinfo=None)
  164. if isinstance(val, dict):
  165. return {k: _strip(v) for k, v in val.items()}
  166. if isinstance(val, list):
  167. return [_strip(v) for v in val]
  168. if isinstance(val, tuple):
  169. return tuple(_strip(v) for v in val)
  170. return val
  171. return statement, _strip(parameters)
  172. return eng
  173. engine = _create_engine()
  174. async_session = async_sessionmaker(
  175. engine,
  176. class_=AsyncSession,
  177. expire_on_commit=False,
  178. )
  179. def get_pool_status() -> dict:
  180. """Snapshot the DB connection pool for diagnostics (issue #2572).
  181. Returns the resolved configuration plus live gauges (checked-out /
  182. checked-in / overflow). Reads the pool's own counters — it does NOT
  183. check out a connection, so it stays truthful even when the pool is
  184. exhausted. Gauges a given pool implementation doesn't expose come back
  185. as ``None`` rather than raising.
  186. """
  187. pool = engine.sync_engine.pool
  188. gauges: dict = {}
  189. for key, method_name in (
  190. ("current_size", "size"),
  191. ("checked_out", "checkedout"),
  192. ("checked_in", "checkedin"),
  193. ("overflow", "overflow"),
  194. ):
  195. method = getattr(pool, method_name, None)
  196. try:
  197. gauges[key] = method() if callable(method) else None
  198. except Exception:
  199. # A gauge should never take down the diagnostics endpoint.
  200. gauges[key] = None
  201. return {
  202. "dialect": "sqlite" if is_sqlite() else "postgresql",
  203. "config": dict(_pool_config),
  204. # Both sides of the ceiling-vs-server comparison, so a support bundle
  205. # shows whether a TooManyConnectionsError was a misconfiguration or a
  206. # genuine leak. None on SQLite or if the startup probe couldn't run.
  207. "server_limits": dict(_server_connection_limits) if _server_connection_limits else None,
  208. **gauges,
  209. }
  210. async def run_with_retry(fn, *, max_attempts: int = 3, label: str = ""):
  211. """Run an async DB operation with retry for SQLite 'database is locked' errors.
  212. ``fn`` is an async callable that receives an ``AsyncSession`` and performs
  213. the full query-mutate-commit cycle. On each retry a fresh session is used
  214. so there are no stale-object / expired-attribute issues after rollback.
  215. On PostgreSQL this calls ``fn`` once with no retry (Postgres uses row-level
  216. locking and doesn't suffer from single-writer contention).
  217. """
  218. if not is_sqlite():
  219. async with async_session() as db:
  220. return await fn(db)
  221. last_exc: OperationalError | None = None
  222. for attempt in range(1, max_attempts + 1):
  223. try:
  224. async with async_session() as db:
  225. return await fn(db)
  226. except OperationalError as exc:
  227. last_exc = exc
  228. if "database is locked" not in str(exc) or attempt == max_attempts:
  229. raise
  230. delay = 0.5 * attempt # 0.5s, 1.0s
  231. logger.warning(
  232. "SQLite locked%s (attempt %d/%d), retrying in %.1fs: %s",
  233. f" ({label})" if label else "",
  234. attempt,
  235. max_attempts,
  236. delay,
  237. exc,
  238. )
  239. await asyncio.sleep(delay)
  240. raise last_exc # unreachable, but keeps type checkers happy
  241. async def close_all_connections():
  242. """Close all database connections for backup/restore operations."""
  243. global engine
  244. await engine.dispose()
  245. async def reinitialize_database():
  246. """Reinitialize database connection after restore."""
  247. global engine, async_session
  248. engine = _create_engine()
  249. async_session = async_sessionmaker(
  250. engine,
  251. class_=AsyncSession,
  252. expire_on_commit=False,
  253. )
  254. class Base(DeclarativeBase):
  255. pass
  256. async def get_db() -> AsyncSession:
  257. async with async_session() as session:
  258. try:
  259. yield session
  260. await session.commit()
  261. except BaseException:
  262. # Catch BaseException (not just Exception) so CancelledError —
  263. # raised when Starlette's BaseHTTPMiddleware cancels the inner
  264. # task scope on client disconnect — also triggers rollback.
  265. # `asyncio.shield` keeps the rollback running to completion
  266. # even when the await itself gets cancelled, so the SQLite
  267. # write lock is released promptly instead of being held until
  268. # the connection is GC'd ages later (which was producing the
  269. # "database is locked" cascade in #1112's support package).
  270. try:
  271. await asyncio.shield(session.rollback())
  272. except BaseException: # noqa: BLE001 — rollback failure must not mask the original
  273. pass
  274. raise
  275. finally:
  276. try:
  277. await asyncio.shield(session.close())
  278. except BaseException: # noqa: BLE001 — close failure must not mask the original
  279. pass
  280. @functools.cache
  281. def load_all_models():
  282. """Import every model module and return Base.metadata.
  283. The models package __init__ doesn't import them all, so code that walks the
  284. metadata (every table, every foreign key) needs this to see the whole
  285. schema, whatever has happened to be imported before. Cached: it is called
  286. per request (schemas/not_null.py) and the models don't change at runtime.
  287. """
  288. import importlib
  289. import pkgutil
  290. import backend.app.models
  291. for module in pkgutil.iter_modules(backend.app.models.__path__):
  292. importlib.import_module(f"backend.app.models.{module.name}")
  293. return Base.metadata
  294. # Tables SQLite must never give a freed id to a new row: whatever still points at
  295. # a deleted row (a token, a socket, in-memory state, files, a row inserted while
  296. # the delete committed) would point at the new one. PostgreSQL uses sequences.
  297. AUTOINCREMENT_TABLES = ("users", "api_keys", "printers", "print_archives", "long_lived_tokens", "spool")
  298. # Columns that keep one of their ids without a foreign key, so the seed below
  299. # cannot find them in the metadata: a deleted printer's print log is kept as
  300. # history, Notify tiles until the remote delete, ephemeral tokens until expiry.
  301. # (test_sqlite_autoincrement fails if a new such column is not listed.)
  302. NON_FK_ID_REFERENCES: dict[str, tuple[tuple[str, str], ...]] = {
  303. "printers": (
  304. ("print_log_entries", "printer_id"),
  305. ("notification_live_activities", "printer_id"),
  306. ("notification_lock_screen_widgets", "printer_id"),
  307. ),
  308. "api_keys": (("auth_ephemeral_tokens", "api_key_id"),),
  309. }
  310. async def highest_referenced_id(conn, table: str) -> int:
  311. """The highest id of `table` in use: its own, or kept by a foreign key or
  312. a column in NON_FK_ID_REFERENCES. A new row must get a higher one."""
  313. from sqlalchemy import text
  314. metadata = load_all_models()
  315. referenced = (
  316. [(table, "id")]
  317. + [
  318. (fk.parent.table.name, fk.parent.name)
  319. for other in metadata.tables.values()
  320. for fk in other.foreign_keys
  321. if fk.column.table.name == table and fk.column.name == "id"
  322. ]
  323. + list(NON_FK_ID_REFERENCES.get(table, ()))
  324. )
  325. highest = 0
  326. for ref_table, ref_column in referenced:
  327. try:
  328. async with conn.begin_nested():
  329. value = (await conn.execute(text(f"SELECT max({ref_column}) FROM {ref_table}"))).scalar() # noqa: S608 # nosec B608
  330. except (OperationalError, ProgrammingError):
  331. continue # a table or column this database does not have yet
  332. if isinstance(value, int) and value > highest:
  333. highest = value
  334. return highest
  335. async def _migrate_sqlite_autoincrement(conn) -> None:
  336. """Rebuild AUTOINCREMENT_TABLES with AUTOINCREMENT on existing SQLite databases.
  337. SQLite's own procedure: a copy made from the table's stored CREATE statement
  338. (so every column an earlier migration added is kept) with the id made
  339. ``INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT``, the rows copied and counted,
  340. the old table dropped, the copy renamed, its indexes and triggers recreated.
  341. Foreign keys are off on SQLite, so the tables pointing here keep their
  342. references by name. The counter then starts above highest_referenced_id,
  343. so an id freed earlier that something still points at is not given out
  344. either. A table already rebuilt (or restored from a PostgreSQL backup,
  345. whose counter stands at the highest id left) only has its counter raised.
  346. """
  347. from sqlalchemy import text
  348. if not is_sqlite():
  349. return
  350. for table in AUTOINCREMENT_TABLES:
  351. row = (
  352. await conn.execute(text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = :t"), {"t": table})
  353. ).first()
  354. if row is None:
  355. continue
  356. sql = row[0] or ""
  357. if "AUTOINCREMENT" in sql.upper():
  358. highest = await highest_referenced_id(conn, table)
  359. current = (
  360. await conn.execute(text("SELECT seq FROM sqlite_sequence WHERE name = :t"), {"t": table})
  361. ).scalar()
  362. if highest > (current or 0):
  363. async with conn.begin_nested():
  364. await conn.execute(text("DELETE FROM sqlite_sequence WHERE name = :t"), {"t": table})
  365. await conn.execute(
  366. text("INSERT INTO sqlite_sequence (name, seq) VALUES (:t, :s)"), {"t": table, "s": highest}
  367. )
  368. logger.info("SQLite: %s ids now continue after %s, still referred to", table, highest)
  369. continue
  370. column = re.search(r"\bid INTEGER NOT NULL\s*,", sql)
  371. primary_key = re.search(r",\s*PRIMARY KEY \(id\)", sql)
  372. if column is None or primary_key is None:
  373. logger.warning("SQLite AUTOINCREMENT rebuild skipped for %s: unexpected table definition", table)
  374. continue
  375. copy = f"{table}__autoinc"
  376. new_sql = sql[: primary_key.start()] + sql[primary_key.end() :]
  377. new_sql = new_sql.replace(column.group(0), "id INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,", 1)
  378. new_sql = re.sub(r"^CREATE TABLE\s+\"?" + re.escape(table) + r"\"?", f"CREATE TABLE {copy}", new_sql, count=1)
  379. extras = [
  380. r[0]
  381. for r in await conn.execute(
  382. text(
  383. "SELECT sql FROM sqlite_master WHERE type IN ('index', 'trigger') AND tbl_name = :t AND sql IS NOT NULL"
  384. ),
  385. {"t": table},
  386. )
  387. ]
  388. async with conn.begin_nested():
  389. await conn.execute(text(f"DROP TABLE IF EXISTS {copy}"))
  390. await conn.execute(text(new_sql))
  391. names = ", ".join(f'"{r[1]}"' for r in await conn.execute(text(f"PRAGMA table_info({table})")))
  392. await conn.execute(text(f"INSERT INTO {copy} ({names}) SELECT {names} FROM {table}"))
  393. original = (await conn.execute(text(f"SELECT count(*) FROM {table}"))).scalar_one()
  394. copied = (await conn.execute(text(f"SELECT count(*) FROM {copy}"))).scalar_one()
  395. if copied != original:
  396. raise RuntimeError(f"{table} AUTOINCREMENT rebuild: {original} rows, {copied} copied")
  397. await conn.execute(text(f"DROP TABLE {table}"))
  398. await conn.execute(text(f"ALTER TABLE {copy} RENAME TO {table}"))
  399. for statement in extras:
  400. await conn.execute(text(statement))
  401. highest = await highest_referenced_id(conn, table)
  402. await conn.execute(text("DELETE FROM sqlite_sequence WHERE name = :t"), {"t": table})
  403. await conn.execute(
  404. text("INSERT INTO sqlite_sequence (name, seq) VALUES (:t, :s)"), {"t": table, "s": highest}
  405. )
  406. logger.info("SQLite: %s now never reuses an id (next id after %s)", table, highest)
  407. async def _raise_postgres_id_sequences(conn) -> None:
  408. """Start AUTOINCREMENT_TABLES' sequences above every id still referred to (PostgreSQL).
  409. PostgreSQL itself never reuses an id, but a SQLite backup imported before
  410. the import did this set each sequence to the highest id left, below ids a
  411. print log, a Notify tile or a token may still keep. Only ever raised.
  412. """
  413. from sqlalchemy import text
  414. if is_sqlite():
  415. return
  416. for table in AUTOINCREMENT_TABLES:
  417. sequence = (await conn.execute(text("SELECT pg_get_serial_sequence(:t, 'id')"), {"t": table})).scalar()
  418. if not sequence:
  419. continue
  420. last_value, is_called = (await conn.execute(text(f"SELECT last_value, is_called FROM {sequence}"))).first() # noqa: S608 # nosec B608
  421. given_out = last_value if is_called else last_value - 1
  422. highest = await highest_referenced_id(conn, table)
  423. if highest > given_out:
  424. await conn.execute(text("SELECT setval(:s, :v)"), {"s": sequence, "v": highest})
  425. logger.info("PostgreSQL: %s ids now continue after %s, still referred to", table, highest)
  426. async def _drop_deleted_printers_from_limits(conn) -> None:
  427. """Take printers deleted before deletes did so out of API keys, Obico and virtual printers (both databases).
  428. SQLite gives a deleted printer's id to the next printer created. So does
  429. PostgreSQL after a SQLite backup is imported: the sequence starts after the
  430. highest id left. And a stale id would make every save of the list it is in
  431. fail the check for unknown printers.
  432. """
  433. from sqlalchemy import exists, select
  434. from backend.app.api.routes.virtual_printers import drop_virtual_printer
  435. from backend.app.core.printer_scope import drop_printers_from_api_keys, drop_printers_from_obico
  436. from backend.app.models.printer import Printer
  437. from backend.app.models.virtual_printer import VirtualPrinter
  438. await drop_printers_from_api_keys(conn)
  439. await drop_printers_from_obico(conn)
  440. aimed_at_nothing = (
  441. await conn.execute(
  442. select(VirtualPrinter.id).where(
  443. VirtualPrinter.target_printer_id.is_not(None),
  444. ~exists().where(Printer.id == VirtualPrinter.target_printer_id),
  445. )
  446. )
  447. ).scalars()
  448. for vp_id in aimed_at_nothing.all():
  449. # Not running yet at start. Its upload directory stays, as after a
  450. # crash between a delete's commit and the directory's removal.
  451. await drop_virtual_printer(conn, vp_id)
  452. async def _drop_ephemeral_tokens_of_missing_owners(conn) -> None:
  453. """Delete one-time tokens of a user or API key that is gone (both databases).
  454. User deletes left them (reset links, 2FA and OIDC steps, camera, WebSocket
  455. and slicer tokens), and they are looked up by username: whoever is given the
  456. name next would take them over. The logout list (revoked_jti) is kept, and
  457. tokens of no user (an API key's, or auth off) are left alone here. An API
  458. key's camera and WebSocket tokens name the key by id, which SQLite
  459. gives to the next key created: those of a deleted key go too.
  460. """
  461. from sqlalchemy import delete, func, select
  462. from backend.app.models.api_key import APIKey
  463. from backend.app.models.auth_ephemeral import AuthEphemeralToken
  464. from backend.app.models.user import User
  465. await conn.execute(
  466. delete(AuthEphemeralToken).where(
  467. AuthEphemeralToken.api_key_id.is_not(None),
  468. AuthEphemeralToken.api_key_id.not_in(select(APIKey.id)),
  469. )
  470. )
  471. await conn.execute(
  472. delete(AuthEphemeralToken).where(
  473. AuthEphemeralToken.token_type != "revoked_jti",
  474. AuthEphemeralToken.username.is_not(None),
  475. AuthEphemeralToken.username != "",
  476. func.lower(AuthEphemeralToken.username).not_in(select(func.lower(User.username))),
  477. )
  478. )
  479. async def _repair_sqlite_orphans(conn) -> None:
  480. """Repair rows that earlier deletes left pointing at a row that is gone (SQLite only).
  481. SQLite runs with foreign keys off, so before core/sqlite_fk applied the ON
  482. DELETE rules at every delete, none were applied. This applies them now to
  483. what was left (sweep_orphans), after the one domain rule that deviates from
  484. the model's. PostgreSQL enforces the rules itself.
  485. """
  486. from sqlalchemy import delete, exists, select
  487. from backend.app.core.sqlite_fk import sweep_orphans
  488. from backend.app.models.library import LibraryFile
  489. from backend.app.models.print_queue import PrintQueueItem
  490. from backend.app.models.settings import Settings
  491. from backend.app.models.user import User
  492. from backend.app.services.library_trash import release_queue_references
  493. if not is_sqlite():
  494. return
  495. # print_queue.library_file_id is ON DELETE CASCADE in the model, but a library
  496. # delete keeps the rows (release_queue_references): finished plates count
  497. # towards a batch, a printing job stays, a waiting one is cancelled. Rows the
  498. # old external rescan and pre-#2819 deletes left behind get the same.
  499. vanished = (
  500. (
  501. await conn.execute(
  502. select(PrintQueueItem.library_file_id)
  503. .where(PrintQueueItem.library_file_id.is_not(None))
  504. .where(~exists().where(LibraryFile.id == PrintQueueItem.library_file_id))
  505. .distinct()
  506. )
  507. )
  508. .scalars()
  509. .all()
  510. )
  511. if vanished:
  512. await release_queue_references(conn, list(vanished))
  513. await sweep_orphans(conn)
  514. # Email 2FA is a settings key named after the user id, which no foreign key
  515. # covers; the next user given a deleted user's id would be asked for a code.
  516. keys = (
  517. await conn.execute(
  518. select(Settings.key).where(Settings.key.like("user\\_%\\_email\\_2fa\\_enabled", escape="\\"))
  519. )
  520. ).scalars()
  521. user_ids = set((await conn.execute(select(User.id))).scalars().all())
  522. gone = [
  523. key for key in keys if (m := re.fullmatch(r"user_(\d+)_email_2fa_enabled", key)) and int(m[1]) not in user_ids
  524. ]
  525. if gone:
  526. await conn.execute(delete(Settings).where(Settings.key.in_(gone)))
  527. async def init_db():
  528. # Import models to register them with SQLAlchemy
  529. from backend.app.models import ( # noqa: F401
  530. active_print_session,
  531. active_print_spoolman,
  532. ams_history,
  533. ams_label,
  534. announcement,
  535. api_key,
  536. archive,
  537. auth_ephemeral,
  538. bug_report,
  539. color_catalog,
  540. connected_app,
  541. external_link,
  542. filament,
  543. filament_sku_settings,
  544. finance,
  545. github_backup,
  546. group,
  547. kprofile_note,
  548. library,
  549. local_preset,
  550. location,
  551. location_ha_sensor,
  552. long_lived_token,
  553. maintenance,
  554. notification,
  555. notification_live_activity,
  556. notification_lock_screen_widget,
  557. notification_template,
  558. oidc_provider,
  559. orca_base_cache,
  560. pending_upload,
  561. pipeline_run,
  562. print_batch,
  563. print_log,
  564. print_queue,
  565. printer,
  566. printer_ha_sensor,
  567. printer_location,
  568. printer_sensor_history,
  569. project,
  570. project_bom,
  571. scheduled_drying,
  572. settings,
  573. shopping_list,
  574. slicer_pipeline,
  575. slot_preset,
  576. smart_plug,
  577. smart_plug_energy_snapshot,
  578. spool,
  579. spool_assignment,
  580. spool_catalog,
  581. spool_filament_preset,
  582. spool_k_profile,
  583. spool_usage_history,
  584. spoolbuddy_device,
  585. spoolman_k_profile,
  586. spoolman_slot_assignment,
  587. user,
  588. user_email_pref,
  589. user_otp_code,
  590. user_totp,
  591. virtual_printer,
  592. )
  593. async with engine.begin() as conn:
  594. await conn.run_sync(Base.metadata.create_all)
  595. # Run migrations for new columns (SQLite doesn't auto-add columns)
  596. await run_migrations(conn)
  597. # Before the orphan repair, so ids its rows still point at count.
  598. await _migrate_sqlite_autoincrement(conn)
  599. await _raise_postgres_id_sequences(conn)
  600. # SQLite applies no ON DELETE rules (foreign keys off): repair rows that
  601. # earlier deletes left pointing at nothing. Not a migration.
  602. await _repair_sqlite_orphans(conn)
  603. await _drop_deleted_printers_from_limits(conn)
  604. await _drop_ephemeral_tokens_of_missing_owners(conn)
  605. # Re-encrypt any legacy plaintext OIDC client_secret / TOTP secret rows
  606. # that exist from before the encryption key was configured.
  607. # Runs on a fresh AsyncSession (NOT the run_migrations() connection) so it
  608. # doesn't share a transaction with the schema-DDL block above — required to
  609. # avoid SQLite "database is locked" contention on the WAL writer.
  610. await _migrate_encrypt_legacy_secrets()
  611. # Seed default notification templates
  612. await seed_notification_templates()
  613. # Seed default groups and migrate existing users
  614. await seed_default_groups()
  615. # Seed default catalog entries
  616. await seed_spool_catalog()
  617. await seed_color_catalog()
  618. await check_pool_fits_server()
  619. async def check_pool_fits_server() -> None:
  620. """Warn when the pool may ask PostgreSQL for more connections than it allows.
  621. ``pool_size + max_overflow`` is the most connections one worker process will
  622. ever open. If that exceeds what the server permits, the pool never reaches
  623. its own limit and so never queues: it goes straight to the server, which
  624. refuses with ``TooManyConnectionsError``. That surfaces wherever the next
  625. connection happened to be needed — in the reported case, halfway through a
  626. queue dispatch, which then left an expected-print registration and a dispatch
  627. claim behind (#2702 follow-up).
  628. The distinction is worth knowing when reading a log: SQLAlchemy's own
  629. ``QueuePool limit ... timed out`` means the pool is the bottleneck (too much
  630. concurrency, or connections held too long), whereas asyncpg's
  631. ``TooManyConnectionsError`` means the pool's ceiling is above the server's.
  632. Not clamped, deliberately. Pool sizes are fixed when the engine is created,
  633. which happens at import — before any connection exists to ask the server
  634. with — and ``engine`` / ``async_session`` are imported by name in ~150 places,
  635. so swapping the engine afterwards would leave stale references. The correct
  636. ceiling also depends on the worker count and on anything else sharing the
  637. server, neither of which Bambuddy can see. So this reports the mismatch with
  638. both numbers and the knobs to fix it, and leaves the choice to the operator.
  639. """
  640. global _server_connection_limits
  641. if is_sqlite():
  642. return
  643. from sqlalchemy import text
  644. in_use: int | None = None
  645. try:
  646. async with engine.connect() as conn:
  647. max_conn = int((await conn.execute(text("SHOW max_connections"))).scalar_one())
  648. reserved = int((await conn.execute(text("SHOW superuser_reserved_connections"))).scalar_one())
  649. try:
  650. in_use = int(
  651. (
  652. await conn.execute(
  653. text("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'")
  654. )
  655. ).scalar_one()
  656. )
  657. except Exception as exc:
  658. # `pg_stat_activity.backend_type` is PostgreSQL 10+, and a
  659. # restricted role sees fewer rows. The count is a nice-to-have
  660. # for spotting other clients; the warning itself only needs the
  661. # two settings above, so losing it must not cost the warning.
  662. # Done last on purpose: a failed statement can abort the
  663. # transaction, and nothing else uses this connection after it.
  664. logger.debug("Could not count client backends: %s", exc)
  665. except Exception as exc:
  666. # A diagnostic must never be the reason startup fails. An older server
  667. # or a restricted role may refuse these.
  668. logger.debug("Could not read PostgreSQL connection limits: %s", exc)
  669. return
  670. available = max_conn - reserved
  671. ceiling = _pool_config.get("pool_size", 0) + _pool_config.get("max_overflow", 0)
  672. _server_connection_limits = {
  673. "max_connections": max_conn,
  674. "superuser_reserved_connections": reserved,
  675. "available_to_bambuddy": available,
  676. "client_backends_at_startup": in_use,
  677. "pool_ceiling_per_worker": ceiling,
  678. }
  679. if ceiling > available:
  680. in_use_note = (
  681. f" {in_use} client connection(s) are open on the server right now, including "
  682. "this one — a count well above 1 means something else shares it."
  683. if in_use is not None
  684. else ""
  685. )
  686. logger.warning(
  687. "DB pool may exceed what PostgreSQL allows: this worker can open up to %d "
  688. "connections (pool_size %d + max_overflow %d) but the server permits %d "
  689. "(max_connections %d minus %d reserved for superusers).%s Exhaustion surfaces "
  690. "as TooManyConnectionsError at whatever ran next, not as a pool timeout. "
  691. "Lower DB_POOL_SIZE / DB_MAX_OVERFLOW, or raise the server's "
  692. "max_connections — and account for every worker process and any other "
  693. "client sharing this server.",
  694. ceiling,
  695. _pool_config.get("pool_size", 0),
  696. _pool_config.get("max_overflow", 0),
  697. available,
  698. max_conn,
  699. reserved,
  700. in_use_note,
  701. )
  702. else:
  703. logger.info(
  704. "DB pool fits the server: up to %d connection(s) per worker, %d available (max_connections %d).",
  705. ceiling,
  706. available,
  707. max_conn,
  708. )
  709. # B2: Module-level counter exposing the number of rows skipped during the last
  710. # _migrate_encrypt_legacy_secrets() invocation. Surfaced via /encryption-status
  711. # (migration_error_count) so operators can spot poison rows that need attention.
  712. _migration_error_count: int = 0
  713. def get_migration_error_count() -> int:
  714. """Return the number of rows that failed to re-encrypt during the last
  715. _migrate_encrypt_legacy_secrets() run."""
  716. return _migration_error_count
  717. async def _migrate_encrypt_legacy_secrets() -> None:
  718. """Re-encrypt OIDC ``client_secret`` and TOTP ``secret`` rows that are still
  719. stored as plaintext (no ``fernet:`` prefix).
  720. Called from :func:`init_db` after :func:`run_migrations` finishes. No-ops
  721. when no encryption key is configured (so plaintext storage stays the
  722. legacy behaviour for installs without a key).
  723. B2: per-row strategy — each row is committed in its own AsyncSession so a
  724. single corrupt row does NOT block other successful re-encryptions on every
  725. startup forever. The skipped-row count is exposed via
  726. :func:`get_migration_error_count` and surfaced on /encryption-status.
  727. B3: unexpected (non-row) failures during the read phase are re-raised so
  728. operators see the problem instead of silent data corruption — startup
  729. fails loudly rather than running with half-migrated rows.
  730. Idempotent: rows that already start with ``fernet:`` are skipped, and the
  731. write-phase re-checks the prefix before encrypting (guards against double
  732. encryption from concurrent workers).
  733. """
  734. from sqlalchemy import not_, select
  735. from backend.app.core.encryption import is_encryption_active
  736. from backend.app.models.oidc_provider import OIDCProvider
  737. from backend.app.models.user_totp import UserTOTP
  738. global _migration_error_count
  739. if not is_encryption_active():
  740. # Reset stale counter from a previous active-key run — we no longer
  741. # have any rows to migrate, so the count must not leak across runs.
  742. _migration_error_count = 0
  743. return
  744. # Phase 1 (read): collect (id, stored_value) tuples for plaintext rows.
  745. # Read phase failures are startup-fatal — re-raise (B3).
  746. try:
  747. async with async_session() as ro:
  748. oidc_rows = await ro.execute(
  749. select(OIDCProvider.id, OIDCProvider._client_secret_enc).where(
  750. not_(OIDCProvider._client_secret_enc.like("fernet:%"))
  751. )
  752. )
  753. oidc_candidates = [(r[0], r[1]) for r in oidc_rows.all()]
  754. totp_rows = await ro.execute(
  755. select(UserTOTP.id, UserTOTP._secret_enc).where(not_(UserTOTP._secret_enc.like("fernet:%")))
  756. )
  757. totp_candidates = [(r[0], r[1]) for r in totp_rows.all()]
  758. except Exception:
  759. logger.error("_migrate_encrypt_legacy_secrets: phase 1 read failed", exc_info=True)
  760. raise # B3
  761. oidc_count = totp_count = error_count = 0
  762. # Phase 2 (write): each row in its own AsyncSession + transaction.
  763. # Failure of one row does NOT block the others.
  764. for oidc_id, stored in oidc_candidates:
  765. if not stored:
  766. continue # defensive: skip empty strings
  767. try:
  768. async with async_session() as wr:
  769. provider = await wr.get(OIDCProvider, oidc_id)
  770. if provider is None:
  771. continue # row deleted between phase 1 and phase 2
  772. # Idempotent guard: re-check inside the write session in case a
  773. # concurrent worker beat us to it.
  774. if not provider._client_secret_enc.startswith("fernet:"):
  775. provider.client_secret = stored # setter -> mfa_encrypt
  776. await wr.commit()
  777. oidc_count += 1
  778. except Exception:
  779. logger.error(
  780. "Failed to re-encrypt OIDCProvider id=%s — skipping",
  781. oidc_id,
  782. exc_info=True,
  783. )
  784. error_count += 1
  785. for totp_id, stored in totp_candidates:
  786. if not stored:
  787. continue
  788. try:
  789. async with async_session() as wr:
  790. totp = await wr.get(UserTOTP, totp_id)
  791. if totp is None:
  792. continue
  793. if not totp._secret_enc.startswith("fernet:"):
  794. totp.secret = stored
  795. await wr.commit()
  796. totp_count += 1
  797. except Exception:
  798. logger.error(
  799. "Failed to re-encrypt UserTOTP id=%s — skipping",
  800. totp_id,
  801. exc_info=True,
  802. )
  803. error_count += 1
  804. _migration_error_count = error_count
  805. if oidc_count or totp_count:
  806. logger.info(
  807. "Re-encrypted legacy plaintext secrets: %d OIDC client_secret(s), %d TOTP secret(s)",
  808. oidc_count,
  809. totp_count,
  810. )
  811. elif error_count == 0:
  812. logger.debug("_migrate_encrypt_legacy_secrets: no rows needed re-encryption")
  813. if error_count:
  814. logger.error(
  815. "_migrate_encrypt_legacy_secrets: %d row(s) skipped due to errors. "
  816. "See /api/v1/auth/encryption-status (migration_error_count).",
  817. error_count,
  818. )
  819. # PostgreSQL SQLSTATE codes meaning "this DDL statement has already been applied".
  820. # We classify on these rather than on the error text because the server renders
  821. # messages in its own ``lc_messages`` locale: a Russian-locale server answers a
  822. # duplicate ADD COLUMN with "уже существует", which no English substring check can
  823. # recognise. That made Bambuddy unstartable on every non-English PostgreSQL server,
  824. # fresh or existing — create_all() runs before run_migrations(), so on a new database
  825. # essentially every ADD COLUMN below is expected to come back as a duplicate (#2949).
  826. _PG_ALREADY_APPLIED = frozenset(
  827. {
  828. "42701", # duplicate_column — ALTER TABLE ADD COLUMN
  829. "42P07", # duplicate_table — CREATE TABLE, CREATE INDEX
  830. "42710", # duplicate_object — ADD CONSTRAINT, CREATE TRIGGER
  831. "23505", # unique_violation — duplicate key
  832. }
  833. )
  834. # undefined_column. Idempotency only for RENAME COLUMN (the rename already ran);
  835. # on any other statement a missing column means a broken schema, not a re-run.
  836. _PG_UNDEFINED_COLUMN = "42703"
  837. def _sqlstate(exc) -> str | None:
  838. """Return the PostgreSQL SQLSTATE behind a SQLAlchemy error, or None.
  839. None on SQLite, whose DBAPI exceptions carry no such code — and which never
  840. localises its messages, so the text match below stays correct there.
  841. """
  842. orig = getattr(exc, "orig", None)
  843. for attr in ("sqlstate", "pgcode"):
  844. code = getattr(orig, attr, None)
  845. if code:
  846. return str(code)
  847. return None
  848. def _is_already_applied(exc, sql: str) -> bool:
  849. """Return True if a failed DDL statement had simply already been applied."""
  850. is_rename = "rename column" in sql.lower()
  851. state = _sqlstate(exc)
  852. if state is not None:
  853. return state in _PG_ALREADY_APPLIED or (state == _PG_UNDEFINED_COLUMN and is_rename)
  854. msg = str(exc).lower()
  855. if any(k in msg for k in ("already exists", "duplicate key", "duplicate column name", "no such column")):
  856. return True
  857. return is_rename and "column" in msg and "does not exist" in msg
  858. # On PostgreSQL every statement that fails is written to the SERVER's log as an
  859. # ERROR, with its text, before _safe_execute gets to swallow it. Since nearly every
  860. # ADD COLUMN below is a re-run on every start, that was ~320 ERROR + STATEMENT
  861. # pairs per start on every PostgreSQL install -- harmless, but it buried real
  862. # errors and alarmed anyone who read the log. These rewrites let the server skip
  863. # what is already there with a NOTICE (not logged by default) instead.
  864. _PG_ADD_COLUMN = re.compile(r"^(\s*ALTER\s+TABLE\s+\S+\s+ADD\s+COLUMN\s+)(?!IF\s+NOT\s+EXISTS\b)", re.I)
  865. _PG_CREATE = re.compile(
  866. r"^(\s*CREATE\s+(?:UNIQUE\s+)?(?:INDEX|TABLE)\s+)(?!IF\s+NOT\s+EXISTS\b)(?!CONCURRENTLY\b)", re.I
  867. )
  868. # No IF NOT EXISTS exists for these two, so the catalog is asked first.
  869. _PG_ADD_CONSTRAINT = re.compile(r"^\s*ALTER\s+TABLE\s+(\S+)\s+ADD\s+CONSTRAINT\s+(\S+)", re.I)
  870. _PG_RENAME_COLUMN = re.compile(r"^\s*ALTER\s+TABLE\s+(\S+)\s+RENAME\s+COLUMN\s+(\S+)\s+TO\s+", re.I)
  871. def _is_pg_conn(conn) -> bool:
  872. """The connection's own dialect, so the rewrites never touch anything else."""
  873. return getattr(getattr(conn, "dialect", None), "name", None) == "postgresql"
  874. def _pg_if_not_exists(sql: str) -> str:
  875. """ADD COLUMN / CREATE INDEX / CREATE TABLE made a no-op when already applied."""
  876. sql = _PG_ADD_COLUMN.sub(r"\1IF NOT EXISTS ", sql, count=1)
  877. return _PG_CREATE.sub(r"\1IF NOT EXISTS ", sql, count=1)
  878. async def _pg_already_applied(conn, sql: str) -> bool:
  879. """Whether a constraint or a rename has already run, asked of the catalog."""
  880. from sqlalchemy import text
  881. m = _PG_ADD_CONSTRAINT.match(sql)
  882. if m:
  883. found = await conn.scalar(
  884. text("SELECT 1 FROM pg_constraint WHERE conname = :name AND conrelid = to_regclass(:table)"),
  885. {"name": m.group(2).strip('"'), "table": m.group(1)},
  886. )
  887. return found is not None
  888. m = _PG_RENAME_COLUMN.match(sql)
  889. if m:
  890. # The old column gone means the rename already ran -- the same reading
  891. # _is_already_applied gives an undefined_column on a RENAME.
  892. found = await conn.scalar(
  893. text(
  894. "SELECT 1 FROM information_schema.columns "
  895. "WHERE table_schema = current_schema() AND table_name = :table AND column_name = :column"
  896. ),
  897. {"table": m.group(1).strip('"'), "column": m.group(2).strip('"')},
  898. )
  899. return found is None
  900. return False
  901. async def _safe_execute(conn, sql):
  902. """Execute a DDL migration statement, silently ignoring idempotency errors.
  903. Statements that had already been applied are swallowed so that re-running DDL
  904. migrations is safe — see :func:`_is_already_applied` for how that is decided
  905. (SQLSTATE on PostgreSQL, message text on SQLite). Idempotency for a missing
  906. column is narrowed to RENAME COLUMN, so a missing column on ADD COLUMN or
  907. CREATE INDEX — which would indicate schema corruption, not a re-run — is never
  908. silently swallowed.
  909. Any other error is logged and re-raised — callers must not assume silent
  910. recovery, as a failure will abort the migration sequence and prevent
  911. application startup.
  912. Only use for DDL statements (ALTER TABLE, CREATE INDEX, etc.).
  913. For DML backfills (UPDATE, DELETE) use conn.execute() directly inside
  914. async with conn.begin_nested() so failures are never silently swallowed.
  915. Uses a savepoint so that a failed statement doesn't poison the surrounding
  916. transaction (required for PostgreSQL).
  917. """
  918. from sqlalchemy import text
  919. if _is_pg_conn(conn):
  920. if await _pg_already_applied(conn, sql):
  921. return
  922. sql = _pg_if_not_exists(sql)
  923. try:
  924. async with conn.begin_nested():
  925. await conn.execute(text(sql))
  926. except (OperationalError, ProgrammingError) as exc:
  927. if not _is_already_applied(exc, sql):
  928. logger.error("Migration statement failed: %s | SQL: %.200s", exc, sql)
  929. raise
  930. async def _api_keys_column_exists(conn, column_name: str) -> bool:
  931. """Return True if the named column exists on ``api_keys``.
  932. Used to gate one-shot data backfills that must run only on the migration
  933. that adds a column — without this, repeating the UPDATE on every startup
  934. would silently overwrite values the user later edited in the UI.
  935. Dialect-specific because SQLite has no information_schema.
  936. """
  937. from sqlalchemy import text
  938. if is_sqlite():
  939. result = await conn.execute(text("PRAGMA table_info(api_keys)"))
  940. return any(row[1] == column_name for row in result)
  941. result = await conn.execute(
  942. text("SELECT 1 FROM information_schema.columns WHERE table_name = 'api_keys' AND column_name = :col"),
  943. {"col": column_name},
  944. )
  945. return result.scalar_one_or_none() is not None
  946. async def _migrate_scope_force_color_overrides_to_plate(conn) -> None:
  947. """Re-scope queue items that carry another plate's filament overrides (#2551).
  948. Queueing several plates of one 3MF used to store the union of every selected
  949. plate's overrides on each item, so a ``force_color_match`` plate printing one
  950. colour sat at Waiting until a printer had the whole batch's palette loaded.
  951. The write paths now narrow to the plate, but items queued before the fix would
  952. stay stuck until the user deleted and re-added them by hand — with a waiting
  953. reason that gives no hint as to why. Repair them here instead.
  954. Only pending items are touched: a printing or finished item's overrides are a
  955. record of what it dispatched with, not an instruction. An item whose plate we
  956. cannot read keeps every override, per ``overrides_for_plate``. Idempotent —
  957. an already-scoped item narrows to itself and is not rewritten.
  958. """
  959. import json
  960. from pathlib import Path
  961. from sqlalchemy import text
  962. from backend.app.services.filament_requirements import overrides_for_plate
  963. rows = (
  964. await conn.execute(
  965. text(
  966. "SELECT q.id, q.plate_id, q.filament_overrides, "
  967. "a.file_path AS archive_path, l.file_path AS library_path "
  968. "FROM print_queue q "
  969. "LEFT JOIN print_archives a ON a.id = q.archive_id "
  970. "LEFT JOIN library_files l ON l.id = q.library_file_id "
  971. "WHERE q.status = 'pending' "
  972. "AND q.plate_id IS NOT NULL "
  973. "AND q.filament_overrides IS NOT NULL"
  974. )
  975. )
  976. ).fetchall()
  977. repaired = 0
  978. for row in rows:
  979. try:
  980. overrides = json.loads(row.filament_overrides)
  981. except (json.JSONDecodeError, TypeError):
  982. continue
  983. if not isinstance(overrides, list) or not overrides:
  984. continue
  985. stored_path = row.archive_path or row.library_path
  986. if not stored_path:
  987. continue
  988. path = Path(stored_path)
  989. if not path.is_absolute():
  990. path = settings.base_dir / stored_path
  991. scoped = overrides_for_plate(overrides, path, row.plate_id)
  992. if len(scoped) == len(overrides):
  993. continue
  994. async with conn.begin_nested():
  995. await conn.execute(
  996. text("UPDATE print_queue SET filament_overrides = :overrides WHERE id = :id"),
  997. {"overrides": json.dumps(scoped) if scoped else None, "id": row.id},
  998. )
  999. repaired += 1
  1000. if repaired:
  1001. logger.info(
  1002. "Re-scoped the filament overrides of %d queued item(s) to the plate they print (#2551)",
  1003. repaired,
  1004. )
  1005. async def _migrate_scope_run_filament_to_plate(conn) -> None:
  1006. """Repair completed print-log rows that stored a multi-plate 3MF's whole-file
  1007. filament (and cost) instead of the printed plate's (#2614).
  1008. When the AMS tracker measured nothing for a completed run, the per-run filament
  1009. fell back to ``PrintArchive.filament_used_grams`` — the sum over EVERY plate of
  1010. the source 3MF (right for the archive card / project rollup, wrong for one
  1011. printed plate). So each printed plate of a 22-plate file logged the full ~12 kg,
  1012. inflating lifetime / user / project / filament stats by the plate count. The
  1013. forward fix scopes new rows; this repairs the rows already written.
  1014. Only completed rows whose stored grams EXACTLY equal the archive's whole-file
  1015. value are touched — that is the mis-copy signature. Tracker-measured rows (a
  1016. rounded spool-delta sum) and partial-progress rows (scaled to progress) never
  1017. match, so they are never clobbered. Cost is scaled by the plate's share of the
  1018. whole so it stays consistent with the corrected grams. Runs AFTER the #2603
  1019. archive plate_id backfill so ``print_archives.plate_id`` is populated.
  1020. Gated to run **exactly once** via a settings flag. This is not merely for
  1021. idempotency: a genuine single-plate print carries a ``plate_id`` too (the UI
  1022. always sends one), and for it the plate estimate legitimately equals the
  1023. whole-file value — so those rows match the signature on every boot. Without
  1024. the one-shot gate we would re-parse every single-plate 3MF on the print log at
  1025. each startup, a cost that grows without bound with print history. One pass is
  1026. enough: the forward fix keeps all new rows correct.
  1027. """
  1028. from pathlib import Path
  1029. from sqlalchemy import text
  1030. from backend.app.utils.threemf_tools import extract_plate_metadata_from_3mf
  1031. flag = "_backfill_2614_plate_filament_done"
  1032. async with conn.begin_nested():
  1033. already = (
  1034. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  1035. ).scalar_one_or_none()
  1036. if already:
  1037. return
  1038. rows = (
  1039. await conn.execute(
  1040. text(
  1041. "SELECT ple.id AS entry_id, ple.filament_used_grams AS grams, ple.cost AS cost, "
  1042. "a.plate_id AS plate_id, a.filament_used_grams AS whole_grams, a.file_path AS file_path "
  1043. "FROM print_log_entries ple "
  1044. "JOIN print_archives a ON a.id = ple.archive_id "
  1045. "WHERE ple.status = 'completed' "
  1046. "AND a.plate_id IS NOT NULL "
  1047. "AND a.file_path IS NOT NULL "
  1048. "AND a.filament_used_grams IS NOT NULL "
  1049. "AND ple.filament_used_grams IS NOT NULL "
  1050. "AND ple.filament_used_grams = a.filament_used_grams"
  1051. )
  1052. )
  1053. ).fetchall()
  1054. corrected = 0
  1055. grams_removed = 0.0
  1056. for row in rows:
  1057. path = Path(row.file_path)
  1058. if not path.is_absolute():
  1059. path = settings.base_dir / row.file_path
  1060. if not path.exists():
  1061. continue
  1062. try:
  1063. plate_grams = extract_plate_metadata_from_3mf(path, row.plate_id).filament_used_grams
  1064. except Exception as exc:
  1065. logger.warning(
  1066. "[#2614] could not read plate %s of %s for log entry %s: %s",
  1067. row.plate_id,
  1068. row.file_path,
  1069. row.entry_id,
  1070. exc,
  1071. )
  1072. continue
  1073. if not plate_grams or plate_grams <= 0:
  1074. continue
  1075. new_grams = round(plate_grams, 2)
  1076. if abs(new_grams - (row.grams or 0)) < 0.01:
  1077. continue # nothing to change (e.g. a genuine single-plate file)
  1078. new_cost = row.cost
  1079. whole = row.whole_grams or 0
  1080. if row.cost and whole > 0:
  1081. new_cost = round(row.cost * (plate_grams / whole), 2)
  1082. await conn.execute(
  1083. text("UPDATE print_log_entries SET filament_used_grams = :g, cost = :c WHERE id = :id"),
  1084. {"g": new_grams, "c": new_cost, "id": row.entry_id},
  1085. )
  1086. corrected += 1
  1087. grams_removed += (row.grams or 0) - new_grams
  1088. if corrected:
  1089. logger.info(
  1090. "[#2614] Re-scoped %d completed print-log row(s) from whole-file to plate filament "
  1091. "(removed %.0f g of over-counted usage from statistics)",
  1092. corrected,
  1093. grams_removed,
  1094. )
  1095. # Mark done unconditionally (even when nothing matched) so this one-shot
  1096. # never re-scans the print log on subsequent boots. id/timestamps come
  1097. # from the table's own defaults; "key" is quoted as it's a keyword.
  1098. await conn.execute(
  1099. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  1100. {"k": flag, "v": "true"},
  1101. )
  1102. async def _reclassify_sliced_3mf_library_files(conn) -> None:
  1103. """Re-type library rows holding a sliced 3MF that does not say so (#2993).
  1104. ``file_type`` was decided from the filename alone, so a sliced 3MF whose
  1105. name lacks the ``.gcode`` infix landed as a source-only project. That is
  1106. not a rare shape: a plate exported from Studio, or a print dispatched
  1107. through the cloud, reaches the archive as ``Foo.3mf`` with its G-code
  1108. intact, and downloading one and re-importing it produced a library file
  1109. Bambuddy refused to offer a Print button for. The forward fix classifies on
  1110. content; this pass reaches the rows already stored.
  1111. One-shot, for the same reason the #2614 backfill is: a genuine source 3MF
  1112. keeps matching ``file_type = '3mf'`` forever, so without the gate every
  1113. boot would re-open every model file in the library.
  1114. External rows are deliberately skipped. They point at a mount that may be
  1115. slow, unmounted, or enormous, and startup is the worst possible place to
  1116. find that out -- the folder's own scan re-types them with no such risk.
  1117. """
  1118. from pathlib import Path
  1119. from sqlalchemy import text
  1120. from backend.app.utils.threemf_tools import carries_gcode
  1121. flag = "_backfill_2993_sliced_3mf_type_done"
  1122. async with conn.begin_nested():
  1123. already = (
  1124. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  1125. ).scalar_one_or_none()
  1126. if already:
  1127. return
  1128. rows = (
  1129. await conn.execute(
  1130. text(
  1131. "SELECT id, file_path FROM library_files "
  1132. "WHERE file_type = '3mf' AND deleted_at IS NULL "
  1133. "AND file_path IS NOT NULL AND file_path <> '' "
  1134. "AND (is_external IS NULL OR is_external = :false_val)"
  1135. ),
  1136. {"false_val": False},
  1137. )
  1138. ).fetchall()
  1139. reclassified = 0
  1140. for row in rows:
  1141. path = Path(row.file_path)
  1142. if not path.is_absolute():
  1143. path = settings.base_dir / row.file_path
  1144. # carries_gcode swallows a missing or unreadable file, so a library
  1145. # with holes in it still finishes the pass.
  1146. if not carries_gcode(path):
  1147. continue
  1148. await conn.execute(
  1149. text("UPDATE library_files SET file_type = 'gcode.3mf' WHERE id = :id"),
  1150. {"id": row.id},
  1151. )
  1152. reclassified += 1
  1153. if reclassified:
  1154. logger.info(
  1155. "[#2993] Re-typed %d library file(s) from source 3MF to sliced -- they carry G-code",
  1156. reclassified,
  1157. )
  1158. # Marked done even when nothing matched, so the scan never repeats.
  1159. await conn.execute(
  1160. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  1161. {"k": flag, "v": "true"},
  1162. )
  1163. async def _backfill_archive_bed_temperature(conn) -> None:
  1164. """Fill in ``print_archives.bed_temperature`` for archives written before #2989.
  1165. Bed temperature was read by looking for a ``bed_temperature`` key, which
  1166. BambuStudio does not write -- it stores a per-filament array per plate type
  1167. and names the fitted plate in ``curr_bed_type``. Every archive from a Bambu
  1168. slice therefore stored NULL: 0 of 455 real 3MFs resolved on the install this
  1169. was measured on. The forward fix reads the right array; without this, every
  1170. archive made before it stays blank, and preheat keeps falling back to the
  1171. keep-warm bed temperature when those jobs are reprinted from the queue.
  1172. Only rows that are still NULL are touched, and only from the 3MF already on
  1173. disk -- nothing is invented and nothing already recorded is overwritten. An
  1174. archive whose file is gone (a no-3MF fallback, or one whose 3MF has been
  1175. cleaned up) is skipped and stays NULL, which is the honest answer.
  1176. Gated to run exactly once via a settings flag, like #2614's repair. The
  1177. work itself is repeatable -- it only fills NULLs -- but the rows it cannot
  1178. fill are exactly the ones it would re-open on every boot, and that set grows
  1179. with print history.
  1180. """
  1181. from pathlib import Path
  1182. from sqlalchemy import text
  1183. from backend.app.utils.threemf_tools import extract_bed_temperature_from_3mf
  1184. flag = "_backfill_2989_bed_temperature_done"
  1185. async with conn.begin_nested():
  1186. already = (
  1187. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  1188. ).scalar_one_or_none()
  1189. if already is not None:
  1190. # Presence, not truthiness. A flag row that somehow holds an empty
  1191. # string would otherwise re-run and then fail the unique key on the
  1192. # INSERT below -- which, at startup, is a boot loop.
  1193. return
  1194. rows = (
  1195. await conn.execute(
  1196. text(
  1197. "SELECT id, file_path FROM print_archives "
  1198. "WHERE bed_temperature IS NULL "
  1199. "AND file_path IS NOT NULL AND file_path != ''"
  1200. )
  1201. )
  1202. ).fetchall()
  1203. filled = 0
  1204. for row in rows:
  1205. # Per row, and broad, for the reason in the extractor's docstring:
  1206. # nothing above this has a handler, so one unreadable archive must
  1207. # not cost the user their boot. #2614's repair guards its rows the
  1208. # same way.
  1209. try:
  1210. path = Path(row.file_path)
  1211. if not path.is_absolute():
  1212. path = settings.base_dir / row.file_path
  1213. if not path.exists():
  1214. continue
  1215. temperature = extract_bed_temperature_from_3mf(path)
  1216. except Exception as exc:
  1217. logger.warning("[#2989] could not read %s for archive %s: %s", row.file_path, row.id, exc)
  1218. continue
  1219. if not temperature:
  1220. continue
  1221. await conn.execute(
  1222. text("UPDATE print_archives SET bed_temperature = :t WHERE id = :id"),
  1223. {"t": temperature, "id": row.id},
  1224. )
  1225. filled += 1
  1226. if filled:
  1227. logger.info(
  1228. "[#2989] Read the bed temperature from the 3MF for %d archive(s) that had none",
  1229. filled,
  1230. )
  1231. # Marked done even when nothing matched, so the rows it could not fill --
  1232. # which are exactly the ones it would re-open every boot -- are not
  1233. # rescanned forever. Same shape as #2614's one-shot.
  1234. await conn.execute(
  1235. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  1236. {"k": flag, "v": "true"},
  1237. )
  1238. async def _migrate_drop_library_print_name(conn) -> None:
  1239. """Strip the embedded 3MF Title (``print_name``) from library file metadata (#1489).
  1240. Library files stored the 3MF's ``<metadata name="Title">`` as
  1241. ``file_metadata.print_name`` — generic ("Exported 3D Model") for Bambu
  1242. Studio exports, a marketing title for MakerWorld downloads — and the
  1243. FileManager wrongly preferred it over the filename for the card label,
  1244. search and sort. New imports no longer store it; this clears it from rows
  1245. imported before the fix so existing libraries don't need a rename
  1246. round-trip. Idempotent — rows without the key are untouched.
  1247. """
  1248. from sqlalchemy import text
  1249. async with conn.begin_nested():
  1250. if is_sqlite():
  1251. await conn.execute(
  1252. text(
  1253. "UPDATE library_files SET file_metadata = json_remove(file_metadata, '$.print_name') "
  1254. "WHERE json_extract(file_metadata, '$.print_name') IS NOT NULL"
  1255. )
  1256. )
  1257. else:
  1258. # file_metadata is a JSON (not JSONB) column — cast to jsonb for the
  1259. # key-exists test (jsonb_exists, avoiding the `?` operator which
  1260. # clashes with driver parameter syntax) and the `- key` removal.
  1261. await conn.execute(
  1262. text(
  1263. "UPDATE library_files SET file_metadata = (file_metadata::jsonb - 'print_name')::json "
  1264. "WHERE jsonb_exists(file_metadata::jsonb, 'print_name')"
  1265. )
  1266. )
  1267. async def _migrate_update_auto_link_constraint(conn) -> None:
  1268. """Update the auto_link CHECK constraint to allow Fall C (custom email claim).
  1269. Old formula: auto_link = FALSE OR (require_ev = TRUE AND email_claim = 'email')
  1270. New formula: auto_link = FALSE OR email_claim != 'email' OR require_ev = TRUE
  1271. Only Fall B (email_claim='email' + require_ev=False) remains blocked.
  1272. Fall C (custom claim, e.g. Azure preferred_username/upn) is now allowed.
  1273. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute (idempotent).
  1274. SQLite: table recreation when old formula is detected in sqlite_master (idempotent).
  1275. """
  1276. from sqlalchemy import text
  1277. _NEW_FORMULA = "auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE"
  1278. _CONSTRAINT_NAME = "ck_auto_link_requires_verified_email_claim"
  1279. if not is_sqlite():
  1280. await _safe_execute(conn, f"ALTER TABLE oidc_providers DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}")
  1281. await _safe_execute(
  1282. conn,
  1283. f"ALTER TABLE oidc_providers ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  1284. )
  1285. else:
  1286. row = (
  1287. await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='oidc_providers'"))
  1288. ).fetchone()
  1289. # Only recreate if the old (more restrictive) formula is still present.
  1290. # Fresh installs created with the new __table_args__ already have the correct formula.
  1291. # Installs without any constraint (pre-SEC-1 upgrades) are skipped — app-level guards suffice.
  1292. if row and "require_email_verified = TRUE AND email_claim = 'email'" in row[0]:
  1293. try:
  1294. async with conn.begin_nested():
  1295. await conn.execute(text("DROP TABLE IF EXISTS oidc_providers_v2"))
  1296. await conn.execute(
  1297. text(
  1298. "CREATE TABLE oidc_providers_v2 ("
  1299. "id INTEGER NOT NULL, "
  1300. "name VARCHAR(100) NOT NULL, "
  1301. "issuer_url VARCHAR(500) NOT NULL, "
  1302. "client_id VARCHAR(255) NOT NULL, "
  1303. "client_secret VARCHAR(512) NOT NULL, "
  1304. "scopes VARCHAR(500), "
  1305. "is_enabled BOOLEAN, "
  1306. "auto_create_users BOOLEAN, "
  1307. "auto_link_existing_accounts BOOLEAN DEFAULT 0, "
  1308. "email_claim VARCHAR(64) DEFAULT 'email', "
  1309. "require_email_verified BOOLEAN DEFAULT 1, "
  1310. "icon_url TEXT, "
  1311. "created_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  1312. "updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, "
  1313. "PRIMARY KEY (id), "
  1314. f"UNIQUE (name), "
  1315. f"CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})"
  1316. ")"
  1317. )
  1318. )
  1319. await conn.execute(
  1320. text(
  1321. "INSERT INTO oidc_providers_v2 "
  1322. "(id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  1323. "auto_create_users, auto_link_existing_accounts, email_claim, "
  1324. "require_email_verified, icon_url, created_at, updated_at) "
  1325. "SELECT id, name, issuer_url, client_id, client_secret, scopes, is_enabled, "
  1326. "auto_create_users, auto_link_existing_accounts, email_claim, "
  1327. "require_email_verified, icon_url, created_at, updated_at "
  1328. "FROM oidc_providers"
  1329. )
  1330. )
  1331. original = (await conn.execute(text("SELECT count(*) FROM oidc_providers"))).scalar_one()
  1332. copied = (await conn.execute(text("SELECT count(*) FROM oidc_providers_v2"))).scalar_one()
  1333. if copied != original:
  1334. raise RuntimeError(
  1335. f"auto_link constraint migration: row count mismatch after copy "
  1336. f"({original} in source, {copied} in copy)"
  1337. )
  1338. await conn.execute(text("DROP TABLE oidc_providers"))
  1339. await conn.execute(text("ALTER TABLE oidc_providers_v2 RENAME TO oidc_providers"))
  1340. except Exception as exc:
  1341. logger.error(
  1342. "auto_link constraint update (SQLite table recreation) FAILED: %s",
  1343. exc,
  1344. exc_info=True,
  1345. )
  1346. raise
  1347. async def _migrate_widen_spoolman_slot_ams_id_range(conn) -> None:
  1348. """Widen ck_ams_id_range on spoolman_slot_assignments to admit AMS-HT (#1274).
  1349. Old formula: (ams_id >= 0 AND ams_id <= 7) OR ams_id = 255
  1350. New formula: (ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255
  1351. The H2C/H2D AMS-HT reports ams_id 128+. The old constraint rejected every
  1352. AMS-HT slot link with `IntegrityError: CHECK constraint failed: ck_ams_id_range`.
  1353. PostgreSQL: DROP CONSTRAINT IF EXISTS + ADD new formula via _safe_execute.
  1354. SQLite: table recreation when the old (narrower) formula is detected in
  1355. sqlite_master. Fresh installs already have the widened constraint from
  1356. the CREATE TABLE migration above.
  1357. """
  1358. from sqlalchemy import text
  1359. _NEW_FORMULA = "(ams_id >= 0 AND ams_id <= 7) OR (ams_id >= 128 AND ams_id <= 191) OR ams_id = 255"
  1360. _CONSTRAINT_NAME = "ck_ams_id_range"
  1361. if not is_sqlite():
  1362. await _safe_execute(
  1363. conn,
  1364. f"ALTER TABLE spoolman_slot_assignments DROP CONSTRAINT IF EXISTS {_CONSTRAINT_NAME}",
  1365. )
  1366. await _safe_execute(
  1367. conn,
  1368. f"ALTER TABLE spoolman_slot_assignments ADD CONSTRAINT {_CONSTRAINT_NAME} CHECK ({_NEW_FORMULA})",
  1369. )
  1370. return
  1371. row = (
  1372. await conn.execute(
  1373. text("SELECT sql FROM sqlite_master WHERE type='table' AND name='spoolman_slot_assignments'")
  1374. )
  1375. ).fetchone()
  1376. if not row:
  1377. return
  1378. sql = row[0] or ""
  1379. # Already widened by an earlier run or by the fresh-install CREATE TABLE above.
  1380. if "ams_id >= 128" in sql:
  1381. return
  1382. # Pre-migration table without any CHECK constraint at all → leave alone;
  1383. # the app-level validation handles correctness and we don't risk a
  1384. # destructive table rebuild for a constraint that isn't blocking anyone.
  1385. if "ck_ams_id_range" not in sql and "ams_id <= 7" not in sql:
  1386. return
  1387. try:
  1388. async with conn.begin_nested():
  1389. await conn.execute(text("DROP TABLE IF EXISTS spoolman_slot_assignments_v2"))
  1390. await conn.execute(
  1391. text(
  1392. "CREATE TABLE spoolman_slot_assignments_v2 ("
  1393. "id INTEGER PRIMARY KEY AUTOINCREMENT, "
  1394. "printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE, "
  1395. f"ams_id INTEGER NOT NULL CHECK ({_NEW_FORMULA}), "
  1396. "tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3), "
  1397. "spoolman_spool_id INTEGER NOT NULL, "
  1398. "assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, "
  1399. "CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)"
  1400. ")"
  1401. )
  1402. )
  1403. await conn.execute(
  1404. text(
  1405. "INSERT INTO spoolman_slot_assignments_v2 "
  1406. "(id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at) "
  1407. "SELECT id, printer_id, ams_id, tray_id, spoolman_spool_id, assigned_at "
  1408. "FROM spoolman_slot_assignments"
  1409. )
  1410. )
  1411. original = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments"))).scalar_one()
  1412. copied = (await conn.execute(text("SELECT count(*) FROM spoolman_slot_assignments_v2"))).scalar_one()
  1413. if copied != original:
  1414. raise RuntimeError(
  1415. f"spoolman_slot_assignments migration: row count mismatch after copy "
  1416. f"({original} in source, {copied} in copy)"
  1417. )
  1418. await conn.execute(text("DROP TABLE spoolman_slot_assignments"))
  1419. await conn.execute(text("ALTER TABLE spoolman_slot_assignments_v2 RENAME TO spoolman_slot_assignments"))
  1420. # The index sits on the renamed table; recreate it idempotently
  1421. # to handle older sqlite versions that don't auto-rename indexes.
  1422. await conn.execute(
  1423. text(
  1424. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool "
  1425. "ON spoolman_slot_assignments (spoolman_spool_id)"
  1426. )
  1427. )
  1428. except Exception as exc:
  1429. logger.error(
  1430. "spoolman_slot_assignments ck_ams_id_range widening (SQLite table recreation) FAILED: %s",
  1431. exc,
  1432. exc_info=True,
  1433. )
  1434. raise
  1435. async def _migrate_create_finance_tables(conn) -> None:
  1436. """Create finance tables missing from databases that predate billing.
  1437. ``Base.metadata.create_all()`` covers fresh installs, but upgrade and
  1438. restore paths can run the handwritten migrations against an existing
  1439. PostgreSQL schema. The finance column migrations below must therefore not
  1440. assume these tables already exist.
  1441. ``UserWallet`` is mapped to ``user_wallets``.
  1442. """
  1443. if is_sqlite():
  1444. statements = [
  1445. """
  1446. CREATE TABLE IF NOT EXISTS cost_centers (
  1447. id INTEGER PRIMARY KEY,
  1448. code VARCHAR(32) NOT NULL UNIQUE,
  1449. name VARCHAR(150) NOT NULL,
  1450. is_active BOOLEAN NOT NULL DEFAULT 1,
  1451. is_private BOOLEAN NOT NULL DEFAULT 0,
  1452. owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1453. total_budget NUMERIC(14,2),
  1454. monthly_budget NUMERIC(14,2),
  1455. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1456. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1457. )
  1458. """,
  1459. """
  1460. CREATE TABLE IF NOT EXISTS user_wallets (
  1461. id INTEGER PRIMARY KEY,
  1462. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  1463. balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
  1464. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  1465. )
  1466. """,
  1467. """
  1468. CREATE TABLE IF NOT EXISTS cost_center_members (
  1469. id INTEGER PRIMARY KEY,
  1470. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1471. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  1472. can_print BOOLEAN NOT NULL DEFAULT 1,
  1473. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1474. CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
  1475. )
  1476. """,
  1477. """
  1478. CREATE TABLE IF NOT EXISTS wallet_transactions (
  1479. id INTEGER PRIMARY KEY,
  1480. user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1481. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  1482. transaction_type VARCHAR(40) NOT NULL,
  1483. amount NUMERIC(14,2) NOT NULL,
  1484. balance_after NUMERIC(14,2),
  1485. description TEXT,
  1486. created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1487. print_run_id VARCHAR(100),
  1488. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1489. print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
  1490. is_voided BOOLEAN NOT NULL DEFAULT 0,
  1491. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1492. CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
  1493. transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
  1494. )
  1495. )
  1496. """,
  1497. """
  1498. CREATE TABLE IF NOT EXISTS budget_reservations (
  1499. id INTEGER PRIMARY KEY,
  1500. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1501. amount NUMERIC(14,2) NOT NULL,
  1502. status VARCHAR(20) NOT NULL,
  1503. source_type VARCHAR(50) NOT NULL,
  1504. source_id INTEGER,
  1505. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1506. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1507. released_at DATETIME
  1508. )
  1509. """,
  1510. ]
  1511. else:
  1512. statements = [
  1513. """
  1514. CREATE TABLE IF NOT EXISTS cost_centers (
  1515. id SERIAL PRIMARY KEY,
  1516. code VARCHAR(32) NOT NULL UNIQUE,
  1517. name VARCHAR(150) NOT NULL,
  1518. is_active BOOLEAN NOT NULL DEFAULT TRUE,
  1519. is_private BOOLEAN NOT NULL DEFAULT FALSE,
  1520. owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1521. total_budget NUMERIC(14,2),
  1522. monthly_budget NUMERIC(14,2),
  1523. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1524. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  1525. )
  1526. """,
  1527. """
  1528. CREATE TABLE IF NOT EXISTS user_wallets (
  1529. id SERIAL PRIMARY KEY,
  1530. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  1531. balance NUMERIC(14,2) NOT NULL DEFAULT 0.0,
  1532. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  1533. )
  1534. """,
  1535. """
  1536. CREATE TABLE IF NOT EXISTS cost_center_members (
  1537. id SERIAL PRIMARY KEY,
  1538. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1539. user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  1540. can_print BOOLEAN NOT NULL DEFAULT TRUE,
  1541. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1542. CONSTRAINT uq_cost_center_members_cc_user UNIQUE (cost_center_id, user_id)
  1543. )
  1544. """,
  1545. """
  1546. CREATE TABLE IF NOT EXISTS wallet_transactions (
  1547. id SERIAL PRIMARY KEY,
  1548. user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1549. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  1550. transaction_type VARCHAR(40) NOT NULL,
  1551. amount NUMERIC(14,2) NOT NULL,
  1552. balance_after NUMERIC(14,2),
  1553. description TEXT,
  1554. created_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
  1555. print_run_id VARCHAR(100),
  1556. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1557. print_queue_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL,
  1558. is_voided BOOLEAN NOT NULL DEFAULT FALSE,
  1559. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1560. CONSTRAINT ck_wallet_transactions_transaction_type CHECK (
  1561. transaction_type IN ('print_charge', 'deposit', 'withdraw', 'manual_adjustment')
  1562. )
  1563. )
  1564. """,
  1565. """
  1566. CREATE TABLE IF NOT EXISTS budget_reservations (
  1567. id SERIAL PRIMARY KEY,
  1568. cost_center_id INTEGER NOT NULL REFERENCES cost_centers(id) ON DELETE CASCADE,
  1569. amount NUMERIC(14,2) NOT NULL,
  1570. status VARCHAR(20) NOT NULL,
  1571. source_type VARCHAR(50) NOT NULL,
  1572. source_id INTEGER,
  1573. print_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL,
  1574. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  1575. released_at TIMESTAMP
  1576. )
  1577. """,
  1578. ]
  1579. for statement in statements:
  1580. await _safe_execute(conn, statement)
  1581. async def _migrate_create_finance_indexes(conn) -> None:
  1582. """Create finance indexes after legacy tables have received new columns."""
  1583. # Older billing migrations created this as a non-unique index. Recreate it
  1584. # so upgraded databases enforce the same constraint as the ORM model.
  1585. await _safe_execute(conn, "DROP INDEX IF EXISTS ix_cost_centers_code")
  1586. indexes = [
  1587. "CREATE UNIQUE INDEX IF NOT EXISTS ix_cost_centers_code ON cost_centers (code)",
  1588. "CREATE INDEX IF NOT EXISTS ix_cost_centers_name ON cost_centers (name)",
  1589. "CREATE UNIQUE INDEX IF NOT EXISTS ix_user_wallets_user_id ON user_wallets (user_id)",
  1590. "CREATE INDEX IF NOT EXISTS ix_cost_center_members_cost_center_id ON cost_center_members (cost_center_id)",
  1591. "CREATE INDEX IF NOT EXISTS ix_cost_center_members_user_id ON cost_center_members (user_id)",
  1592. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_user_id ON wallet_transactions (user_id)",
  1593. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_cost_center_id ON wallet_transactions (cost_center_id)",
  1594. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_transaction_type ON wallet_transactions (transaction_type)",
  1595. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_by_user_id "
  1596. "ON wallet_transactions (created_by_user_id)",
  1597. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_run_id ON wallet_transactions (print_run_id)",
  1598. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_archive_id ON wallet_transactions (print_archive_id)",
  1599. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_print_queue_id ON wallet_transactions (print_queue_id)",
  1600. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_created_at ON wallet_transactions (created_at)",
  1601. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_cost_center_id ON budget_reservations (cost_center_id)",
  1602. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_status ON budget_reservations (status)",
  1603. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_type ON budget_reservations (source_type)",
  1604. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_source_id ON budget_reservations (source_id)",
  1605. "CREATE INDEX IF NOT EXISTS ix_budget_reservations_print_archive_id ON budget_reservations (print_archive_id)",
  1606. ]
  1607. for statement in indexes:
  1608. await _safe_execute(conn, statement)
  1609. async def _migrate_finance_money_to_numeric(conn) -> None:
  1610. """Convert persisted finance money columns on PostgreSQL upgrades."""
  1611. if is_sqlite():
  1612. # SQLite uses dynamic type affinity. New tables declare NUMERIC, while
  1613. # existing values remain protected by cent-rounding at write/rebuild.
  1614. return
  1615. columns = {
  1616. "cost_centers": ("total_budget", "monthly_budget"),
  1617. "user_wallets": ("balance",),
  1618. "wallet_transactions": ("amount", "balance_after"),
  1619. "budget_reservations": ("amount",),
  1620. }
  1621. for table_name, column_names in columns.items():
  1622. for column_name in column_names:
  1623. await _safe_execute(
  1624. conn,
  1625. f"ALTER TABLE {table_name} ALTER COLUMN {column_name} "
  1626. f"TYPE NUMERIC(14,2) USING ROUND({column_name}::numeric, 2)",
  1627. )
  1628. async def _migrate_drop_wallet_currency(conn) -> None:
  1629. """Remove ``user_wallets.currency`` (#3123).
  1630. An install has one currency, held in the ``currency`` app setting. The
  1631. column stored whatever was configured when a wallet row happened to be
  1632. created -- and three of its four writers hardcoded "EUR" -- so it could
  1633. only ever disagree with the setting. Everything reads the setting now, so
  1634. the column would otherwise sit here unread -- a trap for the next person
  1635. who finds it and assumes it means something.
  1636. Skipped on SQLite older than 3.35, which has no DROP COLUMN. Leaving the
  1637. column in place there costs nothing: no code references it and it carries
  1638. a DEFAULT, so inserts that omit it still succeed.
  1639. """
  1640. if is_sqlite():
  1641. import sqlite3
  1642. if sqlite3.sqlite_version_info < (3, 35, 0):
  1643. logger.info(
  1644. "SQLite %s has no ALTER TABLE DROP COLUMN; leaving the unused user_wallets.currency in place",
  1645. sqlite3.sqlite_version,
  1646. )
  1647. return
  1648. await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN currency")
  1649. return
  1650. await _safe_execute(conn, "ALTER TABLE user_wallets DROP COLUMN IF EXISTS currency")
  1651. async def _migrate_add_print_archive_cost_center(conn) -> None:
  1652. """Add the nullable cost-center link missing from pre-billing archives."""
  1653. await _safe_execute(
  1654. conn,
  1655. "ALTER TABLE print_archives ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL",
  1656. )
  1657. # Historical failure-reason labels, mapped to the canonical key that replaced
  1658. # them (issue #2974).
  1659. #
  1660. # Three writers used to put three different spellings of one cause into
  1661. # ``failure_reason``: the backend wrote English display labels, older versions
  1662. # of the archive editor wrote the *translated* label in whatever locale that
  1663. # user was running, and two stale-archive paths wrote English prose sentences.
  1664. # The Failure Analysis widget groups on the raw column, so one real cause could
  1665. # occupy several buckets -- measured on a live install before this landed: 91
  1666. # rows reading "User cancelled" beside 1 reading "userCancelled", which in an
  1667. # English UI rendered as the same words twice with different counts.
  1668. #
  1669. # This is deliberately a FROZEN SNAPSHOT rather than something derived from the
  1670. # locale files at run time. It maps values as they were written historically; if
  1671. # a translation is reworded tomorrow, the old string is still what sits in the
  1672. # database and still has to map. Regenerating it from ``en.ts`` and friends
  1673. # would silently stop recognising the very rows it exists to convert.
  1674. #
  1675. # Every label here resolves to exactly one key -- verified across all 14 locales
  1676. # with no collisions -- so the conversion is exact rather than a best guess. A
  1677. # value that is NOT in this map (free text from an older build, a translation
  1678. # since edited) is deliberately left alone: it already renders through the
  1679. # ``defaultValue`` fallback in both the editor and the Statistics breakdown, and
  1680. # guessing at it would be worse than leaving one honest string in its own bucket.
  1681. _LEGACY_FAILURE_REASON_LABELS: dict[str, str] = {
  1682. "Adhesion failure": "adhesionFailure",
  1683. "Agotamiento del filamento": "filamentRunout",
  1684. "Alabeo": "warping",
  1685. "Altro": "other",
  1686. "Annullato dall'utente": "userCancelled",
  1687. "Annulé par l'utilisateur": "userCancelled",
  1688. "Aucune mise à jour d'état reçue": "noStatusUpdate",
  1689. "Autre": "other",
  1690. "Az ekstrüzyon": "underExtrusion",
  1691. "Bico entupido": "cloggedNozzle",
  1692. "Boquilla obstruida": "cloggedNozzle",
  1693. "Buse bouchée": "cloggedNozzle",
  1694. "Bükülme": "warping",
  1695. "Cancelada por el usuario": "userCancelled",
  1696. "Cancelado pelo usuário": "userCancelled",
  1697. "Clogged nozzle": "cloggedNozzle",
  1698. "Corte de corriente": "powerFailure",
  1699. "Coupure courant": "powerFailure",
  1700. "Deformazione": "warping",
  1701. "Deslocamento de camada": "layerShift",
  1702. "Desplazamiento de capa": "layerShift",
  1703. "Diğer": "other",
  1704. "Door gebruiker geannuleerd": "userCancelled",
  1705. "Draadvorming": "stringing",
  1706. "Durum güncellemesi alınmadı": "noStatusUpdate",
  1707. "Décalage de couche": "layerShift",
  1708. "Défaut d'adhésion": "adhesionFailure",
  1709. "Empenamento": "warping",
  1710. "Espagueti / Desprendido": "spaghettiDetached",
  1711. "Fadenziehen": "stringing",
  1712. "Falha de adesão": "adhesionFailure",
  1713. "Falha de energia": "powerFailure",
  1714. "Fallimento adesione": "adhesionFailure",
  1715. "Fallo de adhesión": "adhesionFailure",
  1716. "Filament aufgebraucht": "filamentRunout",
  1717. "Filament bitti": "filamentRunout",
  1718. "Filament fini": "filamentRunout",
  1719. "Filament op": "filamentRunout",
  1720. "Filament runout": "filamentRunout",
  1721. "Filamento": "stringing",
  1722. "Filamento esaurito": "filamentRunout",
  1723. "Fim do filamento": "filamentRunout",
  1724. "Fios": "stringing",
  1725. "Geen statusupdate ontvangen": "noStatusUpdate",
  1726. "Güç kesintisi": "powerFailure",
  1727. "Haftungsfehler": "adhesionFailure",
  1728. "Hechtingsprobleem": "adhesionFailure",
  1729. "Hilos": "stringing",
  1730. "Katman kayması": "layerShift",
  1731. "Kein Statusupdate empfangen": "noStatusUpdate",
  1732. "Kromtrekken": "warping",
  1733. "Kullanıcı iptal etti": "userCancelled",
  1734. "Laagverschuiving": "layerShift",
  1735. "Layer shift": "layerShift",
  1736. "Mancanza corrente": "powerFailure",
  1737. "Nenhuma atualização de status recebida": "noStatusUpdate",
  1738. "Nessun aggiornamento di stato ricevuto": "noStatusUpdate",
  1739. "No se recibió actualización de estado": "noStatusUpdate",
  1740. "No status update received": "noStatusUpdate",
  1741. "Onderextrusie": "underExtrusion",
  1742. "Other": "other",
  1743. "Otro": "other",
  1744. "Outro": "other",
  1745. "Overig": "other",
  1746. "Power failure": "powerFailure",
  1747. "Schichtversatz": "layerShift",
  1748. "Sonstiges": "other",
  1749. "Sotto-estrusione": "underExtrusion",
  1750. "Sous-extrusion": "underExtrusion",
  1751. "Spagetti / Ayrılmış": "spaghettiDetached",
  1752. "Spaghetti / Abgelöst": "spaghettiDetached",
  1753. "Spaghetti / Destacado": "spaghettiDetached",
  1754. "Spaghetti / Detached": "spaghettiDetached",
  1755. "Spaghetti / Détaché": "spaghettiDetached",
  1756. "Spaghetti / losgeraakt": "spaghettiDetached",
  1757. "Spaghetti / staccato": "spaghettiDetached",
  1758. "Spostamento layer": "layerShift",
  1759. "Stale - print likely cancelled or failed without status update": "noStatusUpdate",
  1760. "Stale - reconciled after reconnect, end time unknown": "noStatusUpdate",
  1761. "Stringing": "stringing",
  1762. "Stringing (Cheveux d'ange)": "stringing",
  1763. "Stromausfall": "powerFailure",
  1764. "Stroomuitval": "powerFailure",
  1765. "Subextrusión": "underExtrusion",
  1766. "Subextrusão": "underExtrusion",
  1767. "Tıkalı nozul": "cloggedNozzle",
  1768. "Ugello intasato": "cloggedNozzle",
  1769. "Under-extrusion": "underExtrusion",
  1770. "Unterextrusion": "underExtrusion",
  1771. "User cancelled": "userCancelled",
  1772. "Verformung": "warping",
  1773. "Verstopfte Düse": "cloggedNozzle",
  1774. "Verstopte nozzle": "cloggedNozzle",
  1775. "Vom Benutzer abgebrochen": "userCancelled",
  1776. "Warping": "warping",
  1777. "Warping (Déformation)": "warping",
  1778. "Yapışma başarısız": "adhesionFailure",
  1779. "İplik oluşumu": "stringing",
  1780. "Биття філаменту": "filamentRunout",
  1781. "Викривлення": "warping",
  1782. "Другое": "other",
  1783. "Закончился филамент": "filamentRunout",
  1784. "Засмічене сопло": "cloggedNozzle",
  1785. "Засор сопла": "cloggedNozzle",
  1786. "Збій живлення": "powerFailure",
  1787. "Зсув шару": "layerShift",
  1788. "Користувач скасовано": "userCancelled",
  1789. "Коробление": "warping",
  1790. "Нанизування": "stringing",
  1791. "Недоэкструзия": "underExtrusion",
  1792. "Обновление статуса не получено": "noStatusUpdate",
  1793. "Оновлення статусу не отримано": "noStatusUpdate",
  1794. "Отменено пользователем": "userCancelled",
  1795. "Плохая адгезия к столу": "adhesionFailure",
  1796. "Порушення адгезії": "adhesionFailure",
  1797. "Підвидавлювання": "underExtrusion",
  1798. "Сбой питания": "powerFailure",
  1799. "Сдвиг слоёв": "layerShift",
  1800. "Спагетти / отрыв детали": "spaghettiDetached",
  1801. "Спагетті / Відр": "spaghettiDetached",
  1802. "Стрингинг": "stringing",
  1803. "інше": "other",
  1804. "その他": "other",
  1805. "ステータス更新を受信できませんでした": "noStatusUpdate",
  1806. "スパゲッティ / 剥離": "spaghettiDetached",
  1807. "ノズル詰まり": "cloggedNozzle",
  1808. "フィラメント切れ": "filamentRunout",
  1809. "ユーザーによるキャンセル": "userCancelled",
  1810. "レイヤーシフト": "layerShift",
  1811. "使用者取消": "userCancelled",
  1812. "其他": "other",
  1813. "反り": "warping",
  1814. "喷嘴堵塞": "cloggedNozzle",
  1815. "噴嘴堵塞": "cloggedNozzle",
  1816. "定着不良": "adhesionFailure",
  1817. "层偏移": "layerShift",
  1818. "層偏移": "layerShift",
  1819. "押出不足": "underExtrusion",
  1820. "拉丝": "stringing",
  1821. "拉丝 / 脱落": "spaghettiDetached",
  1822. "拉絲": "stringing",
  1823. "拉絲 / 脫落": "spaghettiDetached",
  1824. "挤出不足": "underExtrusion",
  1825. "擠出不足": "underExtrusion",
  1826. "断电": "powerFailure",
  1827. "斷電": "powerFailure",
  1828. "未收到状态更新": "noStatusUpdate",
  1829. "未收到狀態更新": "noStatusUpdate",
  1830. "用户取消": "userCancelled",
  1831. "糸引き": "stringing",
  1832. "翘曲": "warping",
  1833. "翹曲": "warping",
  1834. "耗材用完": "filamentRunout",
  1835. "附着力失败": "adhesionFailure",
  1836. "附著力失敗": "adhesionFailure",
  1837. "電源障害": "powerFailure",
  1838. "기타": "other",
  1839. "노즐 막힘": "cloggedNozzle",
  1840. "레이어 시프트": "layerShift",
  1841. "사용자 취소": "userCancelled",
  1842. "상태 업데이트를 받지 못함": "noStatusUpdate",
  1843. "스트링": "stringing",
  1844. "스파게티 / 분리": "spaghettiDetached",
  1845. "압출 부족": "underExtrusion",
  1846. "전원 실패": "powerFailure",
  1847. "접착 실패": "adhesionFailure",
  1848. "필라멘트 소진": "filamentRunout",
  1849. "휨": "warping",
  1850. }
  1851. async def _table_has_column(conn, table: str, column: str) -> bool:
  1852. """Whether ``table`` has ``column``, on either dialect.
  1853. ``table`` is interpolated into the SQLite PRAGMA (it cannot be bound), so
  1854. callers pass literals only.
  1855. """
  1856. from sqlalchemy import text
  1857. if is_sqlite():
  1858. result = await conn.execute(text(f"PRAGMA table_info({table})"))
  1859. return any(row[1] == column for row in result)
  1860. result = await conn.execute(
  1861. text("SELECT 1 FROM information_schema.columns WHERE table_name = :table AND column_name = :col"),
  1862. {"table": table, "col": column},
  1863. )
  1864. return result.first() is not None
  1865. async def _sqlite_table_exists(conn, name: str) -> bool:
  1866. from sqlalchemy import text
  1867. result = await conn.execute(text("SELECT 1 FROM sqlite_master WHERE name = :name"), {"name": name})
  1868. return result.first() is not None
  1869. async def _migrate_failure_reason_vocabulary(conn):
  1870. """Fold historical failure-reason labels onto the canonical keys (#2974).
  1871. ``print_archives.failure_reason`` and ``print_log_entries.failure_reason``
  1872. accumulated three spellings of the same cause -- see
  1873. ``_LEGACY_FAILURE_REASON_LABELS`` for who wrote what. The PATCH route in
  1874. ``api/routes/print_log.py`` has enforced the key vocabulary for a while and
  1875. ``derive_failure_reason`` now produces it too, so this is the one-time pass
  1876. that brings existing rows in line.
  1877. Deliberately NOT gated behind a settings flag, unlike the #2614 backfill.
  1878. The statement is self-terminating -- it only matches values in the map, and
  1879. a key is never a label, so a second run updates nothing -- which makes the
  1880. flag pure overhead. It would also be actively wrong: a user who restores an
  1881. older database, or upgrades through this version twice, would carry the flag
  1882. with none of the conversion, and their legacy rows would never be touched
  1883. again. Cheap and repeatable beats one-shot here.
  1884. """
  1885. from collections import defaultdict
  1886. from sqlalchemy import bindparam, text
  1887. # Invert the map before issuing anything: 168 labels collapse onto 12 keys,
  1888. # so one UPDATE per key with an IN list is 24 statements rather than 336
  1889. # single-value ones on every boot. Identity rows (an en.ts label that is
  1890. # spelled the same as its own key) are dropped -- they would match and
  1891. # rewrite themselves to the value they already hold.
  1892. by_key: dict[str, list[str]] = defaultdict(list)
  1893. for label, key in _LEGACY_FAILURE_REASON_LABELS.items():
  1894. if label != key:
  1895. by_key[key].append(label)
  1896. all_labels = [label for labels in by_key.values() for label in labels]
  1897. total = 0
  1898. async with conn.begin_nested():
  1899. # nosec B608 — the only interpolated fragment is `table`, which the loop
  1900. # below draws from a literal tuple; no caller value reaches the string.
  1901. # Both the key and the label list are bound parameters. A table name
  1902. # cannot be expressed as one, which is why it is interpolated at all.
  1903. for table in ("print_archives", "print_log_entries"):
  1904. # A database older than #1378 has no print_log_entries.failure_reason
  1905. # yet (a later ALTER in run_migrations adds it). Such a table cannot
  1906. # hold a legacy label, and querying it crashed startup with "no such
  1907. # column: failure_reason".
  1908. if not await _table_has_column(conn, table, "failure_reason"):
  1909. continue
  1910. has_work = (
  1911. await conn.execute(
  1912. text(
  1913. f"SELECT 1 FROM {table} WHERE failure_reason IN :labels LIMIT 1" # noqa: S608 # nosec B608
  1914. ).bindparams(bindparam("labels", expanding=True)),
  1915. {"labels": all_labels},
  1916. )
  1917. ).first() is not None
  1918. if not has_work:
  1919. continue
  1920. if table == "print_archives" and is_sqlite() and await _sqlite_table_exists(conn, "archive_fts"):
  1921. # Same trap as the plate_id backfill further down: archives
  1922. # created before the external-content FTS index existed were
  1923. # never indexed, and the AFTER UPDATE trigger's FTS 'delete' on
  1924. # such a row fails with "database disk image is malformed".
  1925. # Rebuild first so every row is present. Only when there is
  1926. # work, since a rebuild re-reads every archive.
  1927. await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
  1928. for key, labels in by_key.items():
  1929. result = await conn.execute(
  1930. text(
  1931. f"UPDATE {table} SET failure_reason = :key " # noqa: S608 # nosec B608
  1932. "WHERE failure_reason IN :labels"
  1933. ).bindparams(bindparam("key"), bindparam("labels", expanding=True)),
  1934. {"key": key, "labels": labels},
  1935. )
  1936. total += result.rowcount or 0
  1937. if total:
  1938. logger.info("[#2974] converted %d failure_reason value(s) to the canonical vocabulary", total)
  1939. async def _migrate_project_priority_status(conn) -> None:
  1940. """Give projects stored with an unknown priority or status a valid one.
  1941. Older builds stored what they were given: a file import without a priority
  1942. wrote ``0`` (SQLite), create accepted any priority and both imports any
  1943. status. The project editor sends both back on every save and refuses such
  1944. values, so the project could no longer be edited at all. An unknown
  1945. priority becomes ``normal``, an unknown status ``active`` -- the same
  1946. fallbacks the imports use now.
  1947. Not gated behind a flag, like the failure_reason vocabulary: it only matches
  1948. values nothing accepts, so a second run changes nothing, and a restored
  1949. older database is repaired on its next start.
  1950. """
  1951. from sqlalchemy import bindparam, text
  1952. from backend.app.models.project import PROJECT_PRIORITIES, PROJECT_STATUSES
  1953. repaired: dict[str, int] = {}
  1954. async with conn.begin_nested():
  1955. # nosec B608 -- `column` and `fallback` come from the literal tuple below;
  1956. # the accepted values are bound parameters.
  1957. for column, accepted, fallback in (
  1958. ("priority", PROJECT_PRIORITIES, "normal"),
  1959. ("status", PROJECT_STATUSES, "active"),
  1960. ):
  1961. result = await conn.execute(
  1962. text(
  1963. f"UPDATE projects SET {column} = :fallback " # noqa: S608 # nosec B608
  1964. f"WHERE {column} IS NULL OR {column} NOT IN :accepted"
  1965. ).bindparams(bindparam("fallback"), bindparam("accepted", expanding=True)),
  1966. {"fallback": fallback, "accepted": list(accepted)},
  1967. )
  1968. repaired[column] = result.rowcount or 0
  1969. if any(repaired.values()):
  1970. logger.info(
  1971. "Repaired projects: %d unknown priority -> normal, %d unknown status -> active",
  1972. repaired["priority"],
  1973. repaired["status"],
  1974. )
  1975. def _pg_ddl_if_not_exists(conn, cursor, statement, parameters, context, executemany):
  1976. """before_cursor_execute hook: the _pg_if_not_exists rewrite for every statement.
  1977. About 40 older migrations run their DDL through conn.execute() in a
  1978. try/except of their own rather than through _safe_execute, so rewriting there
  1979. alone would leave their server-side errors in the log.
  1980. """
  1981. return _pg_if_not_exists(statement), parameters
  1982. async def run_migrations(conn):
  1983. """Run _run_migrations; on PostgreSQL, with the IF NOT EXISTS rewrite in place.
  1984. The hook lives only on this connection and only for the migration run, so no
  1985. statement issued at runtime is ever rewritten.
  1986. """
  1987. if not _is_pg_conn(conn):
  1988. await _run_migrations(conn)
  1989. return
  1990. sync_conn = conn.sync_connection
  1991. event.listen(sync_conn, "before_cursor_execute", _pg_ddl_if_not_exists, retval=True)
  1992. try:
  1993. await _run_migrations(conn)
  1994. finally:
  1995. event.remove(sync_conn, "before_cursor_execute", _pg_ddl_if_not_exists)
  1996. async def _run_migrations(conn):
  1997. """Run all schema migrations and data backfills on startup.
  1998. Includes ALTER TABLE (add columns, rename columns, add constraints),
  1999. CREATE INDEX, CREATE TRIGGER, data UPDATE backfills, and table recreations
  2000. for complex SQLite schema changes that ALTER TABLE cannot handle.
  2001. DDL statements are wrapped in _safe_execute for idempotency.
  2002. DML backfills (UPDATE/DELETE) are executed directly via conn.execute()
  2003. inside begin_nested() so any failure is always fatal and never silently
  2004. swallowed.
  2005. """
  2006. from sqlalchemy import text
  2007. # Existing PostgreSQL databases predate the finance ORM tables. These must
  2008. # exist before any ALTER TABLE / CREATE INDEX statements below reference
  2009. # them. Fresh installs remain idempotent because create_all() runs first.
  2010. await _migrate_create_finance_tables(conn)
  2011. # Data migration: one vocabulary for failure_reason (#2974). Runs early so
  2012. # the Failure Analysis widget and the archive editor never observe a
  2013. # half-converted column.
  2014. await _migrate_failure_reason_vocabulary(conn)
  2015. # Migration: Add parent_run_id column to pipeline_runs (#1425 PR C).
  2016. # Links a retry-failed run back to its parent so the dashboard can show
  2017. # "Retry of run #N" inline. Idempotent on both SQLite and Postgres.
  2018. await _safe_execute(
  2019. conn,
  2020. "ALTER TABLE pipeline_runs ADD COLUMN parent_run_id INTEGER REFERENCES pipeline_runs(id) ON DELETE SET NULL",
  2021. )
  2022. # Migration: Add source_archive_id column to pipeline_runs (#1425 PR B follow-up).
  2023. # Allows a pipeline run to source from an archive's source 3MF in addition
  2024. # to a library file. Idempotent — _safe_execute swallows an already-applied
  2025. # statement on both SQLite and Postgres.
  2026. await _safe_execute(
  2027. conn,
  2028. "ALTER TABLE pipeline_runs ADD COLUMN source_archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL",
  2029. )
  2030. # Migration: Add is_favorite column to print_archives
  2031. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN is_favorite BOOLEAN DEFAULT 0")
  2032. # Migration: Add OIDC group sync columns (#3107). group_claim defaults to
  2033. # 'groups'; group_mapping defaults to '{}' (empty JSON object = sync off,
  2034. # the pre-#3107 behaviour). NOT NULL DEFAULT explicitly so an upgraded
  2035. # database matches what create_all builds on a fresh install (the model
  2036. # columns are non-nullable with server defaults) — a bare DEFAULT would
  2037. # leave the column nullable on the ALTER path and the two installs would
  2038. # disagree on the schema. Existing rows backfill the default on both
  2039. # SQLite and PostgreSQL.
  2040. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN group_claim VARCHAR(64) NOT NULL DEFAULT 'groups'")
  2041. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN group_mapping JSON NOT NULL DEFAULT '{}'")
  2042. # Migration: Add wallet_charge_skipped column to print_archives so deleted print charges stay deleted
  2043. if is_sqlite():
  2044. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT 0")
  2045. else:
  2046. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN wallet_charge_skipped BOOLEAN DEFAULT FALSE")
  2047. # Migration: Add content_hash column to print_archives for duplicate detection
  2048. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN content_hash VARCHAR(64)")
  2049. # Migration: Add auto_off_executed column to smart_plugs
  2050. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_executed BOOLEAN DEFAULT 0")
  2051. # Migration: Add on_print_stopped column to notification_providers
  2052. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_print_stopped BOOLEAN DEFAULT 1")
  2053. # Migration: Add source_3mf_path column to print_archives
  2054. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN source_3mf_path VARCHAR(500)")
  2055. # Migration: Add f3d_path column to print_archives for Fusion 360 design files
  2056. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN f3d_path VARCHAR(500)")
  2057. # Migration: Add plate_id column to print_archives (#2603). The selected plate
  2058. # of a multi-plate 3MF is copied from the queue item at dispatch so Print
  2059. # History can show the actual plate instead of falling back to Plate 1.
  2060. # Nullable, no default — identical DDL on SQLite and Postgres. Backfilled from
  2061. # linked queue rows below.
  2062. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN plate_id INTEGER")
  2063. # Migration: Add on_maintenance_due column to notification_providers
  2064. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_maintenance_due BOOLEAN DEFAULT 0")
  2065. # Migration: Add location column to printers for grouping
  2066. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN location VARCHAR(100)")
  2067. # Migration: Add interval_type column to maintenance_types
  2068. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN interval_type VARCHAR(20) DEFAULT 'hours'")
  2069. # Migration: Add is_deleted column to maintenance_types for soft-deletes
  2070. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN is_deleted BOOLEAN DEFAULT 0")
  2071. # Migration: Add cost_center columns expected by current finance model
  2072. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN code VARCHAR(32)")
  2073. if is_sqlite():
  2074. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT 0")
  2075. else:
  2076. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN is_private BOOLEAN DEFAULT FALSE")
  2077. await _safe_execute(
  2078. conn,
  2079. "ALTER TABLE cost_centers ADD COLUMN owner_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL",
  2080. )
  2081. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN total_budget NUMERIC(14,2)")
  2082. await _safe_execute(conn, "ALTER TABLE cost_centers ADD COLUMN monthly_budget NUMERIC(14,2)")
  2083. timestamp_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
  2084. await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN created_at {timestamp_type}")
  2085. await _safe_execute(conn, f"ALTER TABLE cost_centers ADD COLUMN updated_at {timestamp_type}")
  2086. # Backfill empty cost center codes on upgraded databases.
  2087. if is_sqlite():
  2088. await _safe_execute(
  2089. conn,
  2090. "UPDATE cost_centers SET code = lower(hex(randomblob(6))) WHERE code IS NULL OR trim(code) = ''",
  2091. )
  2092. else:
  2093. await _safe_execute(
  2094. conn,
  2095. "UPDATE cost_centers SET code = substr(md5(random()::text || clock_timestamp()::text), 1, 12) "
  2096. "WHERE code IS NULL OR btrim(code) = ''",
  2097. )
  2098. # Migration: Add custom_interval_type column to printer_maintenance
  2099. await _safe_execute(conn, "ALTER TABLE printer_maintenance ADD COLUMN custom_interval_type VARCHAR(20)")
  2100. # Migration: Add power alert columns to smart_plugs
  2101. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_enabled BOOLEAN DEFAULT 0")
  2102. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_high REAL")
  2103. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_low REAL")
  2104. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN power_alert_last_triggered DATETIME")
  2105. # Migration: Add schedule columns to smart_plugs
  2106. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_enabled BOOLEAN DEFAULT 0")
  2107. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_on_time VARCHAR(5)")
  2108. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN schedule_off_time VARCHAR(5)")
  2109. # Migration: Add daily digest columns to notification_providers
  2110. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_enabled BOOLEAN DEFAULT 0")
  2111. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN daily_digest_time VARCHAR(5)")
  2112. # Migration: Add print_run_id to wallet_transactions so repeated prints of the same archive
  2113. # can be billed independently without mutating archive history.
  2114. await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN print_run_id VARCHAR(100)")
  2115. # CREATE TABLE IF NOT EXISTS is a no-op for an older, incomplete table.
  2116. # Delay indexes until every legacy column they reference has been added.
  2117. await _migrate_finance_money_to_numeric(conn)
  2118. await _migrate_create_finance_indexes(conn)
  2119. # Runs after the CREATE TABLE above, which used to re-add the column on an
  2120. # install whose finance tables predate the ORM (#3123).
  2121. await _migrate_drop_wallet_currency(conn)
  2122. # Migration: Add missing-spool-assignment print-start notification toggle
  2123. try:
  2124. async with conn.begin_nested():
  2125. await conn.execute(
  2126. text(
  2127. "ALTER TABLE notification_providers ADD COLUMN on_print_missing_spool_assignment BOOLEAN DEFAULT 0"
  2128. )
  2129. )
  2130. except (OperationalError, ProgrammingError):
  2131. pass # Already applied
  2132. # Migration: Add project_id column to print_archives
  2133. try:
  2134. async with conn.begin_nested():
  2135. await conn.execute(
  2136. text(
  2137. "ALTER TABLE print_archives ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  2138. )
  2139. )
  2140. except (OperationalError, ProgrammingError):
  2141. pass # Already applied
  2142. # Migration: Add project_id column to print_queue
  2143. try:
  2144. async with conn.begin_nested():
  2145. await conn.execute(
  2146. text("ALTER TABLE print_queue ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  2147. )
  2148. except (OperationalError, ProgrammingError):
  2149. pass # Already applied
  2150. # Migration: Enforce uniqueness on user_oidc_links for existing rows.
  2151. # create_all() is idempotent and does not add constraints to existing tables,
  2152. # so we create covering unique indexes explicitly here.
  2153. await _safe_execute(
  2154. conn,
  2155. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_provider_sub"
  2156. " ON user_oidc_links (provider_id, provider_user_id)",
  2157. )
  2158. await _safe_execute(
  2159. conn,
  2160. "CREATE UNIQUE INDEX IF NOT EXISTS uq_oidc_link_user_provider ON user_oidc_links (user_id, provider_id)",
  2161. )
  2162. # Migration: Add unique indexes to prevent duplicate print-charge transactions
  2163. await _safe_execute(
  2164. conn,
  2165. "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_print_run ON wallet_transactions (transaction_type, print_run_id)",
  2166. )
  2167. await _safe_execute(
  2168. conn,
  2169. "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive ON wallet_transactions (transaction_type, print_archive_id)",
  2170. )
  2171. # Migration: Create FTS5 virtual table for archive full-text search (SQLite only)
  2172. # PostgreSQL uses tsvector + GIN index instead (set up in archives.py search route)
  2173. if is_sqlite():
  2174. try:
  2175. await conn.execute(
  2176. text("""
  2177. CREATE VIRTUAL TABLE IF NOT EXISTS archive_fts USING fts5(
  2178. print_name,
  2179. filename,
  2180. tags,
  2181. notes,
  2182. designer,
  2183. filament_type,
  2184. content='print_archives',
  2185. content_rowid='id'
  2186. )
  2187. """)
  2188. )
  2189. except (OperationalError, ProgrammingError):
  2190. pass # Already applied
  2191. # Migration: Create triggers to keep FTS index in sync
  2192. try:
  2193. await conn.execute(
  2194. text("""
  2195. CREATE TRIGGER IF NOT EXISTS archive_fts_insert AFTER INSERT ON print_archives BEGIN
  2196. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  2197. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  2198. END
  2199. """)
  2200. )
  2201. except (OperationalError, ProgrammingError):
  2202. pass # Already applied
  2203. try:
  2204. await conn.execute(
  2205. text("""
  2206. CREATE TRIGGER IF NOT EXISTS archive_fts_delete AFTER DELETE ON print_archives BEGIN
  2207. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  2208. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  2209. END
  2210. """)
  2211. )
  2212. except (OperationalError, ProgrammingError):
  2213. pass # Already applied
  2214. try:
  2215. await conn.execute(
  2216. text("""
  2217. CREATE TRIGGER IF NOT EXISTS archive_fts_update AFTER UPDATE ON print_archives BEGIN
  2218. INSERT INTO archive_fts(archive_fts, rowid, print_name, filename, tags, notes, designer, filament_type)
  2219. VALUES ('delete', old.id, old.print_name, old.filename, old.tags, old.notes, old.designer, old.filament_type);
  2220. INSERT INTO archive_fts(rowid, print_name, filename, tags, notes, designer, filament_type)
  2221. VALUES (new.id, new.print_name, new.filename, new.tags, new.notes, new.designer, new.filament_type);
  2222. END
  2223. """)
  2224. )
  2225. except (OperationalError, ProgrammingError):
  2226. pass # Already applied
  2227. # One-shot: index the archives that existed before archive_fts did. Its
  2228. # creation above runs no rebuild, so on such installs those rows were
  2229. # never indexed, and the AFTER UPDATE trigger's FTS 'delete' on one of
  2230. # them fails with "database disk image is malformed" -- any UPDATE of an
  2231. # old archive, including the start-up cleanup in
  2232. # _migrate_add_missing_set_null_fks, which would then fail every start.
  2233. # The two backfills below rebuilt only when they had work of their own.
  2234. fts_flag = "_archive_fts_rebuilt_once"
  2235. already = (
  2236. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": fts_flag})
  2237. ).scalar_one_or_none()
  2238. if already is None:
  2239. await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
  2240. await conn.execute(
  2241. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": fts_flag, "v": "true"}
  2242. )
  2243. # Migration: Add auto_off_pending columns to smart_plugs (for restart recovery)
  2244. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending BOOLEAN DEFAULT 0")
  2245. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_pending_since DATETIME")
  2246. # Migration: Add auto_off_persistent column to smart_plugs (keep auto-off enabled between prints)
  2247. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_persistent BOOLEAN DEFAULT 0")
  2248. # Migration: Add AMS alarm notification columns to notification_providers
  2249. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_humidity_high BOOLEAN DEFAULT 0")
  2250. try:
  2251. async with conn.begin_nested():
  2252. await conn.execute(
  2253. text("ALTER TABLE notification_providers ADD COLUMN on_ams_temperature_high BOOLEAN DEFAULT 0")
  2254. )
  2255. except (OperationalError, ProgrammingError):
  2256. pass # Already applied
  2257. # Migration: Add AMS-HT alarm notification columns to notification_providers
  2258. try:
  2259. async with conn.begin_nested():
  2260. await conn.execute(
  2261. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_humidity_high BOOLEAN DEFAULT 0")
  2262. )
  2263. except (OperationalError, ProgrammingError):
  2264. pass # Already applied
  2265. try:
  2266. async with conn.begin_nested():
  2267. await conn.execute(
  2268. text("ALTER TABLE notification_providers ADD COLUMN on_ams_ht_temperature_high BOOLEAN DEFAULT 0")
  2269. )
  2270. except (OperationalError, ProgrammingError):
  2271. pass # Already applied
  2272. # Migration: Add plate not empty notification column to notification_providers
  2273. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_not_empty BOOLEAN DEFAULT 1")
  2274. # Migration: Add notes column to projects (Phase 2)
  2275. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN notes TEXT")
  2276. # Migration: Add attachments column to projects (Phase 3)
  2277. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN attachments JSON")
  2278. # Migration: Add tags column to projects (Phase 4)
  2279. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN tags TEXT")
  2280. # Migration: Add due_date column to projects (Phase 5)
  2281. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN due_date DATETIME")
  2282. # Migration: Add priority column to projects (Phase 5)
  2283. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN priority VARCHAR(20) DEFAULT 'normal'")
  2284. # Migration: Add budget column to projects (Phase 6)
  2285. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN budget REAL")
  2286. # Migration: Add is_template column to projects (Phase 8)
  2287. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN is_template BOOLEAN DEFAULT 0")
  2288. # Migration: Add template_source_id column to projects (Phase 8)
  2289. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN template_source_id INTEGER")
  2290. # Migration: Add parent_id column to projects (Phase 10)
  2291. try:
  2292. async with conn.begin_nested():
  2293. await conn.execute(
  2294. text("ALTER TABLE projects ADD COLUMN parent_id INTEGER REFERENCES projects(id) ON DELETE SET NULL")
  2295. )
  2296. except (OperationalError, ProgrammingError):
  2297. pass # Already applied
  2298. # Data migration: unknown project priority/status -> the editor's fallbacks.
  2299. # After the priority column exists (above).
  2300. await _migrate_project_priority_status(conn)
  2301. # Migration: Rename quantity_printed to quantity_acquired in project_bom_items
  2302. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN quantity_printed TO quantity_acquired")
  2303. # Migration: Add unit_price column to project_bom_items
  2304. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN unit_price REAL")
  2305. # Migration: Add sourcing_url column to project_bom_items
  2306. await _safe_execute(conn, "ALTER TABLE project_bom_items ADD COLUMN sourcing_url VARCHAR(512)")
  2307. # Migration: Rename notes to remarks in project_bom_items
  2308. await _safe_execute(conn, "ALTER TABLE project_bom_items RENAME COLUMN notes TO remarks")
  2309. # Migration: Add show_in_switchbar column to smart_plugs
  2310. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_in_switchbar BOOLEAN DEFAULT 0")
  2311. # Migration: Add runtime tracking columns to printers
  2312. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN runtime_seconds INTEGER DEFAULT 0")
  2313. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN last_runtime_update DATETIME")
  2314. # Migration: Add quantity column to print_archives for tracking item count
  2315. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN quantity INTEGER DEFAULT 1")
  2316. # Migration: Add manual_start column to print_queue for staged prints
  2317. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN manual_start BOOLEAN DEFAULT 0")
  2318. # Migration: Add cost_center_id column to print_queue for billing metadata
  2319. try:
  2320. async with conn.begin_nested():
  2321. await conn.execute(
  2322. text(
  2323. "ALTER TABLE print_queue ADD COLUMN cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL"
  2324. )
  2325. )
  2326. except (OperationalError, ProgrammingError):
  2327. pass # Already applied
  2328. # Migration: Add cost_center_id column to print_archives for billing metadata
  2329. await _migrate_add_print_archive_cost_center(conn)
  2330. # Migration: Add wiki_url column to maintenance_types for documentation links
  2331. await _safe_execute(conn, "ALTER TABLE maintenance_types ADD COLUMN wiki_url VARCHAR(500)")
  2332. # Migration: Add tailscale_disabled column to virtual_printers. Opt-in: default TRUE so
  2333. # the auto-detect + fallback noise only runs for users who explicitly enable it.
  2334. # Postgres rejects `DEFAULT 1` for BOOLEAN (#1070 round-2 review).
  2335. if is_sqlite():
  2336. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT 1")
  2337. else:
  2338. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN tailscale_disabled BOOLEAN DEFAULT true")
  2339. # Migration: Add ams_mapping column to print_queue for storing filament slot assignments
  2340. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN ams_mapping TEXT")
  2341. # Migration: filament_short flag on print_queue (#1496). Set by the
  2342. # dispatch scheduler when the assigned spool can't satisfy the print's
  2343. # per-slot weight; surfaced as a "filament short" badge on the queue row.
  2344. # Postgres rejects `DEFAULT 0` for BOOLEAN — branch on dialect.
  2345. if is_sqlite():
  2346. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT 0")
  2347. else:
  2348. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_short BOOLEAN DEFAULT false")
  2349. # Migration: skip_filament_check flag on print_queue (#1698-followup).
  2350. # Persists the user's "Print Anyway" acknowledgement so the scheduler
  2351. # doesn't re-flag the item every tick after they've confirmed dispatch
  2352. # despite the deficit warning. Set from the start route's skip_filament_check
  2353. # query param and from PrintModal at queue-creation time. Postgres / SQLite
  2354. # boolean default branch matches filament_short above.
  2355. if is_sqlite():
  2356. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT 0")
  2357. else:
  2358. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN skip_filament_check BOOLEAN DEFAULT false")
  2359. # Migration: cleanup flag for transient printer-card uploads routed through
  2360. # the scheduler. The archive copy is durable; the library row/file can be
  2361. # deleted after dispatch.
  2362. if is_sqlite():
  2363. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT 0")
  2364. else:
  2365. await _safe_execute(
  2366. conn, "ALTER TABLE print_queue ADD COLUMN cleanup_library_after_dispatch BOOLEAN DEFAULT false"
  2367. )
  2368. # Migration: Add queue_force_color_match column to virtual_printers (#1188).
  2369. # Opt-in flag: when true, VP queue-mode uploads pin the per-slot type+color
  2370. # from the 3MF onto the queue item's filament_overrides so the scheduler
  2371. # refuses to dispatch onto a printer with the wrong filament loaded.
  2372. # Default false to preserve current behaviour for upgraders.
  2373. if is_sqlite():
  2374. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT 0")
  2375. else:
  2376. await _safe_execute(
  2377. conn, "ALTER TABLE virtual_printers ADD COLUMN queue_force_color_match BOOLEAN DEFAULT FALSE"
  2378. )
  2379. # Migration: Add save_ams_mapping column to virtual_printers. Opt-in flag:
  2380. # when true, VP queue-mode uploads persist the slicer's own AMS-slot pick
  2381. # onto the archive (`extra_data.slicer_ams_mapping`) for reuse on reprint.
  2382. # Default false to preserve current behaviour for upgraders.
  2383. if is_sqlite():
  2384. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT 0")
  2385. else:
  2386. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN save_ams_mapping BOOLEAN DEFAULT FALSE")
  2387. # Per-VP opt-in for auto-print G-code injection (#1516). Default false so
  2388. # existing gcode_snippets users don't silently start injecting on VP/Studio
  2389. # Send jobs after upgrading.
  2390. if is_sqlite():
  2391. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT 0")
  2392. else:
  2393. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE")
  2394. # Migration: nozzle_mapping + nozzles_info on print_queue for H2C rack-swap
  2395. # slicer-pick preservation (#1780). Opaque JSON-string column carrying
  2396. # BambuStudio's per-filament physical nozzle position IDs, forwarded
  2397. # straight from the VP intake to the dispatcher's project_file MQTT
  2398. # command. NULL on every other model. Nullable TEXT — no Postgres / SQLite
  2399. # divergence here. `nozzles_info` shipped in the original #1780 attempt
  2400. # but BambuStudio never actually sends it (verified via wire capture on
  2401. # H2C, see CHANGELOG 0.2.5b1) — the column stays nullable so old rows
  2402. # still load; nothing reads or writes to it anymore.
  2403. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_mapping TEXT")
  2404. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzles_info TEXT")
  2405. # Migration: nozzle_rack_choice (#1784). Which rack position each filament
  2406. # group prints from, as JSON {group_id: 1-based position}. Kept separate
  2407. # from nozzle_mapping above because that one is BambuStudio's own expanded
  2408. # answer and rides to the printer verbatim, while this is the operator's
  2409. # pick and has to survive being re-checked against a rack that may have
  2410. # been re-loaded since. Also on the variants table so a batch clone does
  2411. # not silently lose it. Nullable TEXT, no Postgres / SQLite divergence.
  2412. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_rack_choice TEXT")
  2413. await _safe_execute(conn, "ALTER TABLE print_queue_variants ADD COLUMN nozzle_rack_choice TEXT")
  2414. # Migration: Add target_parts_count column to projects for tracking total parts needed
  2415. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_parts_count INTEGER")
  2416. # Migration: Add url + cover_image_filename columns to projects (#1155).
  2417. # url: external link rendered next to the project name on the card.
  2418. # cover_image_filename: filename of the project's hero image inside the
  2419. # existing attachments dir; rendered as a thumbnail on the card.
  2420. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN url VARCHAR(2048)")
  2421. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN cover_image_filename VARCHAR(255)")
  2422. # Migration: enhanced filament colour handling on color_catalog (#1154).
  2423. # Mirrors the Spool columns added below; widens hex_color to VARCHAR(9)
  2424. # so catalog entries can store an alpha component (#RRGGBBAA). SQLite
  2425. # ignores VARCHAR length, so the widen only matters on PostgreSQL.
  2426. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN extra_colors VARCHAR(255)")
  2427. await _safe_execute(conn, "ALTER TABLE color_catalog ADD COLUMN effect_type VARCHAR(20)")
  2428. if not is_sqlite():
  2429. await _safe_execute(conn, "ALTER TABLE color_catalog ALTER COLUMN hex_color TYPE VARCHAR(9)")
  2430. # Migration: Make printer_id nullable in print_queue for unassigned queue items
  2431. # SQLite doesn't support ALTER COLUMN, so we need to recreate the table
  2432. # PostgreSQL gets the correct schema from create_all(), so skip this
  2433. if is_sqlite():
  2434. try:
  2435. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  2436. row = result.fetchone()
  2437. if row and "printer_id INTEGER NOT NULL" in (row[0] or ""):
  2438. cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
  2439. col_names = {col[1] for col in cols_result.fetchall()}
  2440. await conn.execute(
  2441. text("""
  2442. CREATE TABLE print_queue_new (
  2443. id INTEGER PRIMARY KEY,
  2444. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  2445. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  2446. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  2447. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  2448. position INTEGER DEFAULT 0,
  2449. scheduled_time DATETIME,
  2450. manual_start BOOLEAN DEFAULT 0,
  2451. require_previous_success BOOLEAN DEFAULT 0,
  2452. auto_off_after BOOLEAN DEFAULT 0,
  2453. ams_mapping TEXT,
  2454. status VARCHAR(20) DEFAULT 'pending',
  2455. started_at DATETIME,
  2456. completed_at DATETIME,
  2457. error_message TEXT,
  2458. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  2459. )
  2460. """)
  2461. )
  2462. if "cost_center_id" in col_names:
  2463. await conn.execute(
  2464. text("""
  2465. INSERT INTO print_queue_new
  2466. SELECT id, printer_id, archive_id, cost_center_id, project_id, position, scheduled_time,
  2467. manual_start, require_previous_success, auto_off_after, ams_mapping,
  2468. status, started_at, completed_at, error_message, created_at
  2469. FROM print_queue
  2470. """)
  2471. )
  2472. else:
  2473. await conn.execute(
  2474. text("""
  2475. INSERT INTO print_queue_new
  2476. SELECT id, printer_id, archive_id, NULL, project_id, position, scheduled_time,
  2477. manual_start, require_previous_success, auto_off_after, ams_mapping,
  2478. status, started_at, completed_at, error_message, created_at
  2479. FROM print_queue
  2480. """)
  2481. )
  2482. await conn.execute(text("DROP TABLE print_queue"))
  2483. await conn.execute(text("ALTER TABLE print_queue_new RENAME TO print_queue"))
  2484. except (OperationalError, ProgrammingError):
  2485. pass # Already applied
  2486. # Migration: Add plug_type column to smart_plugs for HA integration
  2487. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN plug_type VARCHAR(20) DEFAULT 'tasmota'")
  2488. # Migration: Add ha_entity_id column to smart_plugs for HA integration
  2489. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_entity_id VARCHAR(100)")
  2490. # Migration: Add project_id column to library_folders for linking folders to projects
  2491. try:
  2492. async with conn.begin_nested():
  2493. await conn.execute(
  2494. text(
  2495. "ALTER TABLE library_folders ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  2496. )
  2497. )
  2498. except (OperationalError, ProgrammingError):
  2499. pass # Already applied
  2500. # Migration: Add archive_id column to library_folders for linking folders to archives
  2501. try:
  2502. async with conn.begin_nested():
  2503. await conn.execute(
  2504. text(
  2505. "ALTER TABLE library_folders ADD COLUMN archive_id INTEGER REFERENCES print_archives(id) ON DELETE SET NULL"
  2506. )
  2507. )
  2508. except (OperationalError, ProgrammingError):
  2509. pass # Already applied
  2510. # Migration: Make ip_address nullable for HA plugs (SQLite requires table recreation)
  2511. # PostgreSQL gets the correct schema from create_all(), so skip this
  2512. if is_sqlite():
  2513. try:
  2514. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  2515. row = result.fetchone()
  2516. if row and "ip_address VARCHAR(45) NOT NULL" in (row[0] or ""):
  2517. await conn.execute(
  2518. text("""
  2519. CREATE TABLE smart_plugs_new (
  2520. id INTEGER PRIMARY KEY,
  2521. name VARCHAR(100) NOT NULL,
  2522. ip_address VARCHAR(45),
  2523. plug_type VARCHAR(20) DEFAULT 'tasmota',
  2524. ha_entity_id VARCHAR(100),
  2525. printer_id INTEGER UNIQUE REFERENCES printers(id) ON DELETE SET NULL,
  2526. enabled BOOLEAN NOT NULL DEFAULT 1,
  2527. auto_on BOOLEAN NOT NULL DEFAULT 1,
  2528. auto_off BOOLEAN NOT NULL DEFAULT 1,
  2529. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  2530. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  2531. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  2532. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  2533. username VARCHAR(50),
  2534. password VARCHAR(100),
  2535. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  2536. power_alert_high FLOAT,
  2537. power_alert_low FLOAT,
  2538. power_alert_last_triggered DATETIME,
  2539. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  2540. schedule_on_time VARCHAR(5),
  2541. schedule_off_time VARCHAR(5),
  2542. show_in_switchbar BOOLEAN DEFAULT 0,
  2543. last_state VARCHAR(10),
  2544. last_checked DATETIME,
  2545. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  2546. auto_off_pending BOOLEAN DEFAULT 0,
  2547. auto_off_pending_since DATETIME,
  2548. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  2549. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  2550. )
  2551. """)
  2552. )
  2553. await conn.execute(
  2554. text("""
  2555. INSERT INTO smart_plugs_new
  2556. SELECT id, name, ip_address,
  2557. COALESCE(plug_type, 'tasmota'), ha_entity_id, printer_id,
  2558. enabled, auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  2559. off_delay_mode, off_delay_minutes, off_temp_threshold,
  2560. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  2561. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  2562. COALESCE(show_in_switchbar, 0), last_state, last_checked, auto_off_executed,
  2563. COALESCE(auto_off_pending, 0), auto_off_pending_since, created_at, updated_at
  2564. FROM smart_plugs
  2565. """)
  2566. )
  2567. await conn.execute(text("DROP TABLE smart_plugs"))
  2568. await conn.execute(text("ALTER TABLE smart_plugs_new RENAME TO smart_plugs"))
  2569. except (OperationalError, ProgrammingError):
  2570. pass # Already applied
  2571. # Migration: Add plate_id column to print_queue for multi-plate 3MF support
  2572. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN plate_id INTEGER")
  2573. # Migration: Add print options columns to print_queue
  2574. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN bed_levelling BOOLEAN DEFAULT 1")
  2575. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN flow_cali BOOLEAN DEFAULT 0")
  2576. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN vibration_cali BOOLEAN DEFAULT 1")
  2577. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN layer_inspect BOOLEAN DEFAULT 0")
  2578. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN timelapse BOOLEAN DEFAULT 0")
  2579. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN use_ams BOOLEAN DEFAULT 1")
  2580. # Migration: Add nozzle offset calibration option (dual-nozzle printers, #1682).
  2581. # Postgres rejects `DEFAULT 1` on a BOOLEAN column — use TRUE / 1 per dialect.
  2582. if is_sqlite():
  2583. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT 1")
  2584. else:
  2585. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN nozzle_offset_cali BOOLEAN DEFAULT TRUE")
  2586. # Migration: convert bed_levelling / flow_cali / nozzle_offset_cali from
  2587. # boolean to tri-state strings (off/on/auto). BambuStudio exposes a third
  2588. # "auto" state for these (skip the calibration if it was done recently); our
  2589. # booleans could only send force-on / off. Legacy rows map true->'on',
  2590. # false->'off'; the new default is 'auto'. Idempotent on both dialects:
  2591. # SQLite leans on column affinity (a BOOLEAN-declared column stores text
  2592. # fine) and only rewrites rows still holding 0/1; PostgreSQL alters the
  2593. # column type only while it is still boolean, so re-runs and fresh
  2594. # create_all() schemas (already VARCHAR) are skipped. Column names are
  2595. # hardcoded constants, not user input.
  2596. _tristate_cols = ("bed_levelling", "flow_cali", "nozzle_offset_cali")
  2597. if is_sqlite():
  2598. for _col in _tristate_cols:
  2599. async with conn.begin_nested():
  2600. # B608 is a false positive here: _col is a hardcoded constant
  2601. # from _tristate_cols, never user input, and SQL identifiers
  2602. # can't be bound as parameters. Suppressed inline below.
  2603. await conn.execute(
  2604. text(f"UPDATE print_queue SET {_col} = 'on' WHERE {_col} IN (1, '1', 'true', 'True')") # nosec B608
  2605. )
  2606. await conn.execute(
  2607. text(f"UPDATE print_queue SET {_col} = 'off' WHERE {_col} IN (0, '0', 'false', 'False')") # nosec B608
  2608. )
  2609. else:
  2610. for _col in _tristate_cols:
  2611. result = await conn.execute(
  2612. text(
  2613. "SELECT data_type FROM information_schema.columns "
  2614. "WHERE table_name = 'print_queue' AND column_name = :col"
  2615. ),
  2616. {"col": _col},
  2617. )
  2618. row = result.fetchone()
  2619. if row and row[0] == "boolean":
  2620. await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} DROP DEFAULT")
  2621. await _safe_execute(
  2622. conn,
  2623. f"ALTER TABLE print_queue ALTER COLUMN {_col} TYPE VARCHAR(8) "
  2624. f"USING (CASE WHEN {_col} THEN 'on' ELSE 'off' END)",
  2625. )
  2626. await _safe_execute(conn, f"ALTER TABLE print_queue ALTER COLUMN {_col} SET DEFAULT 'auto'")
  2627. # Migration: normalise the workflow-default settings rows that back these
  2628. # options from legacy "true"/"false" to the tri-state vocabulary so the API
  2629. # returns real values (the AppSettings validator also coerces on read, but
  2630. # rewriting keeps the stored data honest). Only these three became tri-state.
  2631. for _skey in ("default_bed_levelling", "default_flow_cali", "default_nozzle_offset_cali"):
  2632. async with conn.begin_nested():
  2633. await conn.execute(
  2634. text("UPDATE settings SET value = 'on' WHERE key = :k AND lower(value) IN ('true', '1')"),
  2635. {"k": _skey},
  2636. )
  2637. await conn.execute(
  2638. text("UPDATE settings SET value = 'off' WHERE key = :k AND lower(value) IN ('false', '0')"),
  2639. {"k": _skey},
  2640. )
  2641. # Migration: Per-item preheat / heat-soak override (#1468). preheat_override
  2642. # is one of {inherit, on, off} — 'inherit' falls back to the global
  2643. # preheat_enabled setting; 'on' / 'off' force the decision. The chamber
  2644. # target column overrides the filament-map derivation when not null.
  2645. # Existing rows default to 'inherit' + NULL so behaviour is unchanged for
  2646. # in-flight queues.
  2647. await _safe_execute(
  2648. conn,
  2649. "ALTER TABLE print_queue ADD COLUMN preheat_override VARCHAR(10) DEFAULT 'inherit'",
  2650. )
  2651. await _safe_execute(
  2652. conn,
  2653. "ALTER TABLE print_queue ADD COLUMN preheat_chamber_target_override INTEGER",
  2654. )
  2655. # Migration: Add library_file_id column to print_queue and make archive_id nullable
  2656. # This allows queue items to reference library files directly (archive created at print start)
  2657. try:
  2658. async with conn.begin_nested():
  2659. await conn.execute(
  2660. text(
  2661. "ALTER TABLE print_queue ADD COLUMN library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE"
  2662. )
  2663. )
  2664. except (OperationalError, ProgrammingError):
  2665. pass # Already applied
  2666. # Check if archive_id needs to be made nullable (requires table recreation in SQLite)
  2667. # PostgreSQL gets the correct schema from create_all(), so skip this
  2668. if is_sqlite():
  2669. try:
  2670. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='print_queue'"))
  2671. row = result.fetchone()
  2672. if row and "archive_id INTEGER NOT NULL" in (row[0] or ""):
  2673. cols_result = await conn.execute(text("PRAGMA table_info(print_queue)"))
  2674. col_names = {col[1] for col in cols_result.fetchall()}
  2675. await conn.execute(
  2676. text("""
  2677. CREATE TABLE print_queue_new2 (
  2678. id INTEGER PRIMARY KEY,
  2679. printer_id INTEGER REFERENCES printers(id) ON DELETE CASCADE,
  2680. archive_id INTEGER REFERENCES print_archives(id) ON DELETE CASCADE,
  2681. library_file_id INTEGER REFERENCES library_files(id) ON DELETE CASCADE,
  2682. cost_center_id INTEGER REFERENCES cost_centers(id) ON DELETE SET NULL,
  2683. project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL,
  2684. position INTEGER DEFAULT 0,
  2685. scheduled_time DATETIME,
  2686. manual_start BOOLEAN DEFAULT 0,
  2687. require_previous_success BOOLEAN DEFAULT 0,
  2688. auto_off_after BOOLEAN DEFAULT 0,
  2689. ams_mapping TEXT,
  2690. plate_id INTEGER,
  2691. bed_levelling BOOLEAN DEFAULT 1,
  2692. flow_cali BOOLEAN DEFAULT 0,
  2693. vibration_cali BOOLEAN DEFAULT 1,
  2694. layer_inspect BOOLEAN DEFAULT 0,
  2695. timelapse BOOLEAN DEFAULT 0,
  2696. use_ams BOOLEAN DEFAULT 1,
  2697. status VARCHAR(20) DEFAULT 'pending',
  2698. started_at DATETIME,
  2699. completed_at DATETIME,
  2700. error_message TEXT,
  2701. created_at DATETIME DEFAULT CURRENT_TIMESTAMP
  2702. )
  2703. """)
  2704. )
  2705. if "cost_center_id" in col_names:
  2706. await conn.execute(
  2707. text("""
  2708. INSERT INTO print_queue_new2
  2709. SELECT id, printer_id, archive_id, NULL, cost_center_id, project_id, position, scheduled_time,
  2710. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  2711. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  2712. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  2713. status, started_at, completed_at, error_message, created_at
  2714. FROM print_queue
  2715. """)
  2716. )
  2717. else:
  2718. await conn.execute(
  2719. text("""
  2720. INSERT INTO print_queue_new2
  2721. SELECT id, printer_id, archive_id, NULL, NULL, project_id, position, scheduled_time,
  2722. manual_start, require_previous_success, auto_off_after, ams_mapping, plate_id,
  2723. COALESCE(bed_levelling, 1), COALESCE(flow_cali, 0), COALESCE(vibration_cali, 1),
  2724. COALESCE(layer_inspect, 0), COALESCE(timelapse, 0), COALESCE(use_ams, 1),
  2725. status, started_at, completed_at, error_message, created_at
  2726. FROM print_queue
  2727. """)
  2728. )
  2729. await conn.execute(text("DROP TABLE print_queue"))
  2730. await conn.execute(text("ALTER TABLE print_queue_new2 RENAME TO print_queue"))
  2731. except (OperationalError, ProgrammingError):
  2732. pass # Already applied
  2733. # Migration: Add dispatching_at claim column to print_queue (#2615). Nullable
  2734. # timestamp; the type differs by dialect (SQLite DATETIME vs Postgres
  2735. # TIMESTAMP) so an existing-DB upgrade doesn't hit "type datetime does not
  2736. # exist" on Postgres. On a fresh DB create_all() already built the column, so
  2737. # the ALTER is swallowed as already applied.
  2738. #
  2739. # Placed AFTER the print_queue_new2 table-recreate above: that recreate
  2740. # (SQLite-only, and only on ancient DBs whose archive_id is still NOT NULL)
  2741. # rebuilds print_queue from an explicit column list that doesn't carry this
  2742. # column, so adding it earlier would let the recreate silently drop it. Adding
  2743. # it here means it survives that path.
  2744. if is_sqlite():
  2745. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at DATETIME")
  2746. else:
  2747. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatching_at TIMESTAMP")
  2748. # Migration: Add HA energy sensor entity columns to smart_plugs
  2749. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_power_entity VARCHAR(100)")
  2750. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_today_entity VARCHAR(100)")
  2751. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN ha_energy_total_entity VARCHAR(100)")
  2752. # Migration: Create users table for authentication
  2753. try:
  2754. async with conn.begin_nested():
  2755. await conn.execute(
  2756. text("""
  2757. CREATE TABLE IF NOT EXISTS users (
  2758. id INTEGER PRIMARY KEY,
  2759. username VARCHAR(100) NOT NULL UNIQUE,
  2760. password_hash VARCHAR(255) NOT NULL,
  2761. role VARCHAR(20) NOT NULL DEFAULT 'user',
  2762. is_active BOOLEAN NOT NULL DEFAULT 1,
  2763. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  2764. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  2765. )
  2766. """)
  2767. )
  2768. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_users_username ON users(username)"))
  2769. except (OperationalError, ProgrammingError):
  2770. pass # Already applied
  2771. # Migration: Add external camera columns to printers
  2772. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_url VARCHAR(500)")
  2773. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_type VARCHAR(20)")
  2774. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_enabled BOOLEAN DEFAULT 0")
  2775. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN external_camera_snapshot_url VARCHAR(500)")
  2776. # Migration: Add external_url column to print_archives for user-defined links (Printables, etc.)
  2777. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN external_url VARCHAR(500)")
  2778. # Migration: Add sliced_for_model column to print_archives for model-based queue assignment
  2779. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN sliced_for_model VARCHAR(50)")
  2780. # Migration: Add is_external column to library_files for external cloud files
  2781. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN is_external BOOLEAN DEFAULT 0")
  2782. # Migration: Add project_id column to library_files
  2783. try:
  2784. async with conn.begin_nested():
  2785. await conn.execute(
  2786. text(
  2787. "ALTER TABLE library_files ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  2788. )
  2789. )
  2790. except (OperationalError, ProgrammingError):
  2791. pass # Already applied
  2792. # Migration: Add is_external column to library_folders for external cloud folders
  2793. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN is_external BOOLEAN DEFAULT 0")
  2794. # Migration: Add external folder settings columns to library_folders
  2795. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_readonly BOOLEAN DEFAULT 0")
  2796. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_show_hidden BOOLEAN DEFAULT 0")
  2797. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN external_path VARCHAR(500)")
  2798. # Migration: Add plate_detection_enabled column to printers
  2799. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_enabled BOOLEAN DEFAULT 0")
  2800. # Migration: Add plate detection ROI columns to printers
  2801. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_x REAL")
  2802. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_y REAL")
  2803. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_w REAL")
  2804. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN plate_detection_roi_h REAL")
  2805. # Migration: Remove UNIQUE constraint from smart_plugs.printer_id
  2806. # This allows HA scripts to coexist with regular plugs (scripts are for multi-device control)
  2807. # SQLite requires table recreation to drop constraints
  2808. # PostgreSQL gets the correct schema from create_all(), so skip this
  2809. if is_sqlite():
  2810. try:
  2811. needs_migration = False
  2812. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='smart_plugs'"))
  2813. row = result.fetchone()
  2814. table_sql = (row[0] or "").upper() if row else ""
  2815. if "PRINTER_ID" in table_sql and "UNIQUE" in table_sql:
  2816. import re
  2817. if re.search(r'"?PRINTER_ID"?\s+\w+\s+UNIQUE', table_sql) or re.search(
  2818. r'UNIQUE\s*\([^)]*"?PRINTER_ID"?', table_sql
  2819. ):
  2820. needs_migration = True
  2821. idx_result = await conn.execute(
  2822. text("SELECT sql FROM sqlite_master WHERE type='index' AND tbl_name='smart_plugs' AND sql IS NOT NULL")
  2823. )
  2824. for idx_row in idx_result.fetchall():
  2825. idx_sql = (idx_row[0] or "").upper()
  2826. if "UNIQUE" in idx_sql and "PRINTER_ID" in idx_sql:
  2827. needs_migration = True
  2828. break
  2829. if needs_migration:
  2830. # Create new table without UNIQUE constraint on printer_id
  2831. await conn.execute(
  2832. text("""
  2833. CREATE TABLE smart_plugs_temp (
  2834. id INTEGER PRIMARY KEY,
  2835. name VARCHAR(100) NOT NULL,
  2836. ip_address VARCHAR(45),
  2837. plug_type VARCHAR(20) DEFAULT 'tasmota',
  2838. ha_entity_id VARCHAR(100),
  2839. ha_power_entity VARCHAR(100),
  2840. ha_energy_today_entity VARCHAR(100),
  2841. ha_energy_total_entity VARCHAR(100),
  2842. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  2843. enabled BOOLEAN NOT NULL DEFAULT 1,
  2844. auto_on BOOLEAN NOT NULL DEFAULT 1,
  2845. auto_off BOOLEAN NOT NULL DEFAULT 1,
  2846. auto_off_persistent BOOLEAN NOT NULL DEFAULT 0,
  2847. off_delay_mode VARCHAR(20) NOT NULL DEFAULT 'time',
  2848. off_delay_minutes INTEGER NOT NULL DEFAULT 5,
  2849. off_temp_threshold INTEGER NOT NULL DEFAULT 70,
  2850. username VARCHAR(50),
  2851. password VARCHAR(100),
  2852. power_alert_enabled BOOLEAN NOT NULL DEFAULT 0,
  2853. power_alert_high FLOAT,
  2854. power_alert_low FLOAT,
  2855. power_alert_last_triggered DATETIME,
  2856. schedule_enabled BOOLEAN NOT NULL DEFAULT 0,
  2857. schedule_on_time VARCHAR(5),
  2858. schedule_off_time VARCHAR(5),
  2859. show_in_switchbar BOOLEAN DEFAULT 0,
  2860. last_state VARCHAR(10),
  2861. last_checked DATETIME,
  2862. auto_off_executed BOOLEAN NOT NULL DEFAULT 0,
  2863. auto_off_pending BOOLEAN DEFAULT 0,
  2864. auto_off_pending_since DATETIME,
  2865. created_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL,
  2866. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP NOT NULL
  2867. )
  2868. """)
  2869. )
  2870. # Copy data
  2871. await conn.execute(
  2872. text("""
  2873. INSERT INTO smart_plugs_temp
  2874. SELECT id, name, ip_address, plug_type, ha_entity_id, ha_power_entity,
  2875. ha_energy_today_entity, ha_energy_total_entity, printer_id, enabled,
  2876. auto_on, auto_off, COALESCE(auto_off_persistent, 0),
  2877. off_delay_mode, off_delay_minutes, off_temp_threshold,
  2878. username, password, power_alert_enabled, power_alert_high, power_alert_low,
  2879. power_alert_last_triggered, schedule_enabled, schedule_on_time, schedule_off_time,
  2880. show_in_switchbar, last_state, last_checked, auto_off_executed,
  2881. auto_off_pending, auto_off_pending_since, created_at, updated_at
  2882. FROM smart_plugs
  2883. """)
  2884. )
  2885. # Drop old table and rename new one
  2886. await conn.execute(text("DROP TABLE smart_plugs"))
  2887. await conn.execute(text("ALTER TABLE smart_plugs_temp RENAME TO smart_plugs"))
  2888. except (OperationalError, ProgrammingError):
  2889. pass # Already applied
  2890. # Migration: Add show_on_printer_card column to smart_plugs
  2891. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN show_on_printer_card BOOLEAN DEFAULT 1")
  2892. # Migration: Add MQTT smart plug fields (legacy)
  2893. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_topic VARCHAR(200)")
  2894. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_path VARCHAR(100)")
  2895. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_path VARCHAR(100)")
  2896. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_path VARCHAR(100)")
  2897. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_multiplier REAL DEFAULT 1.0")
  2898. # Migration: Add enhanced MQTT smart plug fields (separate topics and multipliers)
  2899. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_topic VARCHAR(200)")
  2900. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_power_multiplier REAL DEFAULT 1.0")
  2901. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_topic VARCHAR(200)")
  2902. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_energy_multiplier REAL DEFAULT 1.0")
  2903. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_topic VARCHAR(200)")
  2904. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN mqtt_state_on_value VARCHAR(50)")
  2905. # Migration: Copy existing mqtt_topic to mqtt_power_topic for backward compatibility
  2906. try:
  2907. async with conn.begin_nested():
  2908. await conn.execute(
  2909. text("""
  2910. UPDATE smart_plugs
  2911. SET mqtt_power_topic = mqtt_topic,
  2912. mqtt_power_multiplier = mqtt_multiplier
  2913. WHERE mqtt_topic IS NOT NULL AND mqtt_power_topic IS NULL
  2914. """)
  2915. )
  2916. except (OperationalError, ProgrammingError):
  2917. pass # Already applied
  2918. # Migration: Create groups table for permission-based access control
  2919. try:
  2920. async with conn.begin_nested():
  2921. await conn.execute(
  2922. text("""
  2923. CREATE TABLE IF NOT EXISTS groups (
  2924. id INTEGER PRIMARY KEY,
  2925. name VARCHAR(100) NOT NULL UNIQUE,
  2926. description VARCHAR(500),
  2927. permissions JSON,
  2928. is_system BOOLEAN NOT NULL DEFAULT 0,
  2929. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  2930. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  2931. )
  2932. """)
  2933. )
  2934. await conn.execute(text("CREATE INDEX IF NOT EXISTS ix_groups_name ON groups(name)"))
  2935. except (OperationalError, ProgrammingError):
  2936. pass # Already applied
  2937. # Migration: Create user_groups association table
  2938. try:
  2939. async with conn.begin_nested():
  2940. await conn.execute(
  2941. text("""
  2942. CREATE TABLE IF NOT EXISTS user_groups (
  2943. user_id INTEGER NOT NULL,
  2944. group_id INTEGER NOT NULL,
  2945. PRIMARY KEY (user_id, group_id),
  2946. FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
  2947. FOREIGN KEY (group_id) REFERENCES groups(id) ON DELETE CASCADE
  2948. )
  2949. """)
  2950. )
  2951. except (OperationalError, ProgrammingError):
  2952. pass # Already applied
  2953. # Migration: Add model-based queue assignment columns to print_queue
  2954. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_model VARCHAR(50)")
  2955. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN required_filament_types TEXT")
  2956. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN waiting_reason TEXT")
  2957. # Migration: Add nozzle_count column to printers (for dual-extruder detection)
  2958. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN nozzle_count INTEGER DEFAULT 1")
  2959. # Migration: Add print_hours_offset column to printers (baseline hours adjustment)
  2960. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN print_hours_offset REAL DEFAULT 0.0")
  2961. # Migration: Add queue notification event columns to notification_providers
  2962. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_added BOOLEAN DEFAULT 0")
  2963. try:
  2964. async with conn.begin_nested():
  2965. await conn.execute(
  2966. text("ALTER TABLE notification_providers ADD COLUMN on_queue_job_assigned BOOLEAN DEFAULT 0")
  2967. )
  2968. except (OperationalError, ProgrammingError):
  2969. pass # Already applied
  2970. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_started BOOLEAN DEFAULT 0")
  2971. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_waiting BOOLEAN DEFAULT 1")
  2972. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_skipped BOOLEAN DEFAULT 1")
  2973. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_job_failed BOOLEAN DEFAULT 1")
  2974. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_queue_completed BOOLEAN DEFAULT 0")
  2975. # Migration: Add created_by_id column to print_archives for user tracking (Issue #206)
  2976. try:
  2977. async with conn.begin_nested():
  2978. await conn.execute(
  2979. text(
  2980. "ALTER TABLE print_archives ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  2981. )
  2982. )
  2983. except (OperationalError, ProgrammingError):
  2984. pass # Already applied
  2985. # Migration: Add created_by_id column to print_queue for user tracking (Issue #206)
  2986. try:
  2987. async with conn.begin_nested():
  2988. await conn.execute(
  2989. text("ALTER TABLE print_queue ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL")
  2990. )
  2991. except (OperationalError, ProgrammingError):
  2992. pass # Already applied
  2993. # Migration: Add created_by_id column to library_files for user tracking (Issue #206)
  2994. try:
  2995. async with conn.begin_nested():
  2996. await conn.execute(
  2997. text(
  2998. "ALTER TABLE library_files ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  2999. )
  3000. )
  3001. except (OperationalError, ProgrammingError):
  3002. pass # Already applied
  3003. # Migration: Add target_location column to print_queue for location-based filtering (Issue #220)
  3004. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN target_location VARCHAR(100)")
  3005. # Migration: Convert absolute paths to relative paths in library_files table
  3006. # This ensures backup/restore portability across different installations
  3007. try:
  3008. async with conn.begin_nested():
  3009. base_dir_str = str(settings.base_dir)
  3010. # Ensure we have a trailing slash for clean replacement
  3011. if not base_dir_str.endswith("/"):
  3012. base_dir_str += "/"
  3013. # Update file_path - remove base_dir prefix from absolute paths
  3014. await conn.execute(
  3015. text("""
  3016. UPDATE library_files
  3017. SET file_path = SUBSTR(file_path, LENGTH(:base_dir) + 1)
  3018. WHERE file_path LIKE :pattern
  3019. """),
  3020. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  3021. )
  3022. # Update thumbnail_path - remove base_dir prefix from absolute paths
  3023. await conn.execute(
  3024. text("""
  3025. UPDATE library_files
  3026. SET thumbnail_path = SUBSTR(thumbnail_path, LENGTH(:base_dir) + 1)
  3027. WHERE thumbnail_path LIKE :pattern
  3028. """),
  3029. {"base_dir": base_dir_str, "pattern": base_dir_str + "%"},
  3030. )
  3031. except (OperationalError, ProgrammingError):
  3032. pass # Already applied
  3033. # Create active_print_spoolman table for Spoolman per-filament tracking.
  3034. # filament_usage is nullable so the no-3MF branch can still create a row
  3035. # that carries only tray_remain_start for the remain%-delta fallback
  3036. # (#1820 — matches internal-inventory Path 2 in usage_tracker).
  3037. await _safe_execute(
  3038. conn,
  3039. """
  3040. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  3041. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3042. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3043. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  3044. filament_usage TEXT,
  3045. ams_trays TEXT NOT NULL,
  3046. slot_to_tray TEXT,
  3047. layer_usage TEXT,
  3048. filament_properties TEXT,
  3049. tray_remain_start TEXT,
  3050. tray_now_at_start INTEGER,
  3051. UNIQUE(printer_id, archive_id)
  3052. )
  3053. """
  3054. if is_sqlite()
  3055. else """
  3056. CREATE TABLE IF NOT EXISTS active_print_spoolman (
  3057. id SERIAL PRIMARY KEY,
  3058. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  3059. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  3060. filament_usage TEXT,
  3061. ams_trays TEXT NOT NULL,
  3062. slot_to_tray TEXT,
  3063. layer_usage TEXT,
  3064. filament_properties TEXT,
  3065. tray_remain_start TEXT,
  3066. tray_now_at_start INTEGER,
  3067. UNIQUE(printer_id, archive_id)
  3068. )
  3069. """,
  3070. )
  3071. # Migration for installs that already created active_print_spoolman with
  3072. # the original schema: add tray_remain_start, and relax filament_usage's
  3073. # NOT NULL so the no-3MF branch can persist a remain-only tracking row.
  3074. await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_remain_start TEXT")
  3075. # Which slot the print was drawing from at the start, so the remain%-delta
  3076. # fallback can tell a slot this print used from one it never touched
  3077. # (#1820). Nullable, because a row written mid-upgrade has no answer to
  3078. # give. INTEGER is spelled the same either way; the branch is only for
  3079. # IF NOT EXISTS, which SQLite's ALTER TABLE does not accept.
  3080. if is_sqlite():
  3081. await _safe_execute(conn, "ALTER TABLE active_print_spoolman ADD COLUMN tray_now_at_start INTEGER")
  3082. else:
  3083. await _safe_execute(
  3084. conn,
  3085. "ALTER TABLE active_print_spoolman ADD COLUMN IF NOT EXISTS tray_now_at_start INTEGER",
  3086. )
  3087. if is_sqlite():
  3088. # SQLite can't ALTER COLUMN; patch sqlite_master directly. Mirrors the
  3089. # users.password_hash NULL-relaxation a few hundred lines below — see
  3090. # the comment there for the schema_version bump rationale.
  3091. try:
  3092. result = await conn.execute(
  3093. text("SELECT sql FROM sqlite_master WHERE type='table' AND name='active_print_spoolman'")
  3094. )
  3095. tbl_sql = result.scalar()
  3096. if tbl_sql and "filament_usage TEXT NOT NULL" in tbl_sql:
  3097. version_result = await conn.execute(text("PRAGMA schema_version"))
  3098. schema_version = version_result.scalar() or 0
  3099. await conn.execute(text("PRAGMA writable_schema = ON"))
  3100. await conn.execute(
  3101. text(
  3102. "UPDATE sqlite_master "
  3103. "SET sql = replace(sql, 'filament_usage TEXT NOT NULL', 'filament_usage TEXT') "
  3104. "WHERE type='table' AND name='active_print_spoolman'"
  3105. )
  3106. )
  3107. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  3108. await conn.execute(text("PRAGMA writable_schema = OFF"))
  3109. except (OperationalError, ProgrammingError) as exc:
  3110. logger.warning(
  3111. "Could not relax active_print_spoolman.filament_usage NOT NULL via writable_schema: %s — "
  3112. "no-3MF Spoolman fallback will be a no-op on this install",
  3113. exc,
  3114. )
  3115. else:
  3116. await _safe_execute(conn, "ALTER TABLE active_print_spoolman ALTER COLUMN filament_usage DROP NOT NULL")
  3117. # Migration: Add preset_source column to slot_preset_mappings for local preset support
  3118. try:
  3119. async with conn.begin_nested():
  3120. await conn.execute(
  3121. text("ALTER TABLE slot_preset_mappings ADD COLUMN preset_source VARCHAR(20) DEFAULT 'cloud'")
  3122. )
  3123. except (OperationalError, ProgrammingError):
  3124. pass # Already applied
  3125. # Migration: Add email column to users for Advanced Auth (PR #322)
  3126. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN email VARCHAR(255)")
  3127. # Migration: Add inventory spool tracking columns
  3128. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN added_full BOOLEAN")
  3129. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_used DATETIME")
  3130. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN encode_time DATETIME")
  3131. # Migration: Add RFID tag matching columns to spool
  3132. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_uid VARCHAR(16)")
  3133. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tray_uuid VARCHAR(32)")
  3134. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN data_origin VARCHAR(20)")
  3135. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN tag_type VARCHAR(20)")
  3136. # Migration: Add core_weight_catalog_id to track which catalog entry was used for empty spool weight
  3137. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN core_weight_catalog_id INTEGER")
  3138. # Migration: Create spool_usage_history table for filament consumption tracking
  3139. await _safe_execute(
  3140. conn,
  3141. """
  3142. CREATE TABLE IF NOT EXISTS spool_usage_history (
  3143. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3144. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  3145. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  3146. print_name VARCHAR(500),
  3147. weight_used REAL NOT NULL DEFAULT 0,
  3148. percent_used INTEGER NOT NULL DEFAULT 0,
  3149. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  3150. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  3151. )
  3152. """
  3153. if is_sqlite()
  3154. else """
  3155. CREATE TABLE IF NOT EXISTS spool_usage_history (
  3156. id SERIAL PRIMARY KEY,
  3157. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  3158. printer_id INTEGER REFERENCES printers(id) ON DELETE SET NULL,
  3159. print_name VARCHAR(500),
  3160. weight_used REAL NOT NULL DEFAULT 0,
  3161. percent_used INTEGER NOT NULL DEFAULT 0,
  3162. status VARCHAR(20) NOT NULL DEFAULT 'completed',
  3163. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  3164. )
  3165. """,
  3166. )
  3167. # Migration: Add open_in_new_tab column to external_links
  3168. await _safe_execute(conn, "ALTER TABLE external_links ADD COLUMN open_in_new_tab BOOLEAN DEFAULT 0")
  3169. # Migration: Add bed cooled notification column to notification_providers
  3170. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_bed_cooled BOOLEAN DEFAULT 0")
  3171. # Migration: Add first layer complete notification column to notification_providers
  3172. try:
  3173. async with conn.begin_nested():
  3174. await conn.execute(
  3175. text("ALTER TABLE notification_providers ADD COLUMN on_first_layer_complete BOOLEAN DEFAULT 0")
  3176. )
  3177. except (OperationalError, ProgrammingError):
  3178. pass # Already applied
  3179. # Migration: Add weight_locked flag to spool table (skip AMS auto-sync for manually-entered weights)
  3180. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_locked BOOLEAN DEFAULT 0")
  3181. # Migration: Add SpoolBuddy scale weight tracking columns to spool table
  3182. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_scale_weight INTEGER")
  3183. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_weighed_at DATETIME")
  3184. # Migration: Add cost tracking fields to spool table
  3185. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN cost_per_kg REAL")
  3186. # Migration: Per-spool category + low-stock threshold override (#729). Both
  3187. # nullable — NULL category leaves the spool uncategorised, NULL threshold
  3188. # falls back to the global low_stock_threshold setting.
  3189. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN category VARCHAR(50)")
  3190. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN low_stock_threshold_pct INTEGER")
  3191. # Migration: Add user-editable storage location to spool table
  3192. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN storage_location VARCHAR(255)")
  3193. # Migration: Add weight_used_baseline anchor for the resettable "Total
  3194. # Consumed" stat (#1390). Existing spools default to 0 (no baseline),
  3195. # so the counter starts unaffected; pressing "Reset usage to 0" now
  3196. # stamps baseline = weight_used without touching remaining.
  3197. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN weight_used_baseline REAL DEFAULT 0")
  3198. # Migration: Widen tag_uid column from VARCHAR(16) to VARCHAR(32) to accommodate 7-byte NFC
  3199. # UIDs (14 hex chars) in addition to 8-byte Bambu Lab UIDs (16 hex chars).
  3200. # ALTER COLUMN ... TYPE is PostgreSQL-only syntax; SQLite ignores VARCHAR sizes so no-op there.
  3201. if not is_sqlite():
  3202. await _safe_execute(conn, "ALTER TABLE spool ALTER COLUMN tag_uid TYPE VARCHAR(32)")
  3203. # Migration: enhanced filament colour handling (#1154). `extra_colors` is
  3204. # a comma-separated list of 6- or 8-char hex tokens (no `#`) for multi-
  3205. # colour gradients; `effect_type` is one of {sparkle, wood, marble, glow,
  3206. # matte} as a visual rendering hint. Both nullable — NULL keeps the
  3207. # current single-rgba/no-effect behaviour.
  3208. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN extra_colors VARCHAR(255)")
  3209. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN effect_type VARCHAR(20)")
  3210. # Migration: Add cost field to spool_usage_history table
  3211. await _safe_execute(conn, "ALTER TABLE spool_usage_history ADD COLUMN cost REAL")
  3212. # Migration: Add archive_id field to spool_usage_history table
  3213. try:
  3214. async with conn.begin_nested():
  3215. await conn.execute(
  3216. text("ALTER TABLE spool_usage_history ADD COLUMN archive_id INTEGER REFERENCES print_archives(id)")
  3217. )
  3218. except (OperationalError, ProgrammingError):
  3219. pass # Already applied
  3220. # Migration: Migrate single virtual printer key-value settings to virtual_printers table.
  3221. # Once: the settings stay (the virtual printer settings still write some), so
  3222. # with no virtual printers left this rebuilt one at every start, aimed at the
  3223. # old target, which SQLite may have given to another printer since.
  3224. vp_flag = "_migrate_single_virtual_printer_done"
  3225. try:
  3226. async with conn.begin_nested():
  3227. already = (
  3228. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": vp_flag})
  3229. ).scalar_one_or_none()
  3230. result = await conn.execute(text("SELECT COUNT(*) FROM virtual_printers"))
  3231. count = result.scalar() or 0
  3232. if count == 0 and not already:
  3233. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_enabled'"))
  3234. row = result.fetchone()
  3235. if row:
  3236. # Old settings exist — migrate to first virtual printer row
  3237. old_enabled = row[0] == "true" if row[0] else False
  3238. result = await conn.execute(
  3239. text("SELECT value FROM settings WHERE key = 'virtual_printer_access_code'")
  3240. )
  3241. row = result.fetchone()
  3242. old_access_code = row[0] if row else None
  3243. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_mode'"))
  3244. row = result.fetchone()
  3245. old_mode = row[0] if row else "archive"
  3246. # Translate to canonical wire values (#1429 mode-label
  3247. # discrepancy): legacy `immediate` → `archive`, legacy
  3248. # `print_queue` → `queue`. The historical `queue` alias
  3249. # for `review` predates the canonical rename and is
  3250. # preserved (existing user intent was "pending review").
  3251. if old_mode == "queue":
  3252. old_mode = "review"
  3253. elif old_mode == "immediate":
  3254. old_mode = "archive"
  3255. elif old_mode == "print_queue":
  3256. old_mode = "queue"
  3257. result = await conn.execute(text("SELECT value FROM settings WHERE key = 'virtual_printer_model'"))
  3258. row = result.fetchone()
  3259. old_model = row[0] if row else "BL-P001"
  3260. result = await conn.execute(
  3261. text("SELECT value FROM settings WHERE key = 'virtual_printer_target_printer_id'")
  3262. )
  3263. row = result.fetchone()
  3264. old_target_id = int(row[0]) if row and row[0] else None
  3265. result = await conn.execute(
  3266. text("SELECT value FROM settings WHERE key = 'virtual_printer_remote_interface_ip'")
  3267. )
  3268. row = result.fetchone()
  3269. old_remote_iface = row[0] if row else None
  3270. await conn.execute(
  3271. text("""
  3272. INSERT INTO virtual_printers
  3273. (name, enabled, mode, model, access_code, target_printer_id,
  3274. bind_ip, remote_interface_ip, serial_suffix, position)
  3275. VALUES
  3276. (:name, :enabled, :mode, :model, :access_code, :target_id,
  3277. NULL, :remote_iface, '391800001', 0)
  3278. """),
  3279. {
  3280. "name": "Bambuddy",
  3281. "enabled": old_enabled,
  3282. "mode": old_mode or "archive",
  3283. "model": old_model,
  3284. "access_code": old_access_code,
  3285. "target_id": old_target_id,
  3286. "remote_iface": old_remote_iface,
  3287. },
  3288. )
  3289. if not already:
  3290. await conn.execute(
  3291. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": vp_flag, "v": "true"}
  3292. )
  3293. except (OperationalError, ProgrammingError, IntegrityError):
  3294. pass # Table may not exist yet on first run, or columns have different constraints
  3295. # Migration: Add filament_overrides column to print_queue for filament override in model-based assignment
  3296. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN filament_overrides TEXT")
  3297. # Migration: Add NFC reader and display control columns to spoolbuddy_devices
  3298. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_reader_type VARCHAR(20)")
  3299. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN nfc_connection VARCHAR(20)")
  3300. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_brightness INTEGER DEFAULT 100")
  3301. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN display_blank_timeout INTEGER DEFAULT 0")
  3302. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN has_backlight BOOLEAN DEFAULT 0")
  3303. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN last_calibrated_at DATETIME")
  3304. # Migration: Add NFC tag write payload column to spoolbuddy_devices
  3305. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_write_payload TEXT")
  3306. # Migration: Add OTA update tracking columns to spoolbuddy_devices
  3307. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_status VARCHAR(20)")
  3308. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN update_message VARCHAR(255)")
  3309. # Migration: Persist SpoolBuddy backend URL and queued system payload
  3310. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN backend_url VARCHAR(255)")
  3311. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN pending_system_payload TEXT")
  3312. # Migration: Add system_stats JSON blob column to spoolbuddy_devices
  3313. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN system_stats TEXT")
  3314. # Migration: Add SSH host key for TOFU verification (H1 security fix)
  3315. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ADD COLUMN ssh_host_key VARCHAR(500)")
  3316. # Migration: Widen ssh_host_key from VARCHAR(500) to TEXT — RSA-3072+ host keys
  3317. # in OpenSSH format exceed 500 chars (RSA-4096 ~720 chars). PostgreSQL enforces
  3318. # the limit and rejects the UPDATE; SQLite ignores VARCHAR length so no-op there.
  3319. if not is_sqlite():
  3320. await _safe_execute(conn, "ALTER TABLE spoolbuddy_devices ALTER COLUMN ssh_host_key TYPE TEXT")
  3321. # Migration: Convert ams_labels table from (printer_id, ams_id) key to ams_serial_number key
  3322. # Labels are now keyed by AMS serial number so they persist when the AMS is moved to another printer.
  3323. # PostgreSQL gets the correct schema from create_all(), so skip this
  3324. if is_sqlite():
  3325. try:
  3326. await conn.execute(text("DROP TABLE IF EXISTS ams_labels_new"))
  3327. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='ams_labels'"))
  3328. row = result.fetchone()
  3329. if row and "printer_id" in (row[0] or ""):
  3330. # Old schema: rebuild the table with ams_serial_number as the unique key.
  3331. # Existing rows get a synthetic serial "p{printer_id}a{ams_id}" so data is preserved.
  3332. await conn.execute(
  3333. text("""
  3334. CREATE TABLE ams_labels_new (
  3335. id INTEGER PRIMARY KEY,
  3336. ams_serial_number VARCHAR(50) NOT NULL,
  3337. ams_id INTEGER,
  3338. label VARCHAR(100) NOT NULL,
  3339. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3340. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  3341. CONSTRAINT uq_ams_label_serial UNIQUE (ams_serial_number)
  3342. )
  3343. """)
  3344. )
  3345. await conn.execute(
  3346. text("""
  3347. INSERT INTO ams_labels_new (id, ams_serial_number, ams_id, label, created_at, updated_at)
  3348. SELECT id,
  3349. 'p' || CAST(printer_id AS TEXT) || 'a' || CAST(ams_id AS TEXT),
  3350. ams_id,
  3351. label,
  3352. created_at,
  3353. updated_at
  3354. FROM ams_labels
  3355. """)
  3356. )
  3357. await conn.execute(text("DROP TABLE ams_labels"))
  3358. await conn.execute(text("ALTER TABLE ams_labels_new RENAME TO ams_labels"))
  3359. except (OperationalError, ProgrammingError):
  3360. pass # Already migrated or table does not exist yet
  3361. # Migration: Add auto_dispatch column to virtual_printers
  3362. await _safe_execute(conn, "ALTER TABLE virtual_printers ADD COLUMN auto_dispatch BOOLEAN DEFAULT 1")
  3363. # Migration: Fix VP model codes — convert legacy SSDP codes and display names to correct SSDP codes
  3364. # Legacy codes (from multi-VP refactor) and display names (from proxy auto-inherit)
  3365. vp_model_fixes = {
  3366. "3DPrinter-X1-Carbon": "BL-P001",
  3367. "3DPrinter-X1": "BL-P002",
  3368. "X1C": "BL-P001",
  3369. "X1": "BL-P002",
  3370. "X1E": "C13",
  3371. "X2D": "N6",
  3372. "P1P": "C11",
  3373. "P1S": "C12",
  3374. "P2S": "N7",
  3375. "A1": "N2S",
  3376. "A1 Mini": "N1",
  3377. "H2D": "O1D",
  3378. "H2C": "O1C",
  3379. "H2S": "O1S",
  3380. }
  3381. for old_val, new_val in vp_model_fixes.items():
  3382. await conn.execute(
  3383. text("UPDATE virtual_printers SET model = :new WHERE model = :old"),
  3384. {"old": old_val, "new": new_val},
  3385. )
  3386. await conn.execute(
  3387. text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_model' AND value = :old"),
  3388. {"old": old_val, "new": new_val},
  3389. )
  3390. # Migration: Rename VP mode wire values to match the user-facing labels
  3391. # (#1429 follow-up). The UI button "Archive" had always saved `immediate`
  3392. # and "Queue" had always saved `print_queue` — a mismatch that showed up
  3393. # confusingly in every support bundle. The button labels stay; the wire
  3394. # value is what changes. Idempotent: re-running the UPDATE on canonical
  3395. # values is a no-op. SQLite and Postgres both accept this statement
  3396. # unchanged (string literal comparison, no driver-specific syntax).
  3397. vp_mode_renames = [("immediate", "archive"), ("print_queue", "queue")]
  3398. for old_val, new_val in vp_mode_renames:
  3399. await conn.execute(
  3400. text("UPDATE virtual_printers SET mode = :new WHERE mode = :old"),
  3401. {"old": old_val, "new": new_val},
  3402. )
  3403. await conn.execute(
  3404. text("UPDATE settings SET value = :new WHERE key = 'virtual_printer_mode' AND value = :old"),
  3405. {"old": old_val, "new": new_val},
  3406. )
  3407. # Migration: Auto-sync VP access codes from their target printer.
  3408. # Non-proxy VPs with a target printer (the live-mirror bridge) forward the
  3409. # slicer's MQTT/RTSPS auth bytes through to the real printer, so the VP's
  3410. # access code MUST equal the target's — earlier UIs let them diverge,
  3411. # producing a VP that the slicer could bind but whose bridge silently
  3412. # failed to authenticate against the real printer. The route layer now
  3413. # auto-inherits on every create/update; this backfill corrects any rows
  3414. # that pre-date that change. Idempotent (re-running on synced rows is a
  3415. # no-op because the WHERE clause excludes them). SQLite and Postgres both
  3416. # accept correlated subqueries in UPDATE — no driver-specific syntax.
  3417. mismatch_result = await conn.execute(
  3418. text(
  3419. "SELECT vp.id AS vp_id, vp.name AS vp_name, p.name AS target_name "
  3420. "FROM virtual_printers vp "
  3421. "JOIN printers p ON vp.target_printer_id = p.id "
  3422. "WHERE vp.mode != 'proxy' "
  3423. " AND (vp.access_code IS NULL OR vp.access_code != p.access_code)"
  3424. )
  3425. )
  3426. for row in mismatch_result.fetchall():
  3427. logger.info(
  3428. "VP %r (id=%d) access code synced from target printer %r",
  3429. row.vp_name,
  3430. row.vp_id,
  3431. row.target_name,
  3432. )
  3433. await conn.execute(
  3434. text(
  3435. "UPDATE virtual_printers "
  3436. "SET access_code = ("
  3437. " SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
  3438. ") "
  3439. "WHERE virtual_printers.target_printer_id IS NOT NULL "
  3440. " AND virtual_printers.mode != 'proxy' "
  3441. " AND (virtual_printers.access_code IS NULL OR virtual_printers.access_code != ("
  3442. " SELECT access_code FROM printers WHERE printers.id = virtual_printers.target_printer_id"
  3443. " ))"
  3444. )
  3445. )
  3446. # Migration: Recover queue items that got stuck in `skipped` because of
  3447. # the cancellation-cascade bug (#1667). Pre-fix, the scheduler's
  3448. # `_check_previous_success` lookback excluded `cancelled` but included
  3449. # `skipped`, so a single user-cancelled print poisoned every downstream
  3450. # item with `require_previous_success=True` indefinitely. The reporter saw
  3451. # 18 items blocked over 3 days from one cancellation.
  3452. #
  3453. # Conservative reversal: ONLY reset rows whose immediate predecessor on
  3454. # the same printer (by completed_at desc, excluding the skipped-bug
  3455. # cascade) was `cancelled`. Skipped items whose true predecessor was a
  3456. # real `failed` or `aborted` print stay skipped — those were legitimate.
  3457. # Genuine failure-skips share the same status + error_message + completed_at
  3458. # fingerprint as bug-skips, so the predecessor check is what distinguishes
  3459. # them. Idempotent (post-reset rows no longer match the WHERE clause).
  3460. #
  3461. # Correlated subquery is portable across SQLite and Postgres. The
  3462. # `error_message` literal matches the exact string the buggy scheduler
  3463. # wrote — narrowing further on intent.
  3464. stuck_skipped_result = await conn.execute(
  3465. text(
  3466. "SELECT pq.id, pq.printer_id "
  3467. "FROM print_queue pq "
  3468. "WHERE pq.status = 'skipped' "
  3469. " AND pq.error_message = 'Previous print failed or was aborted' "
  3470. " AND pq.completed_at IS NOT NULL "
  3471. " AND ("
  3472. " SELECT prev.status FROM print_queue prev "
  3473. " WHERE prev.printer_id = pq.printer_id "
  3474. " AND prev.id != pq.id "
  3475. " AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
  3476. " AND prev.completed_at IS NOT NULL "
  3477. " AND prev.completed_at < pq.completed_at "
  3478. " ORDER BY prev.completed_at DESC LIMIT 1"
  3479. " ) = 'cancelled'"
  3480. )
  3481. )
  3482. stuck_ids = [row.id for row in stuck_skipped_result.fetchall()]
  3483. if stuck_ids:
  3484. logger.info(
  3485. "Queue cancellation-cascade migration (#1667): resetting %d skipped item(s) to pending",
  3486. len(stuck_ids),
  3487. )
  3488. await conn.execute(
  3489. text(
  3490. "UPDATE print_queue "
  3491. "SET status = 'pending', error_message = NULL, completed_at = NULL "
  3492. "WHERE id IN ("
  3493. " SELECT pq.id FROM print_queue pq "
  3494. " WHERE pq.status = 'skipped' "
  3495. " AND pq.error_message = 'Previous print failed or was aborted' "
  3496. " AND pq.completed_at IS NOT NULL "
  3497. " AND ("
  3498. " SELECT prev.status FROM print_queue prev "
  3499. " WHERE prev.printer_id = pq.printer_id "
  3500. " AND prev.id != pq.id "
  3501. " AND prev.status IN ('completed', 'failed', 'cancelled', 'aborted') "
  3502. " AND prev.completed_at IS NOT NULL "
  3503. " AND prev.completed_at < pq.completed_at "
  3504. " ORDER BY prev.completed_at DESC LIMIT 1"
  3505. " ) = 'cancelled'"
  3506. ")"
  3507. )
  3508. )
  3509. # Migration: Unify `LibraryFile.file_type` across ingest paths (#1600).
  3510. # Pre-#1600, only the external-folder scan path stored `gcode.3mf` for
  3511. # sliced outputs — the upload, ZIP-extract, and in-process paths all
  3512. # stripped to the trailing `.3mf` and stored `3mf`, so the same file
  3513. # family was split between two values depending on how it was ingested.
  3514. # Going forward `classify_file_type()` is canonical; this backfill flips
  3515. # existing legacy `3mf` rows whose filename ends in `.gcode.3mf` to the
  3516. # canonical compound name. Idempotent (post-update rows no longer match
  3517. # `file_type = '3mf'`) and dialect-neutral (`LOWER` + `LIKE` work the
  3518. # same under SQLite and Postgres).
  3519. await conn.execute(
  3520. text(
  3521. "UPDATE library_files SET file_type = 'gcode.3mf' "
  3522. "WHERE file_type = '3mf' AND LOWER(filename) LIKE '%.gcode.3mf'"
  3523. )
  3524. )
  3525. # Migration: Add per-user Bambu Cloud credential columns
  3526. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token VARCHAR(500)")
  3527. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_email VARCHAR(255)")
  3528. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_region VARCHAR(10)")
  3529. # Cleanup: Remove obsolete settings keys that are no longer used
  3530. obsolete_keys = ["slicer_binary_path"]
  3531. for key in obsolete_keys:
  3532. await conn.execute(text("DELETE FROM settings WHERE key = :key"), {"key": key})
  3533. # Migration: Create user_email_preferences table for user-specific email notification settings
  3534. try:
  3535. async with conn.begin_nested():
  3536. await conn.execute(
  3537. text("""
  3538. CREATE TABLE IF NOT EXISTS user_email_preferences (
  3539. id INTEGER PRIMARY KEY,
  3540. user_id INTEGER NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
  3541. notify_print_start BOOLEAN NOT NULL DEFAULT 1,
  3542. notify_print_complete BOOLEAN NOT NULL DEFAULT 1,
  3543. notify_print_failed BOOLEAN NOT NULL DEFAULT 1,
  3544. notify_print_stopped BOOLEAN NOT NULL DEFAULT 1,
  3545. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  3546. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  3547. )
  3548. """)
  3549. )
  3550. await conn.execute(
  3551. text("CREATE INDEX IF NOT EXISTS ix_user_email_preferences_user_id ON user_email_preferences(user_id)")
  3552. )
  3553. except (OperationalError, ProgrammingError):
  3554. pass # Already applied
  3555. # Legacy migration: Add notify_print_stopped column (for any existing partial tables)
  3556. try:
  3557. async with conn.begin_nested():
  3558. await conn.execute(
  3559. text("ALTER TABLE user_email_preferences ADD COLUMN notify_print_stopped BOOLEAN NOT NULL DEFAULT 1")
  3560. )
  3561. except (OperationalError, ProgrammingError):
  3562. pass # Column already exists or table created with full schema
  3563. # Migration: Add camera_rotation column to printers
  3564. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_rotation INTEGER DEFAULT 0")
  3565. # Migration: Add awaiting_plate_clear column to printers (#961)
  3566. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN awaiting_plate_clear BOOLEAN DEFAULT FALSE NOT NULL")
  3567. # Migration: Add REST/Webhook smart plug fields
  3568. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_url VARCHAR(500)")
  3569. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_on_body TEXT")
  3570. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_url VARCHAR(500)")
  3571. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_off_body TEXT")
  3572. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_method VARCHAR(10)")
  3573. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_headers TEXT")
  3574. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_url VARCHAR(500)")
  3575. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_path VARCHAR(200)")
  3576. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_status_on_value VARCHAR(50)")
  3577. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_path VARCHAR(200)")
  3578. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_path VARCHAR(200)")
  3579. # Migration: Add separate REST power/energy URLs and multipliers
  3580. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_url VARCHAR(500)")
  3581. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_power_multiplier REAL DEFAULT 1.0")
  3582. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_url VARCHAR(500)")
  3583. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_multiplier REAL DEFAULT 1.0")
  3584. # Migration (#2539): a REST plug's lifetime energy counter, separate from its
  3585. # today counter. Devices differ in which they expose — a Shelly reports only
  3586. # a cumulative `aenergy.total`, a Tasmota behind a REST bridge reports both —
  3587. # and conflating the two made the cumulative value read as "today", so it
  3588. # never reset at midnight and "Total" stayed empty forever.
  3589. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_path VARCHAR(200)")
  3590. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN rest_energy_total_multiplier REAL DEFAULT 1.0")
  3591. # Migration: Add batch_id column to print_queue for batch grouping
  3592. try:
  3593. async with conn.begin_nested():
  3594. await conn.execute(
  3595. text(
  3596. "ALTER TABLE print_queue ADD COLUMN batch_id INTEGER REFERENCES print_batches(id) ON DELETE SET NULL"
  3597. )
  3598. )
  3599. except (OperationalError, ProgrammingError):
  3600. pass
  3601. # Migration (#342): batch orders — planning metadata on print_batches. The
  3602. # per-plate target rows live in their own table, created by create_all().
  3603. await _safe_execute(
  3604. conn, "ALTER TABLE print_batches ADD COLUMN project_id INTEGER REFERENCES projects(id) ON DELETE SET NULL"
  3605. )
  3606. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN notes TEXT")
  3607. if is_sqlite():
  3608. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date DATETIME")
  3609. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at DATETIME")
  3610. else:
  3611. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN due_date TIMESTAMP")
  3612. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN completed_at TIMESTAMP")
  3613. # Migration (#342): attribute a logged run to the queue item that produced
  3614. # it, so batch cost/energy can be summed without guessing from archive_id.
  3615. await _safe_execute(
  3616. conn,
  3617. "ALTER TABLE print_log_entries ADD COLUMN queue_item_id INTEGER REFERENCES print_queue(id) ON DELETE SET NULL",
  3618. )
  3619. await _safe_execute(
  3620. conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_queue_item_id ON print_log_entries (queue_item_id)"
  3621. )
  3622. # Migration: Shortest-job-first scheduling columns on print_queue
  3623. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN print_time_seconds INTEGER")
  3624. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN been_jumped BOOLEAN DEFAULT FALSE NOT NULL")
  3625. # Migration: Auto-print G-code injection (#422)
  3626. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gcode_injection BOOLEAN DEFAULT FALSE NOT NULL")
  3627. # Migration: Store estimated print cost for budget checks before queued jobs start
  3628. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN estimated_cost FLOAT")
  3629. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN billing_run_id VARCHAR(36)")
  3630. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN billing_run_id VARCHAR(36)")
  3631. if is_sqlite():
  3632. await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT 0 NOT NULL")
  3633. else:
  3634. await _safe_execute(conn, "ALTER TABLE wallet_transactions ADD COLUMN is_voided BOOLEAN DEFAULT FALSE NOT NULL")
  3635. await _safe_execute(
  3636. conn,
  3637. "CREATE INDEX IF NOT EXISTS ix_wallet_transactions_is_voided ON wallet_transactions (is_voided)",
  3638. )
  3639. if is_sqlite():
  3640. await _safe_execute(
  3641. conn,
  3642. "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT 1",
  3643. )
  3644. else:
  3645. await _safe_execute(
  3646. conn,
  3647. "ALTER TABLE notification_providers ADD COLUMN on_billing_charge_failed BOOLEAN DEFAULT TRUE",
  3648. )
  3649. # Reprints reuse their source archive, so archive uniqueness must only be
  3650. # the legacy fallback for rows without a per-run UUID. The globally unique
  3651. # print_run_id is the idempotency key for all new charges.
  3652. await _safe_execute(conn, "DROP INDEX IF EXISTS uq_wallet_transactions_archive")
  3653. await _safe_execute(
  3654. conn,
  3655. "CREATE UNIQUE INDEX IF NOT EXISTS uq_wallet_transactions_archive"
  3656. " ON wallet_transactions (transaction_type, print_archive_id) WHERE print_run_id IS NULL",
  3657. )
  3658. # Migration: Add backup_spools and backup_archives columns to github_backup_config
  3659. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_spools BOOLEAN DEFAULT 0")
  3660. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN backup_archives BOOLEAN DEFAULT 0")
  3661. # Migration: Widen columns where SQLite allowed data beyond the declared VARCHAR limit
  3662. if not is_sqlite():
  3663. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_hash TYPE VARCHAR(255)")
  3664. await _safe_execute(conn, "ALTER TABLE api_keys ALTER COLUMN key_prefix TYPE VARCHAR(20)")
  3665. await _safe_execute(conn, "ALTER TABLE print_archives ALTER COLUMN filament_color TYPE VARCHAR(200)")
  3666. # Migration: Create GIN index for full-text search on PostgreSQL
  3667. # (SQLite uses FTS5 virtual table instead, set up above)
  3668. if not is_sqlite():
  3669. try:
  3670. await conn.execute(
  3671. text("""
  3672. CREATE INDEX IF NOT EXISTS idx_archives_fulltext
  3673. ON print_archives
  3674. USING GIN (to_tsvector('simple',
  3675. COALESCE(print_name, '') || ' ' ||
  3676. COALESCE(filename, '') || ' ' ||
  3677. COALESCE(tags, '') || ' ' ||
  3678. COALESCE(notes, '') || ' ' ||
  3679. COALESCE(designer, '') || ' ' ||
  3680. COALESCE(filament_type, '')
  3681. ))
  3682. """)
  3683. )
  3684. except (OperationalError, ProgrammingError):
  3685. pass # Already applied
  3686. # (An API key's printer_ids [] used to be turned into NULL here at every start,
  3687. # from when both meant all printers. [] means none now, so that made a key
  3688. # limited to no printer, such as one whose printers were all deleted, reach
  3689. # every printer after a restart. Old [] were converted long ago.)
  3690. # Migration: Add auth_source column to users for LDAP support (#794)
  3691. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN auth_source VARCHAR(20) DEFAULT 'local' NOT NULL")
  3692. # Migration: Make password_hash nullable for LDAP users (#794)
  3693. # LDAP users have no local password — the column must allow NULL so auto-provisioning
  3694. # doesn't hit a NOT NULL constraint failure on upgraded installs whose users table was
  3695. # originally created before LDAP support landed.
  3696. if is_sqlite():
  3697. # SQLite can't ALTER COLUMN; patch sqlite_master directly via writable_schema.
  3698. # Bump schema_version afterwards so SQLite reloads the table definition from disk —
  3699. # without that bump, the current connection keeps enforcing the old NOT NULL from
  3700. # its cached schema. Safe because row data is untouched and the replace() is a
  3701. # no-op if the constraint has already been removed.
  3702. try:
  3703. result = await conn.execute(text("SELECT sql FROM sqlite_master WHERE type='table' AND name='users'"))
  3704. users_sql = result.scalar()
  3705. if users_sql and "password_hash VARCHAR(255) NOT NULL" in users_sql:
  3706. version_result = await conn.execute(text("PRAGMA schema_version"))
  3707. schema_version = version_result.scalar() or 0
  3708. await conn.execute(text("PRAGMA writable_schema = ON"))
  3709. await conn.execute(
  3710. text(
  3711. "UPDATE sqlite_master "
  3712. "SET sql = replace(sql, 'password_hash VARCHAR(255) NOT NULL', 'password_hash VARCHAR(255)') "
  3713. "WHERE type = 'table' AND name = 'users'"
  3714. )
  3715. )
  3716. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  3717. await conn.execute(text("PRAGMA writable_schema = OFF"))
  3718. except (OperationalError, ProgrammingError) as exc:
  3719. logger.error(
  3720. "Failed to remove NOT NULL from users.password_hash via writable_schema — "
  3721. "OIDC/LDAP user creation will fail on this install: %s",
  3722. exc,
  3723. exc_info=True,
  3724. )
  3725. else:
  3726. await _safe_execute(conn, "ALTER TABLE users ALTER COLUMN password_hash DROP NOT NULL")
  3727. # Migration: Add energy_start_kwh to print_archives (#941)
  3728. # Persists the smart plug lifetime counter captured at print start, so per-print
  3729. # energy tracking survives a backend restart mid-print.
  3730. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_kwh REAL")
  3731. # Migration: Add subtask_id to print_archives (#972)
  3732. # MQTT-provided task identifier used to resume the same archive row across a
  3733. # backend restart mid-print. Without it, a long print (e.g. 13h) triggers
  3734. # stale-cancel + new-archive, losing started_at continuity.
  3735. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN subtask_id VARCHAR(64)")
  3736. # Migration: Add bed_type to print_archives (#1253)
  3737. # Build plate type extracted from 3MF (curr_bed_type), drives the bed icon
  3738. # rendered on archive cards.
  3739. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN bed_type VARCHAR(64)")
  3740. # Migration: Add deleted_at to print_archives (#1343)
  3741. # Soft-delete sentinel so deleting an archive entry from the UI no longer
  3742. # wipes its filament / time / cost contribution from Quick Stats. Listings
  3743. # hide rows where deleted_at IS NOT NULL; the stats endpoint counts them all.
  3744. # DATETIME on SQLite, TIMESTAMP on PostgreSQL (PG doesn't accept DATETIME on
  3745. # ALTER TABLE the same way it tolerates it inside CREATE TABLE).
  3746. _deleted_at_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
  3747. await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN deleted_at {_deleted_at_type}")
  3748. await _safe_execute(
  3749. conn,
  3750. "CREATE INDEX IF NOT EXISTS ix_print_archives_deleted_at ON print_archives (deleted_at)",
  3751. )
  3752. # Migration: Add bambuddy_forced_timelapse to print_archives (#1397)
  3753. # Tracks prints where Bambuddy forced the firmware to record a timelapse
  3754. # so the finish-photo extractor could pull the post-park-pre-drop frame.
  3755. # The cleanup path uses this to delete the timelapse both locally and on
  3756. # the printer's SD after extraction — the user didn't opt in to a
  3757. # timelapse recording. Postgres rejects `DEFAULT 0` for BOOLEAN; SQLite
  3758. # accepts both 0/FALSE — branch the literal.
  3759. _bool_false_literal = "0" if is_sqlite() else "FALSE"
  3760. await _safe_execute(
  3761. conn,
  3762. f"ALTER TABLE print_archives ADD COLUMN bambuddy_forced_timelapse BOOLEAN DEFAULT {_bool_false_literal}",
  3763. )
  3764. # Migration: Create smart_plug_energy_snapshots table (#941)
  3765. # Hourly snapshots of each plug's lifetime counter, so date-range queries in
  3766. # "total consumption" energy mode can compute (last - first) deltas.
  3767. await _safe_execute(
  3768. conn,
  3769. """
  3770. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  3771. id INTEGER PRIMARY KEY AUTOINCREMENT,
  3772. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  3773. recorded_at DATETIME NOT NULL,
  3774. lifetime_kwh REAL NOT NULL
  3775. )
  3776. """
  3777. if is_sqlite()
  3778. else """
  3779. CREATE TABLE IF NOT EXISTS smart_plug_energy_snapshots (
  3780. id SERIAL PRIMARY KEY,
  3781. plug_id INTEGER NOT NULL REFERENCES smart_plugs(id) ON DELETE CASCADE,
  3782. recorded_at TIMESTAMP NOT NULL,
  3783. lifetime_kwh REAL NOT NULL
  3784. )
  3785. """,
  3786. )
  3787. await _safe_execute(
  3788. conn,
  3789. "CREATE INDEX IF NOT EXISTS ix_plug_energy_snapshots_plug_time "
  3790. "ON smart_plug_energy_snapshots(plug_id, recorded_at)",
  3791. )
  3792. # Migration: Add PKCE code_verifier column to auth_ephemeral_tokens
  3793. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN code_verifier VARCHAR(128)")
  3794. # Migration: Add TOTP replay-protection counter to user_totp
  3795. await _safe_execute(conn, "ALTER TABLE user_totp ADD COLUMN last_totp_counter BIGINT")
  3796. # Migration: Add challenge_id for pre-auth token client binding (HttpOnly cookie)
  3797. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN challenge_id VARCHAR(128)")
  3798. # Migration: Add auto_link_existing_accounts column to oidc_providers (M-4)
  3799. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  3800. if is_sqlite():
  3801. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT 0")
  3802. else:
  3803. await _safe_execute(
  3804. conn, "ALTER TABLE oidc_providers ADD COLUMN auto_link_existing_accounts BOOLEAN DEFAULT false"
  3805. )
  3806. # Migration: Azure Entra ID support — configurable email claim and verification requirement
  3807. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN email_claim VARCHAR(64) DEFAULT 'email'")
  3808. # Postgres rejects `DEFAULT 1` for BOOLEAN columns.
  3809. if is_sqlite():
  3810. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT 1")
  3811. else:
  3812. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN require_email_verified BOOLEAN DEFAULT true")
  3813. # SEC-1 backfill: reset auto_link only for Fall B (email_claim='email' + require_email_verified=False).
  3814. # Fall C (custom claim) is now allowed to use auto_link — do NOT reset those rows.
  3815. # Runs BEFORE the CHECK constraint below so Fall B rows self-heal rather than failing
  3816. # PostgreSQL's "check constraint is violated by some row" on ADD CONSTRAINT.
  3817. # On fresh installs the column defaults guarantee this UPDATE matches zero rows.
  3818. # TRUE/FALSE literals are accepted by both SQLite (≥ 3.23) and PostgreSQL — no dialect branch needed.
  3819. try:
  3820. async with conn.begin_nested():
  3821. await conn.execute(
  3822. text(
  3823. "UPDATE oidc_providers SET auto_link_existing_accounts = FALSE "
  3824. "WHERE auto_link_existing_accounts = TRUE "
  3825. "AND email_claim = 'email' AND require_email_verified = FALSE"
  3826. )
  3827. )
  3828. except Exception as exc:
  3829. logger.error(
  3830. "SEC-1 safety backfill FAILED — auto_link_existing_accounts may remain enabled "
  3831. "on providers with unsafe email settings: %s",
  3832. exc,
  3833. exc_info=True,
  3834. )
  3835. raise
  3836. # SEC-1: Add DB-level CHECK constraint for existing PostgreSQL installs.
  3837. # SQLite does not support ALTER TABLE ADD CONSTRAINT — handled by __table_args__ at creation.
  3838. # Runs AFTER the backfill so Fall B rows don't fail constraint validation.
  3839. if not is_sqlite():
  3840. add_constraint = (
  3841. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_auto_link_requires_verified_email_claim "
  3842. "CHECK (auto_link_existing_accounts = FALSE OR email_claim != 'email' OR require_email_verified = TRUE)"
  3843. )
  3844. try:
  3845. # Asked first so an existing constraint costs no ERROR in the server log.
  3846. if not (_is_pg_conn(conn) and await _pg_already_applied(conn, add_constraint)):
  3847. async with conn.begin_nested():
  3848. await conn.execute(text(add_constraint))
  3849. except (OperationalError, ProgrammingError) as exc:
  3850. # Classified by SQLSTATE, not by message text: a non-English server
  3851. # reports the constraint as already present in its own language (#2949).
  3852. if not _is_already_applied(exc, add_constraint):
  3853. logger.error(
  3854. "Security constraint migration FAILED — auto_link safety constraint may not be enforced: %s",
  3855. exc,
  3856. exc_info=True,
  3857. )
  3858. raise
  3859. # Migration: Update auto_link CHECK constraint formula (existing installs).
  3860. # Existing PostgreSQL installs that ran the ADD CONSTRAINT above with the old formula
  3861. # (or a previous version of this code) need an explicit DROP + ADD to update it.
  3862. # For SQLite, the table is recreated with the new constraint formula if the old formula
  3863. # is still present in sqlite_master (SQLite cannot ALTER TABLE DROP/ADD CONSTRAINT).
  3864. await _migrate_update_auto_link_constraint(conn)
  3865. # Migration: Add default_group_id to oidc_providers.
  3866. # Must run AFTER _migrate_update_auto_link_constraint to avoid being dropped during
  3867. # the SQLite table recreation that function performs on stale-formula databases.
  3868. await _safe_execute(
  3869. conn,
  3870. "ALTER TABLE oidc_providers ADD COLUMN default_group_id INTEGER REFERENCES groups(id) ON DELETE SET NULL",
  3871. )
  3872. # Migration: Add cached-icon columns to oidc_providers (#1333).
  3873. # SPA's strict CSP (img-src 'self' data: blob:) blocks hotlinking external
  3874. # icon hosts, so we proxy them: admin sets icon_url, backend fetches and
  3875. # caches the bytes here, the SPA renders <img src="/api/v1/auth/oidc/providers/{id}/icon">.
  3876. # Must run AFTER _migrate_update_auto_link_constraint for the same reason as
  3877. # default_group_id above (SQLite table recreation drops unknown columns).
  3878. # Dialect-conditional type: BLOB on SQLite, BYTEA on PostgreSQL.
  3879. _blob_type = "BLOB" if is_sqlite() else "BYTEA"
  3880. await _safe_execute(conn, f"ALTER TABLE oidc_providers ADD COLUMN icon_data {_blob_type}")
  3881. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_content_type VARCHAR(20)")
  3882. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN icon_etag VARCHAR(64)")
  3883. # PostgreSQL-only: enforce the all-or-nothing triplet at the DB layer.
  3884. # SQLite cannot ADD CONSTRAINT to an existing table — fresh SQLite
  3885. # installs get the CHECK via metadata.create_all (model __table_args__);
  3886. # stale SQLite installs rely on the application layer, same trade-off
  3887. # as the default_group_id FK ON DELETE SET NULL above.
  3888. if not is_sqlite():
  3889. await _safe_execute(
  3890. conn,
  3891. "ALTER TABLE oidc_providers ADD CONSTRAINT ck_oidc_icon_triplet_co_null "
  3892. "CHECK ((icon_data IS NULL) = (icon_content_type IS NULL) "
  3893. "AND (icon_content_type IS NULL) = (icon_etag IS NULL))",
  3894. )
  3895. # Migration: Add password_changed_at to users (M-R7-B)
  3896. # Tracks the last time a user's password was changed/reset. JWTs whose iat
  3897. # predates this timestamp are rejected in all six auth validation paths.
  3898. # R4 fix: TIMESTAMP is accepted by both SQLite and PostgreSQL; DATETIME
  3899. # is rejected by Postgres ("type 'datetime' does not exist"), which made
  3900. # _safe_execute swallow the error and leave existing Postgres installs
  3901. # without the column — causing UndefinedColumnError on every User query.
  3902. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN password_changed_at TIMESTAMP")
  3903. # Migration: Back-fill password_changed_at = created_at for existing users (I2).
  3904. # Users who never changed their password would have NULL here, meaning old
  3905. # tokens could never be invalidated via the freshness check. Setting it to
  3906. # created_at is conservative: any token issued before the account was created
  3907. # is always invalid, so this is a safe lower bound.
  3908. async with conn.begin_nested():
  3909. await conn.execute(text("UPDATE users SET password_changed_at = created_at WHERE password_changed_at IS NULL"))
  3910. # Migration: Provenance columns on library_files for MakerWorld imports.
  3911. # source_url is indexed so "already imported" dedupe lookups stay O(log N)
  3912. # as the library grows.
  3913. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_type VARCHAR(32)")
  3914. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN source_url VARCHAR(512)")
  3915. await _safe_execute(
  3916. conn,
  3917. "CREATE INDEX IF NOT EXISTS ix_library_files_source_url ON library_files(source_url)",
  3918. )
  3919. # Migration: Cache metadata title on pending uploads (#1152 follow-up).
  3920. # Without this column the review card always shows the FTP filename while
  3921. # the eventual archive's print_name comes from the 3MF metadata title,
  3922. # creating a confusing review→archive name mismatch. Captured at upload
  3923. # time so /pending-uploads/ list calls don't have to reopen each 3MF.
  3924. await _safe_execute(
  3925. conn,
  3926. "ALTER TABLE pending_uploads ADD COLUMN metadata_print_name VARCHAR(255)",
  3927. )
  3928. # Migration: Per-user API key ownership + cloud-access scope (#1182).
  3929. # user_id is nullable so legacy keys (created before #1182) survive the
  3930. # migration; cloud routes reject calls from keys without an owner so the
  3931. # operator is forced to recreate them. ON DELETE CASCADE so deleting a user
  3932. # takes their keys with them — orphan keys must never authenticate.
  3933. # SQLite ignores REFERENCES on ADD COLUMN (not enforced but not an error);
  3934. # PostgreSQL enforces the FK from this point forward. Indexed for the
  3935. # auth-gate's owner→keys lookup that runs on every API-keyed request.
  3936. await _safe_execute(
  3937. conn,
  3938. "ALTER TABLE api_keys ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE",
  3939. )
  3940. await _safe_execute(
  3941. conn,
  3942. "CREATE INDEX IF NOT EXISTS ix_api_keys_user_id ON api_keys(user_id)",
  3943. )
  3944. # ``DEFAULT 0`` works on SQLite (boolean is just integer-coerced) but
  3945. # asyncpg's strict type-check rejects it: "column is of type boolean but
  3946. # default expression is of type integer". Use ``DEFAULT FALSE`` so both
  3947. # dialects accept the same statement — same pattern as the print_queue
  3948. # gcode_injection migration above.
  3949. await _safe_execute(
  3950. conn,
  3951. "ALTER TABLE api_keys ADD COLUMN can_access_cloud BOOLEAN DEFAULT FALSE",
  3952. )
  3953. # Narrowly-scoped settings-write toggle for the dynamic-tariff push case
  3954. # documented in wiki/features/energy.md (#1356). Defaults FALSE so existing
  3955. # keys never silently gain settings-write capability on upgrade.
  3956. await _safe_execute(
  3957. conn,
  3958. "ALTER TABLE api_keys ADD COLUMN can_update_energy_cost BOOLEAN DEFAULT FALSE",
  3959. )
  3960. # GHSA-r2qv-8222-hqg3 (CVE-2026-pending, CVSS 9.9): split file-management out
  3961. # of the implicit "any API key" grant into an explicit scope flag. The
  3962. # allowlist-based ``_check_apikey_permissions`` (see ``core/auth.py``) routes
  3963. # LIBRARY_UPLOAD / LIBRARY_UPDATE_OWN / LIBRARY_DELETE_OWN / MAKERWORLD_IMPORT
  3964. # through this flag. DEFAULT TRUE matches the existing "queue + read" trust
  3965. # baseline; backfill mirrors can_queue so a key the user previously created as
  3966. # "queue-only" retains the file-upload step its queue workflow already used,
  3967. # while a hardened "read-only" key (can_queue=False) does not silently gain a
  3968. # new write capability on upgrade. Backfill is gated on column non-existence
  3969. # so user-edited values are never overwritten on subsequent startup.
  3970. column_existed = await _api_keys_column_exists(conn, "can_manage_library")
  3971. await _safe_execute(
  3972. conn,
  3973. "ALTER TABLE api_keys ADD COLUMN can_manage_library BOOLEAN DEFAULT TRUE",
  3974. )
  3975. if not column_existed:
  3976. async with conn.begin_nested():
  3977. await conn.execute(text("UPDATE api_keys SET can_manage_library = can_queue"))
  3978. # Same shape: SpoolBuddy NFC/scale/system endpoints plus manual inventory
  3979. # writes split out of the implicit "any API key" grant. Backfill mirrors
  3980. # ``can_queue`` so the bundled SpoolBuddy kiosk key (created via the CLI
  3981. # with can_queue=False) does NOT silently gain inventory writes — but
  3982. # the CLI override sets the new flag True explicitly, since the kiosk
  3983. # itself is the legitimate writer (see ``cli.py``).
  3984. column_existed = await _api_keys_column_exists(conn, "can_manage_inventory")
  3985. await _safe_execute(
  3986. conn,
  3987. "ALTER TABLE api_keys ADD COLUMN can_manage_inventory BOOLEAN DEFAULT TRUE",
  3988. )
  3989. if not column_existed:
  3990. async with conn.begin_nested():
  3991. await conn.execute(text("UPDATE api_keys SET can_manage_inventory = can_queue"))
  3992. # #1832 follow-up: carve maintenance CRUD out of the admin denylist so
  3993. # HA-style automations can log "cleaned nozzle" via API key. Distinct
  3994. # from the two backfills above: MAINTENANCE_CREATE / _UPDATE / _DELETE
  3995. # were EXPLICITLY denied for every API key under the pre-migration model
  3996. # (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no existing
  3997. # integration relies on them. Column default TRUE matches the "safe,
  3998. # on-by-default" pattern for keys created via the UI going forward;
  3999. # existing rows backfill to FALSE so the upgrade path does not silently
  4000. # widen scope for keys created before this flag existed. Users opt in
  4001. # via Settings → API Keys per key.
  4002. column_existed = await _api_keys_column_exists(conn, "can_manage_maintenance")
  4003. await _safe_execute(
  4004. conn,
  4005. "ALTER TABLE api_keys ADD COLUMN can_manage_maintenance BOOLEAN DEFAULT TRUE",
  4006. )
  4007. if not column_existed:
  4008. async with conn.begin_nested():
  4009. await conn.execute(text("UPDATE api_keys SET can_manage_maintenance = FALSE"))
  4010. # #1888: carve archive CRUD (create/update/delete — NOT purge) out of the
  4011. # admin denylist so automations can prune old prints via API key. Same
  4012. # shape and reasoning as can_manage_maintenance above: ARCHIVES_CREATE /
  4013. # _UPDATE_* / _DELETE_* were EXPLICITLY denied for every API key under the
  4014. # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
  4015. # existing integration relies on them. Column default TRUE for keys created
  4016. # via the UI going forward; existing rows backfill to FALSE so the upgrade
  4017. # path does not silently widen scope for keys created before this flag
  4018. # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid
  4019. # on both SQLite and Postgres, so no dialect branch is needed.
  4020. column_existed = await _api_keys_column_exists(conn, "can_manage_archives")
  4021. await _safe_execute(
  4022. conn,
  4023. "ALTER TABLE api_keys ADD COLUMN can_manage_archives BOOLEAN DEFAULT TRUE",
  4024. )
  4025. if not column_existed:
  4026. async with conn.begin_nested():
  4027. await conn.execute(text("UPDATE api_keys SET can_manage_archives = FALSE"))
  4028. # #1893: carve project CRUD + membership (create/update/delete, add-archives)
  4029. # out of the admin denylist so automations can manage projects via API key.
  4030. # Identical shape and reasoning to can_manage_archives above: PROJECTS_CREATE
  4031. # / _UPDATE / _DELETE were EXPLICITLY denied for every API key under the
  4032. # pre-migration model (they were on ``_APIKEY_DENIED_PERMISSIONS``), so no
  4033. # existing integration relies on them. Column default TRUE for keys created
  4034. # via the UI going forward; existing rows backfill to FALSE so the upgrade
  4035. # path does not silently widen scope for keys created before this flag
  4036. # existed. Users opt in via Settings → API Keys per key. BOOLEAN is valid on
  4037. # both SQLite and Postgres, so no dialect branch is needed.
  4038. column_existed = await _api_keys_column_exists(conn, "can_manage_projects")
  4039. await _safe_execute(
  4040. conn,
  4041. "ALTER TABLE api_keys ADD COLUMN can_manage_projects BOOLEAN DEFAULT TRUE",
  4042. )
  4043. if not column_existed:
  4044. async with conn.begin_nested():
  4045. await conn.execute(text("UPDATE api_keys SET can_manage_projects = FALSE"))
  4046. # Migration: Soft-delete column for trash bin (Issue #1008). Indexed so the
  4047. # sweeper's "SELECT ... WHERE deleted_at < cutoff" and the trash list's
  4048. # "WHERE deleted_at IS NOT NULL" stay cheap as the table grows.
  4049. #
  4050. # ``DATETIME`` is a SQLite-only type alias — PostgreSQL rejects it as
  4051. # invalid syntax, _safe_execute swallows the error, and the column is
  4052. # never added (breaking every query that references it). Emit
  4053. # dialect-appropriate SQL so both backends get the column.
  4054. if is_sqlite():
  4055. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at DATETIME")
  4056. else:
  4057. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN deleted_at TIMESTAMP")
  4058. await _safe_execute(
  4059. conn,
  4060. "CREATE INDEX IF NOT EXISTS ix_library_files_deleted_at ON library_files(deleted_at)",
  4061. )
  4062. # Legacy SQLite installs created `settings` without a UNIQUE constraint on `key`,
  4063. # so `INSERT OR IGNORE` below silently degrades to a plain INSERT and dupes rows on
  4064. # every restart. Dedupe (keep lowest id per key) and add the missing unique index
  4065. # before seeding. Safe/idempotent on both dialects — fresh installs already have
  4066. # no dupes and `create_all` already emits the index.
  4067. async with conn.begin_nested():
  4068. await conn.execute(text("DELETE FROM settings WHERE id NOT IN (SELECT MIN(id) FROM settings GROUP BY key)"))
  4069. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_settings_key ON settings(key)")
  4070. # Migration: Normalise provider_email to lowercase (SEC-3).
  4071. # Required for Entra ID where UPN/email claims may arrive in mixed case.
  4072. # LOWER() is supported by both SQLite and PostgreSQL; the UPDATE is idempotent.
  4073. # Executed directly (not via _safe_execute) so any column-reference failure
  4074. # is always fatal and never silently swallowed.
  4075. async with conn.begin_nested():
  4076. await conn.execute(
  4077. text(
  4078. "UPDATE user_oidc_links SET provider_email = LOWER(provider_email) "
  4079. "WHERE provider_email IS NOT NULL AND provider_email != LOWER(provider_email)"
  4080. )
  4081. )
  4082. # Migration: Create spoolman_slot_assignments table for local AMS-slot→Spoolman-spool mapping.
  4083. # Replaces the pattern of writing spool.location in Spoolman (which polluted the
  4084. # user-editable storage_location field in the UI).
  4085. # ck_ams_id_range formula was widened in #1274 to admit AMS-HT (ams_id 128-191).
  4086. await _safe_execute(
  4087. conn,
  4088. """
  4089. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  4090. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4091. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  4092. 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),
  4093. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  4094. spoolman_spool_id INTEGER NOT NULL,
  4095. assigned_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4096. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  4097. )
  4098. """
  4099. if is_sqlite()
  4100. else """
  4101. CREATE TABLE IF NOT EXISTS spoolman_slot_assignments (
  4102. id SERIAL PRIMARY KEY,
  4103. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  4104. 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),
  4105. tray_id INTEGER NOT NULL CHECK (tray_id >= 0 AND tray_id <= 3),
  4106. spoolman_spool_id INTEGER NOT NULL,
  4107. assigned_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4108. CONSTRAINT uq_slot_assignment UNIQUE(printer_id, ams_id, tray_id)
  4109. )
  4110. """,
  4111. )
  4112. await _safe_execute(
  4113. conn,
  4114. "CREATE INDEX IF NOT EXISTS ix_slot_assignment_spool ON spoolman_slot_assignments (spoolman_spool_id)",
  4115. )
  4116. # Migration: widen ck_ams_id_range on spoolman_slot_assignments to allow
  4117. # AMS-HT ids (128-191). Existing installs created before #1274 carry the
  4118. # stale formula which rejects every AMS-HT slot link with a CHECK violation.
  4119. await _migrate_widen_spoolman_slot_ams_id_range(conn)
  4120. # Migration: Create spoolman_k_profile table for K-value calibration profiles linked to Spoolman spools.
  4121. await _safe_execute(
  4122. conn,
  4123. """
  4124. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  4125. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4126. spoolman_spool_id INTEGER NOT NULL,
  4127. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  4128. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  4129. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  4130. nozzle_type VARCHAR(50),
  4131. k_value REAL NOT NULL,
  4132. name VARCHAR(100),
  4133. cali_idx INTEGER,
  4134. setting_id VARCHAR(50),
  4135. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4136. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  4137. )
  4138. """
  4139. if is_sqlite()
  4140. else """
  4141. CREATE TABLE IF NOT EXISTS spoolman_k_profile (
  4142. id SERIAL PRIMARY KEY,
  4143. spoolman_spool_id INTEGER NOT NULL,
  4144. printer_id INTEGER NOT NULL REFERENCES printers(id) ON DELETE CASCADE,
  4145. extruder INTEGER NOT NULL DEFAULT 0 CHECK (extruder >= 0 AND extruder <= 1),
  4146. nozzle_diameter VARCHAR(10) NOT NULL DEFAULT '0.4',
  4147. nozzle_type VARCHAR(50),
  4148. k_value DOUBLE PRECISION NOT NULL,
  4149. name VARCHAR(100),
  4150. cali_idx INTEGER,
  4151. setting_id VARCHAR(50),
  4152. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4153. CONSTRAINT uq_spoolman_k_profile UNIQUE(spoolman_spool_id, printer_id, extruder, nozzle_diameter)
  4154. )
  4155. """,
  4156. )
  4157. await _safe_execute(
  4158. conn,
  4159. "CREATE INDEX IF NOT EXISTS ix_spoolman_k_profile_spool ON spoolman_k_profile (spoolman_spool_id)",
  4160. )
  4161. # Migration: Add provider column to github_backup_config for multi-provider support
  4162. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN provider VARCHAR(30) DEFAULT 'github'")
  4163. # Migration: Add allow_insecure_http column to github_backup_config for self-hosted HTTP instances
  4164. await _safe_execute(conn, "ALTER TABLE github_backup_config ADD COLUMN allow_insecure_http BOOLEAN DEFAULT FALSE")
  4165. # Seed default settings keys that must exist on fresh install
  4166. default_settings = [
  4167. ("advanced_auth_enabled", "false"),
  4168. ("smtp_auth_enabled", "true"),
  4169. ]
  4170. for key, value in default_settings:
  4171. try:
  4172. if is_sqlite():
  4173. await conn.execute(
  4174. text("INSERT OR IGNORE INTO settings (key, value) VALUES (:key, :value)"),
  4175. {"key": key, "value": value},
  4176. )
  4177. else:
  4178. await conn.execute(
  4179. text("INSERT INTO settings (key, value) VALUES (:key, :value) ON CONFLICT (key) DO NOTHING"),
  4180. {"key": key, "value": value},
  4181. )
  4182. except (OperationalError, ProgrammingError):
  4183. pass
  4184. # Migration: Create filament_sku_settings table for reorder forecasting
  4185. if is_sqlite():
  4186. await _safe_execute(
  4187. conn,
  4188. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  4189. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4190. material VARCHAR(50) NOT NULL,
  4191. subtype VARCHAR(50),
  4192. brand VARCHAR(100),
  4193. lead_time_days INTEGER NOT NULL DEFAULT 0,
  4194. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  4195. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  4196. color_name VARCHAR(100),
  4197. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4198. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4199. UNIQUE (material, subtype, brand, color_name)
  4200. )""",
  4201. )
  4202. async with conn.begin_nested():
  4203. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  4204. await _safe_execute(
  4205. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_value INTEGER NOT NULL DEFAULT 14"
  4206. )
  4207. await _safe_execute(
  4208. conn, "ALTER TABLE filament_sku_settings ADD COLUMN safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'"
  4209. )
  4210. await _safe_execute(
  4211. conn, "ALTER TABLE filament_sku_settings ADD COLUMN alerts_snoozed BOOLEAN NOT NULL DEFAULT 0"
  4212. )
  4213. # Migration: add color_name to filament_sku_settings so forecasts
  4214. # distinguish colours within a SKU. The matching ALTER for
  4215. # filament_shopping_list runs AFTER that table's CREATE below — on
  4216. # fresh installs the table doesn't exist yet at this point and
  4217. # _safe_execute does not swallow "no such table".
  4218. await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN color_name VARCHAR(100)")
  4219. # Backfill and drop legacy safety_margin_days column — SQLite requires a table rebuild.
  4220. # Only run if the stale column still exists.
  4221. cols_result = await conn.execute(text("PRAGMA table_info(filament_sku_settings)"))
  4222. col_names = [row[1] for row in cols_result.fetchall()]
  4223. if "safety_margin_days" in col_names:
  4224. async with conn.begin_nested():
  4225. # Defensive: a previous startup may have crashed mid-rebuild leaving
  4226. # filament_sku_settings_new behind, which would break the CREATE below.
  4227. await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_new"))
  4228. await conn.execute(
  4229. text(
  4230. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  4231. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  4232. )
  4233. )
  4234. await conn.execute(
  4235. text(
  4236. """CREATE TABLE filament_sku_settings_new (
  4237. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4238. material VARCHAR(50) NOT NULL,
  4239. subtype VARCHAR(50),
  4240. brand VARCHAR(100),
  4241. color_name VARCHAR(100),
  4242. lead_time_days INTEGER NOT NULL DEFAULT 0,
  4243. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  4244. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  4245. alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
  4246. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4247. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4248. UNIQUE (material, subtype, brand, color_name)
  4249. )"""
  4250. )
  4251. )
  4252. await conn.execute(
  4253. text(
  4254. """INSERT INTO filament_sku_settings_new
  4255. (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  4256. safety_margin_unit, alerts_snoozed, created_at, updated_at)
  4257. SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  4258. safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
  4259. FROM filament_sku_settings"""
  4260. )
  4261. )
  4262. await conn.execute(text("DROP TABLE filament_sku_settings"))
  4263. await conn.execute(text("ALTER TABLE filament_sku_settings_new RENAME TO filament_sku_settings"))
  4264. # Widen the unique key to include color_name on pre-existing tables. The
  4265. # auto-created UNIQUE index still covers only (material, subtype, brand)
  4266. # after the ADD COLUMN above, so rebuild the table to refresh it (#forecast
  4267. # -color-grouping). Detected by inspecting the index columns; skipped once
  4268. # color_name is already part of the key.
  4269. idx_rows = await conn.execute(text("PRAGMA index_list(filament_sku_settings)"))
  4270. needs_uq_rebuild = False
  4271. for idx in idx_rows.fetchall():
  4272. if idx[3] != "u": # origin col: 'u' = UNIQUE constraint, 'c' = CREATE INDEX, 'pk' = primary key
  4273. continue
  4274. info = await conn.execute(text(f"PRAGMA index_info({idx[1]})"))
  4275. cols = {row[2] for row in info.fetchall()}
  4276. if "material" in cols and "color_name" not in cols:
  4277. needs_uq_rebuild = True
  4278. break
  4279. if needs_uq_rebuild:
  4280. async with conn.begin_nested():
  4281. await conn.execute(text("DROP TABLE IF EXISTS filament_sku_settings_uqfix"))
  4282. await conn.execute(
  4283. text(
  4284. """CREATE TABLE filament_sku_settings_uqfix (
  4285. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4286. material VARCHAR(50) NOT NULL,
  4287. subtype VARCHAR(50),
  4288. brand VARCHAR(100),
  4289. color_name VARCHAR(100),
  4290. lead_time_days INTEGER NOT NULL DEFAULT 0,
  4291. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  4292. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  4293. alerts_snoozed BOOLEAN NOT NULL DEFAULT 0,
  4294. created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4295. updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
  4296. UNIQUE (material, subtype, brand, color_name)
  4297. )"""
  4298. )
  4299. )
  4300. await conn.execute(
  4301. text(
  4302. """INSERT INTO filament_sku_settings_uqfix
  4303. (id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  4304. safety_margin_unit, alerts_snoozed, created_at, updated_at)
  4305. SELECT id, material, subtype, brand, color_name, lead_time_days, safety_margin_value,
  4306. safety_margin_unit, COALESCE(alerts_snoozed, 0), created_at, updated_at
  4307. FROM filament_sku_settings"""
  4308. )
  4309. )
  4310. await conn.execute(text("DROP TABLE filament_sku_settings"))
  4311. await conn.execute(text("ALTER TABLE filament_sku_settings_uqfix RENAME TO filament_sku_settings"))
  4312. await _safe_execute(
  4313. conn,
  4314. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  4315. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4316. material VARCHAR(50) NOT NULL,
  4317. subtype VARCHAR(50),
  4318. brand VARCHAR(100),
  4319. color_name VARCHAR(100),
  4320. quantity_spools INTEGER NOT NULL DEFAULT 1,
  4321. note VARCHAR(500),
  4322. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  4323. purchased_at DATETIME,
  4324. added_at DATETIME DEFAULT CURRENT_TIMESTAMP
  4325. )""",
  4326. )
  4327. # Backfill color_name on pre-#1814 upgrades — the CREATE above already
  4328. # has it for fresh installs; the ALTER is the upgrade path. "duplicate
  4329. # column name" is swallowed by _safe_execute, so re-runs are no-ops.
  4330. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN color_name VARCHAR(100)")
  4331. # SQLite has no implicit updated_at trigger — add one so the column stays current.
  4332. await _safe_execute(
  4333. conn,
  4334. """CREATE TRIGGER IF NOT EXISTS trg_filament_sku_settings_updated_at
  4335. AFTER UPDATE ON filament_sku_settings FOR EACH ROW
  4336. BEGIN
  4337. UPDATE filament_sku_settings SET updated_at = CURRENT_TIMESTAMP WHERE id = OLD.id;
  4338. END""",
  4339. )
  4340. else:
  4341. await _safe_execute(
  4342. conn,
  4343. """CREATE TABLE IF NOT EXISTS filament_sku_settings (
  4344. id SERIAL PRIMARY KEY,
  4345. material VARCHAR(50) NOT NULL,
  4346. subtype VARCHAR(50),
  4347. brand VARCHAR(100),
  4348. lead_time_days INTEGER NOT NULL DEFAULT 0,
  4349. safety_margin_value INTEGER NOT NULL DEFAULT 14,
  4350. safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days',
  4351. color_name VARCHAR(100),
  4352. created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  4353. updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
  4354. UNIQUE (material, subtype, brand, color_name)
  4355. )""",
  4356. )
  4357. async with conn.begin_nested():
  4358. await conn.execute(text("UPDATE filament_sku_settings SET lead_time_days = 0 WHERE lead_time_days = 7"))
  4359. await _safe_execute(
  4360. conn,
  4361. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_value INTEGER NOT NULL DEFAULT 14",
  4362. )
  4363. await _safe_execute(
  4364. conn,
  4365. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS safety_margin_unit VARCHAR(10) NOT NULL DEFAULT 'days'",
  4366. )
  4367. await _safe_execute(
  4368. conn,
  4369. "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS alerts_snoozed BOOLEAN NOT NULL DEFAULT FALSE",
  4370. )
  4371. # Migration: add color_name to filament_sku_settings and widen the
  4372. # unique key to include it so forecasts distinguish colours within a
  4373. # SKU (#forecast-color-grouping). The matching ALTER for
  4374. # filament_shopping_list runs AFTER that table's CREATE below — on
  4375. # fresh installs the table doesn't exist yet at this point.
  4376. await _safe_execute(conn, "ALTER TABLE filament_sku_settings ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
  4377. # Widen UNIQUE (material, subtype, brand) → (material, subtype, brand, color_name).
  4378. # The original constraint was declared with name="uq_filament_sku" in the
  4379. # model, so we drop/re-add by that name. Gated on a pg_constraint lookup so
  4380. # the rebuild only runs when color_name is missing from the key — without
  4381. # the gate, every startup would take an ACCESS EXCLUSIVE lock on the table
  4382. # and churn the constraint.
  4383. uq_check = await conn.execute(
  4384. text(
  4385. "SELECT 1 FROM pg_constraint c "
  4386. "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY(c.conkey) "
  4387. "WHERE c.conname = 'uq_filament_sku' AND a.attname = 'color_name' LIMIT 1"
  4388. )
  4389. )
  4390. if uq_check.scalar_one_or_none() is None:
  4391. await _safe_execute(
  4392. conn,
  4393. "ALTER TABLE filament_sku_settings DROP CONSTRAINT IF EXISTS uq_filament_sku",
  4394. )
  4395. await _safe_execute(
  4396. conn,
  4397. "ALTER TABLE filament_sku_settings ADD CONSTRAINT uq_filament_sku "
  4398. "UNIQUE (material, subtype, brand, color_name)",
  4399. )
  4400. # Only backfill from safety_margin_days if that column still exists (PostgreSQL).
  4401. col_check = await conn.execute(
  4402. text(
  4403. "SELECT 1 FROM information_schema.columns "
  4404. "WHERE table_name = 'filament_sku_settings' AND column_name = 'safety_margin_days'"
  4405. )
  4406. )
  4407. if col_check.fetchone():
  4408. async with conn.begin_nested():
  4409. await conn.execute(
  4410. text(
  4411. "UPDATE filament_sku_settings SET safety_margin_value = safety_margin_days "
  4412. "WHERE safety_margin_value = 14 AND safety_margin_days != 14"
  4413. )
  4414. )
  4415. await _safe_execute(
  4416. conn,
  4417. """CREATE TABLE IF NOT EXISTS filament_shopping_list (
  4418. id SERIAL PRIMARY KEY,
  4419. material VARCHAR(50) NOT NULL,
  4420. subtype VARCHAR(50),
  4421. brand VARCHAR(100),
  4422. color_name VARCHAR(100),
  4423. quantity_spools INTEGER NOT NULL DEFAULT 1,
  4424. note VARCHAR(500),
  4425. status VARCHAR(20) NOT NULL DEFAULT 'pending',
  4426. purchased_at TIMESTAMP,
  4427. added_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
  4428. )""",
  4429. )
  4430. await _safe_execute(
  4431. conn,
  4432. "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'pending'",
  4433. )
  4434. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS purchased_at TIMESTAMP")
  4435. # Backfill color_name on pre-#1814 upgrades — the CREATE above already
  4436. # has it for fresh installs; the ALTER is the upgrade path.
  4437. await _safe_execute(conn, "ALTER TABLE filament_shopping_list ADD COLUMN IF NOT EXISTS color_name VARCHAR(100)")
  4438. # Migration: Add inventory stock alert columns to notification_providers.
  4439. # Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  4440. if is_sqlite():
  4441. await _safe_execute(
  4442. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT 0"
  4443. )
  4444. await _safe_execute(
  4445. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT 0"
  4446. )
  4447. else:
  4448. await _safe_execute(
  4449. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_reorder_alert BOOLEAN DEFAULT false"
  4450. )
  4451. await _safe_execute(
  4452. conn, "ALTER TABLE notification_providers ADD COLUMN on_stock_break_alert BOOLEAN DEFAULT false"
  4453. )
  4454. # Backfill the two flags above. The DEFAULT on those ALTERs only reaches
  4455. # existing rows when the ALTER is the statement that adds the column -- and
  4456. # on an install whose notification_providers table was (re)created from
  4457. # Base.metadata, create_all() had already added them by the time migrations
  4458. # ran, so _safe_execute swallowed the ALTER as a duplicate column and every
  4459. # pre-existing row kept NULL. Harmless while nothing read the flags; a 500
  4460. # on the whole provider list once #2827 declared them on the response
  4461. # schema, because pydantic will not accept None for a bool.
  4462. #
  4463. # false matches both the intent of the DEFAULT above and the behaviour the
  4464. # rows already have: _get_providers_for_event filters on `.is_(True)`, so a
  4465. # NULL flag never sent anything. Idempotent -- the WHERE matches nothing on
  4466. # the second run.
  4467. async with conn.begin_nested():
  4468. stock_backfill = await conn.execute(
  4469. text(
  4470. "UPDATE notification_providers SET on_stock_reorder_alert = :off WHERE on_stock_reorder_alert IS NULL"
  4471. ),
  4472. {"off": False},
  4473. )
  4474. stock_backfill_break = await conn.execute(
  4475. text("UPDATE notification_providers SET on_stock_break_alert = :off WHERE on_stock_break_alert IS NULL"),
  4476. {"off": False},
  4477. )
  4478. repaired = (stock_backfill.rowcount or 0) + (stock_backfill_break.rowcount or 0)
  4479. if repaired:
  4480. logger.info("Backfilled %s NULL inventory stock alert flag(s) on notification_providers", repaired)
  4481. # Migration: Heal orphan auth-related rows left behind by user-delete
  4482. # on SQLite. user_oidc_links, user_totp, user_otp_codes (introduced in
  4483. # PR #933) and long_lived_tokens (PR #1108) all declare ON DELETE
  4484. # CASCADE on user_id — both predate the explicit APIKey-cleanup
  4485. # pattern in PR #1182. PostgreSQL enforces the cascade, but SQLite
  4486. # ships with FK enforcement off, so rows pointing to a deleted user
  4487. # persisted — blocking SSO re-login (the OIDC callback finds the
  4488. # orphan link, fails to resolve the missing user, and falls through
  4489. # to "account_inactive" instead of triggering auto_create), leaking
  4490. # MFA secrets, and leaving camera-stream tokens whose secret_hash is
  4491. # still verify()-able by lookup_prefix. See issue #1285 (#1295 review
  4492. # extended the cleanup to long_lived_tokens). This migration is a
  4493. # no-op on PostgreSQL and idempotent on SQLite.
  4494. async with conn.begin_nested():
  4495. oidc_result = await conn.execute(
  4496. text("DELETE FROM user_oidc_links WHERE user_id NOT IN (SELECT id FROM users)")
  4497. )
  4498. totp_result = await conn.execute(text("DELETE FROM user_totp WHERE user_id NOT IN (SELECT id FROM users)"))
  4499. otp_result = await conn.execute(text("DELETE FROM user_otp_codes WHERE user_id NOT IN (SELECT id FROM users)"))
  4500. llt_result = await conn.execute(
  4501. text("DELETE FROM long_lived_tokens WHERE user_id NOT IN (SELECT id FROM users)")
  4502. )
  4503. oidc_n = oidc_result.rowcount or 0
  4504. totp_n = totp_result.rowcount or 0
  4505. otp_n = otp_result.rowcount or 0
  4506. llt_n = llt_result.rowcount or 0
  4507. if oidc_n or totp_n or otp_n or llt_n:
  4508. logger.info(
  4509. "Cleaned up orphan auth rows: %d OIDC links, %d TOTP, %d OTP codes, %d long-lived tokens",
  4510. oidc_n,
  4511. totp_n,
  4512. otp_n,
  4513. llt_n,
  4514. )
  4515. # Migration: extend print_log_entries with archive_id, cost, energy, failure_reason,
  4516. # created_by_id (#1378). Statistics queries shift from PrintArchive to PrintLogEntry
  4517. # so reprints contribute new rows instead of overwriting the source archive's data.
  4518. if is_sqlite():
  4519. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN archive_id INTEGER")
  4520. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN cost REAL")
  4521. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_kwh REAL")
  4522. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN energy_cost REAL")
  4523. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN failure_reason VARCHAR(100)")
  4524. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN created_by_id INTEGER")
  4525. else:
  4526. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS archive_id INTEGER")
  4527. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS cost DOUBLE PRECISION")
  4528. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_kwh DOUBLE PRECISION")
  4529. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS energy_cost DOUBLE PRECISION")
  4530. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS failure_reason VARCHAR(100)")
  4531. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN IF NOT EXISTS created_by_id INTEGER")
  4532. await _safe_execute(
  4533. conn, "CREATE INDEX IF NOT EXISTS ix_print_log_entries_archive_id ON print_log_entries (archive_id)"
  4534. )
  4535. # Backfill PrintLogEntry → PrintArchive linkage and per-event cost/energy
  4536. # for pre-#1378 rows the column-add migration left NULL (#1390).
  4537. #
  4538. # Without this backfill the user's Quick Stats show Filament Cost = 0 and
  4539. # Time Accuracy empty even though their archives carry both, because:
  4540. #
  4541. # - the new stats queries SUM PrintLogEntry.cost (NULL for old rows)
  4542. # - the time-accuracy query JOINs PrintArchive ON archive_id (NULL for
  4543. # old rows, so old runs get excluded from the average)
  4544. #
  4545. # Pre-#1378, archive.cost / energy_kwh / energy_cost were overwritten by
  4546. # each rerun, so the current archive values represent the *latest* run.
  4547. # Backfilling them onto the latest matching PrintLogEntry per archive
  4548. # reconstructs the pre-fix total exactly (sum across archives stays
  4549. # unchanged), and leaves earlier reprints with NULL cost so they
  4550. # contribute zero — matching the "first/latest writes, rest stay NULL"
  4551. # convention #1378 introduced for new prints.
  4552. #
  4553. # DML, not DDL — use conn.execute() inside a savepoint per _safe_execute's
  4554. # own docstring. SQL is plain ANSI (correlated UPDATE, MAX/GROUP BY/HAVING,
  4555. # CASE in HAVING) and runs unchanged on SQLite + PostgreSQL; verified
  4556. # against postgres:16-alpine + asyncpg.
  4557. #
  4558. # Step 1: link old log entries to their archive via print_name + printer_id.
  4559. # Picks the highest-id matching archive when multiple share the same key
  4560. # (newest archive wins — closest to the log's overwrite-then-leave shape).
  4561. # One-shot: since #1378 a NULL archive_id is a real answer (no archive, or
  4562. # its archive was deleted: ON DELETE SET NULL), and running this on every
  4563. # start linked such runs to whichever archive shares the name.
  4564. from sqlalchemy import text as _text
  4565. link_flag = "_backfill_1390_log_archive_link_done"
  4566. async with conn.begin_nested():
  4567. linked = (
  4568. await conn.execute(_text('SELECT value FROM settings WHERE "key" = :k'), {"k": link_flag})
  4569. ).scalar_one_or_none()
  4570. if linked is None:
  4571. async with conn.begin_nested():
  4572. await conn.execute(
  4573. _text("""
  4574. UPDATE print_log_entries
  4575. SET archive_id = (
  4576. SELECT a.id
  4577. FROM print_archives a
  4578. WHERE a.print_name = print_log_entries.print_name
  4579. AND (
  4580. a.printer_id = print_log_entries.printer_id
  4581. OR (a.printer_id IS NULL AND print_log_entries.printer_id IS NULL)
  4582. )
  4583. ORDER BY a.id DESC
  4584. LIMIT 1
  4585. )
  4586. WHERE archive_id IS NULL AND print_name IS NOT NULL
  4587. """)
  4588. )
  4589. await conn.execute(
  4590. _text('INSERT INTO settings ("key", value) VALUES (:k, :v)'), {"k": link_flag, "v": "true"}
  4591. )
  4592. # Step 2: backfill cost / energy_kwh / energy_cost onto the latest linked
  4593. # log entry per archive — the row whose creation time best matches the
  4594. # value currently stored on the archive (overwrite-on-reprint semantics
  4595. # under the old design). Only fires for archives where NO log entry has
  4596. # cost set yet, which gives the migration a clean idempotency property:
  4597. # the second pass sees the archive already has a cost-bearing run and
  4598. # leaves the rest of its history NULL (instead of marching up the
  4599. # ID-ordered list of NULL runs on every pass).
  4600. async with conn.begin_nested():
  4601. await conn.execute(
  4602. _text("""
  4603. UPDATE print_log_entries
  4604. SET cost = (SELECT cost FROM print_archives WHERE id = print_log_entries.archive_id),
  4605. energy_kwh = (SELECT energy_kwh FROM print_archives WHERE id = print_log_entries.archive_id),
  4606. energy_cost = (SELECT energy_cost FROM print_archives WHERE id = print_log_entries.archive_id)
  4607. WHERE id IN (
  4608. SELECT MAX(id)
  4609. FROM print_log_entries
  4610. WHERE archive_id IS NOT NULL
  4611. GROUP BY archive_id
  4612. HAVING SUM(CASE WHEN cost IS NOT NULL THEN 1 ELSE 0 END) = 0
  4613. )
  4614. """)
  4615. )
  4616. # Migration: smart_plugs gets per-plug auto-off-after-drying toggle and
  4617. # delay (#1349). Fires whenever any AMS attached to the linked printer
  4618. # finishes a dry cycle. Plain ANSI ALTER TABLE works on both SQLite and
  4619. # Postgres for INTEGER/BOOLEAN with simple defaults.
  4620. if is_sqlite():
  4621. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN auto_off_after_drying BOOLEAN DEFAULT 0")
  4622. await _safe_execute(
  4623. conn, "ALTER TABLE smart_plugs ADD COLUMN off_delay_after_drying_minutes INTEGER DEFAULT 10"
  4624. )
  4625. else:
  4626. await _safe_execute(
  4627. conn,
  4628. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS auto_off_after_drying BOOLEAN DEFAULT false",
  4629. )
  4630. await _safe_execute(
  4631. conn,
  4632. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS off_delay_after_drying_minutes INTEGER DEFAULT 10",
  4633. )
  4634. # Migration: Add per-user Orca Cloud credential columns. Mirrors the Bambu
  4635. # Cloud columns but adds refresh_token + expires_at (Supabase PKCE issues
  4636. # short-lived access tokens with rotating refresh tokens), plus three
  4637. # transient PKCE state columns held during the auth handshake. DATETIME
  4638. # is SQLite-only — Postgres uses TIMESTAMP, so the datetime columns are
  4639. # dialect-branched per project convention.
  4640. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_token VARCHAR(2000)")
  4641. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_refresh_token VARCHAR(128)")
  4642. if is_sqlite():
  4643. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_expires_at DATETIME")
  4644. else:
  4645. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_expires_at TIMESTAMP")
  4646. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_email VARCHAR(255)")
  4647. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_user_id VARCHAR(64)")
  4648. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_verifier VARCHAR(64)")
  4649. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_state VARCHAR(32)")
  4650. if is_sqlite():
  4651. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN orca_cloud_pending_at DATETIME")
  4652. else:
  4653. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS orca_cloud_pending_at TIMESTAMP")
  4654. # Migration: record when Bambu rejects a stored cloud token. Until now the
  4655. # only state we kept was the token string itself, so a dead credential was
  4656. # indistinguishable from a live one and the UI reported "connected" forever
  4657. # while every cloud call 401'd. DATETIME is SQLite-only — Postgres uses
  4658. # TIMESTAMP, so the column is dialect-branched per project convention.
  4659. if is_sqlite():
  4660. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN cloud_token_invalid_at DATETIME")
  4661. else:
  4662. await _safe_execute(conn, "ALTER TABLE users ADD COLUMN IF NOT EXISTS cloud_token_invalid_at TIMESTAMP")
  4663. # Data migration: drop the embedded 3MF Title (`print_name`) from library
  4664. # file metadata so the FileManager displays the filename, not the title (#1489).
  4665. await _migrate_drop_library_print_name(conn)
  4666. # Data migration: queue items written before #2551 carry every selected plate's
  4667. # filament overrides, so a force-colour plate waits on colours it never prints.
  4668. await _migrate_scope_force_color_overrides_to_plate(conn)
  4669. # Backfill NULL print_archives.created_at — older rows (and rows imported
  4670. # via the SQLite ↔ Postgres cross-DB restore path) can land with NULL
  4671. # because the column was originally created without a DEFAULT clause and
  4672. # server_default=func.now() only fires at table creation, not column
  4673. # population. The list_archives response model requires a datetime, so a
  4674. # single NULL row 500s the whole endpoint (#1732).
  4675. async with conn.begin_nested():
  4676. if is_sqlite():
  4677. await conn.execute(
  4678. text(
  4679. "UPDATE print_archives "
  4680. "SET created_at = COALESCE(completed_at, started_at, datetime('now')) "
  4681. "WHERE created_at IS NULL"
  4682. )
  4683. )
  4684. else:
  4685. await conn.execute(
  4686. text(
  4687. "UPDATE print_archives "
  4688. "SET created_at = COALESCE(completed_at, started_at, NOW()) "
  4689. "WHERE created_at IS NULL"
  4690. )
  4691. )
  4692. # Migration: structured storage locations (#1004). Flat catalog of physical
  4693. # shelves/drawers; spool.location_id FK with storage_location kept denormalized.
  4694. await _safe_execute(
  4695. conn,
  4696. """
  4697. CREATE TABLE IF NOT EXISTS locations (
  4698. id INTEGER PRIMARY KEY AUTOINCREMENT,
  4699. name VARCHAR(255) NOT NULL UNIQUE,
  4700. name_key VARCHAR(255),
  4701. identifier VARCHAR(100),
  4702. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4703. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  4704. )
  4705. """
  4706. if is_sqlite()
  4707. else """
  4708. CREATE TABLE IF NOT EXISTS locations (
  4709. id SERIAL PRIMARY KEY,
  4710. name VARCHAR(255) NOT NULL UNIQUE,
  4711. name_key VARCHAR(255),
  4712. identifier VARCHAR(100),
  4713. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  4714. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  4715. )
  4716. """,
  4717. )
  4718. await _safe_execute(conn, "ALTER TABLE locations ADD COLUMN name_key VARCHAR(255)")
  4719. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_locations_name_key ON locations (name_key)")
  4720. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN location_id INTEGER REFERENCES locations(id)")
  4721. await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_location_id ON spool (location_id)")
  4722. # Backfill name_key on legacy rows FIRST. If a pre-existing locations
  4723. # row was manually inserted before this migration ran, its name_key is
  4724. # NULL. The dedup INSERT below would then be silently skipped by
  4725. # UNIQUE(name) (legacy row already has the name), AND the spool-link
  4726. # UPDATE that joins on name_key would miss it. Doing this backfill BEFORE
  4727. # the INSERT keeps the join consistent on both branches of the migration.
  4728. async with conn.begin_nested():
  4729. await conn.execute(
  4730. text(
  4731. """
  4732. UPDATE locations
  4733. SET name_key = LOWER(TRIM(name))
  4734. WHERE name_key IS NULL OR TRIM(name_key) = ''
  4735. """
  4736. )
  4737. )
  4738. # Backfill locations from existing free-text storage_location values.
  4739. # GROUP BY name_key so case variants ("Drybox 1" / "DRYBOX 1") collapse to
  4740. # one row; INSERT OR IGNORE / ON CONFLICT keeps the migration idempotent.
  4741. _location_backfill_sql = (
  4742. """
  4743. INSERT OR IGNORE INTO locations (name, name_key, created_at, updated_at)
  4744. SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  4745. FROM spool
  4746. WHERE TRIM(COALESCE(storage_location, '')) != ''
  4747. GROUP BY LOWER(TRIM(storage_location))
  4748. """
  4749. if is_sqlite()
  4750. else """
  4751. INSERT INTO locations (name, name_key, created_at, updated_at)
  4752. SELECT MIN(TRIM(storage_location)), LOWER(TRIM(storage_location)), CURRENT_TIMESTAMP, CURRENT_TIMESTAMP
  4753. FROM spool
  4754. WHERE TRIM(COALESCE(storage_location, '')) != ''
  4755. GROUP BY LOWER(TRIM(storage_location))
  4756. ON CONFLICT (name_key) DO NOTHING
  4757. """
  4758. )
  4759. async with conn.begin_nested():
  4760. await conn.execute(text(_location_backfill_sql))
  4761. await conn.execute(
  4762. text(
  4763. """
  4764. UPDATE spool
  4765. SET location_id = (
  4766. SELECT l.id FROM locations l
  4767. WHERE l.name_key = LOWER(TRIM(spool.storage_location))
  4768. LIMIT 1
  4769. )
  4770. WHERE TRIM(COALESCE(storage_location, '')) != ''
  4771. AND location_id IS NULL
  4772. """
  4773. )
  4774. )
  4775. # Sanity check: any spools that still have a free-text storage_location
  4776. # but no location_id link mean a row slipped through the dedup INSERT
  4777. # (most likely a pre-existing manually-inserted locations row with a
  4778. # hostile name shape that the UNIQUE(name) check tripped on). Surface
  4779. # the count so ops can investigate — the user won't see those spools in
  4780. # location-filtered queries until they're manually linked or re-saved.
  4781. orphan_count_row = await conn.execute(
  4782. text("SELECT COUNT(*) FROM spool WHERE TRIM(COALESCE(storage_location, '')) != '' AND location_id IS NULL")
  4783. )
  4784. orphan_count = orphan_count_row.scalar() or 0
  4785. if orphan_count:
  4786. logger.warning(
  4787. "Storage-location migration left %d spool(s) with free-text storage_location "
  4788. "but no location_id link. Re-save those spools or merge the orphaned location "
  4789. "names manually.",
  4790. orphan_count,
  4791. )
  4792. # Migration: Add on_ai_failure_detection column to notification_providers (#1794).
  4793. # Splits Obico AI failure detection out of the multiplexed on_printer_error
  4794. # event so users can subscribe to spaghetti alerts independently of HMS
  4795. # hardware-error alerts. Postgres rejects `DEFAULT 0` for BOOLEAN columns.
  4796. if is_sqlite():
  4797. await _safe_execute(
  4798. conn,
  4799. "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT 0",
  4800. )
  4801. else:
  4802. await _safe_execute(
  4803. conn,
  4804. "ALTER TABLE notification_providers ADD COLUMN on_ai_failure_detection BOOLEAN DEFAULT false",
  4805. )
  4806. # Migration: Add gate_acknowledged column to print_queue (#1818). Cleared
  4807. # by the per-printer "Resume after failure" action so the scheduler's
  4808. # `_check_previous_success` lookback skips this row. Postgres rejects
  4809. # `DEFAULT 0` for BOOLEAN columns.
  4810. if is_sqlite():
  4811. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT 0")
  4812. else:
  4813. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN gate_acknowledged BOOLEAN DEFAULT false")
  4814. # Migration: Add is_autologin column to oidc_providers (#1589). Postgres
  4815. # rejects ``DEFAULT 0`` for BOOLEAN columns.
  4816. if is_sqlite():
  4817. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT 0")
  4818. else:
  4819. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_autologin BOOLEAN DEFAULT false")
  4820. # Migration: Add is_env_managed column to oidc_providers (#2593). Marks the
  4821. # provider upserted from BAMBUDDY_OIDC_* env vars on startup. Postgres
  4822. # rejects ``DEFAULT 0`` for BOOLEAN columns.
  4823. if is_sqlite():
  4824. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT 0")
  4825. else:
  4826. await _safe_execute(conn, "ALTER TABLE oidc_providers ADD COLUMN is_env_managed BOOLEAN DEFAULT false")
  4827. # Migration: Add dispatch_attempts to print_queue (#2555). Counts the times
  4828. # the start-watchdog reverted the row from 'printing' back to 'pending' so a
  4829. # printer that never actually starts stops being retried forever. INTEGER
  4830. # DEFAULT 0 is spelled identically on SQLite and Postgres — no dialect branch.
  4831. # Verified on both dialects: ADD COLUMN ... DEFAULT 0 backfills existing rows,
  4832. # so no separate UPDATE is needed (and _safe_execute is DDL-only — see its
  4833. # docstring). The scheduler reads it as `(item.dispatch_attempts or 0) + 1`
  4834. # regardless, so even a NULL row could not disable the retry cap.
  4835. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN dispatch_attempts INTEGER DEFAULT 0")
  4836. # Backfill: copy the selected plate from linked queue rows onto their archives
  4837. # (#2603). Recovers the plate for archives created before print_archives had a
  4838. # plate_id column, wherever the queue row still points at the archive and
  4839. # carries a plate. Runs here — after every print_queue column migration
  4840. # (plate_id, archive_id) — because it reads print_queue.plate_id, which is
  4841. # added far earlier in this function but must exist before this DML runs on a
  4842. # first-ever migration pass. Correlated-subquery form so the DML is identical
  4843. # on SQLite and Postgres; the WHERE plate_id IS NULL guard makes it idempotent
  4844. # and keeps it from clobbering values set on later runs.
  4845. async with conn.begin_nested():
  4846. # Only do any work (and, on SQLite, the FTS rebuild below) when there is
  4847. # actually a plate to recover — so this is a one-off cost on the upgrade
  4848. # boot, not an every-boot tax once every archive is backfilled.
  4849. has_work = (
  4850. await conn.execute(
  4851. text(
  4852. "SELECT 1 FROM print_archives a "
  4853. "JOIN print_queue q ON q.archive_id = a.id "
  4854. "WHERE a.plate_id IS NULL AND q.plate_id IS NOT NULL "
  4855. "LIMIT 1"
  4856. )
  4857. )
  4858. ).first() is not None
  4859. if has_work:
  4860. # SQLite: print_archives has an external-content FTS index (archive_fts,
  4861. # created above) whose AFTER UPDATE trigger issues an FTS 'delete' for
  4862. # the row. Archives created before that table existed were never indexed
  4863. # (its creation runs no rebuild), and updating an un-indexed row trips
  4864. # "database disk image is malformed". plate_id isn't even an FTS column,
  4865. # so the trigger's re-index is pointless here — but it still fires. Rebuild
  4866. # the index from the content table first so every row is present and the
  4867. # trigger's 'delete' is well-defined. Postgres has no such FTS table.
  4868. if is_sqlite():
  4869. await conn.execute(text("INSERT INTO archive_fts(archive_fts) VALUES('rebuild')"))
  4870. await conn.execute(
  4871. text(
  4872. "UPDATE print_archives "
  4873. "SET plate_id = ("
  4874. " SELECT pq.plate_id FROM print_queue pq "
  4875. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL "
  4876. " LIMIT 1"
  4877. ") "
  4878. "WHERE plate_id IS NULL "
  4879. "AND EXISTS ("
  4880. " SELECT 1 FROM print_queue pq "
  4881. " WHERE pq.archive_id = print_archives.id AND pq.plate_id IS NOT NULL"
  4882. ")"
  4883. )
  4884. )
  4885. # Migration: repair completed print-log rows that stored a multi-plate 3MF's
  4886. # whole-file filament instead of the printed plate's (#2614). Runs AFTER the
  4887. # #2603 archive plate_id backfill above so print_archives.plate_id is populated.
  4888. await _migrate_scope_run_filament_to_plate(conn)
  4889. # Backfill: archives written before #2989 have no bed temperature, because
  4890. # the extractor looked for a key BambuStudio never writes. Re-reads the 3MF
  4891. # already on disk. One-shot; see the function for why it is gated.
  4892. await _backfill_archive_bed_temperature(conn)
  4893. # Backfill: library rows typed from the filename alone kept a sliced 3MF
  4894. # named `Foo.3mf` filed as a source-only project (#2993). Re-reads the zip
  4895. # already on disk. One-shot, internal rows only; see the function.
  4896. await _reclassify_sliced_3mf_library_files(conn)
  4897. # Migration: Add controls_printer_power to smart_plugs (#2629). Marks
  4898. # whether a plug actually feeds the printer's own power — only then may an
  4899. # auto-off mark the printer offline. Defaults to true so existing plugs
  4900. # keep the previous behaviour; accessory plugs (filter fan, lights) are
  4901. # opted out by the user. BOOLEAN literals differ per dialect (SQLite has
  4902. # no true/false keyword), so the default is dialect-branched.
  4903. if is_sqlite():
  4904. await _safe_execute(conn, "ALTER TABLE smart_plugs ADD COLUMN controls_printer_power BOOLEAN DEFAULT 1")
  4905. else:
  4906. await _safe_execute(
  4907. conn,
  4908. "ALTER TABLE smart_plugs ADD COLUMN IF NOT EXISTS controls_printer_power BOOLEAN DEFAULT true",
  4909. )
  4910. # Migration: real filesystem mtime for library files/folders (#2680). The
  4911. # folder tree's "sort by recent activity" and the file pane's date sort must
  4912. # track the on-disk mtime (``ls -t``), not Bambuddy's DB ``updated_at`` — for
  4913. # a bulk external scan every row's ``updated_at`` is the same scan instant, so
  4914. # ordering was arbitrary. Nullable; the timestamp type differs by dialect
  4915. # (SQLite DATETIME vs Postgres TIMESTAMP) so an existing-DB upgrade doesn't hit
  4916. # "type datetime does not exist" on Postgres. On a fresh DB create_all() already
  4917. # built the column, so the ALTER is swallowed as already applied.
  4918. if is_sqlite():
  4919. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at DATETIME")
  4920. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at DATETIME")
  4921. else:
  4922. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN fs_modified_at TIMESTAMP")
  4923. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN fs_modified_at TIMESTAMP")
  4924. # Migration: Disambiguate the four ``user_print_*`` notification template
  4925. # names by appending " Email" (#1792). See ``_migrate_rename_user_print_template_names``.
  4926. await _migrate_rename_user_print_template_names(conn)
  4927. await _migrate_clear_bug_report_emails(conn)
  4928. # Migration: per-file print progress inside a project (#1897).
  4929. # - print_archives.library_file_id: which library file a queued run was
  4930. # dispatched from; nullable. Added without its foreign key (SQLite can't
  4931. # ADD CONSTRAINT); on PostgreSQL _migrate_add_missing_set_null_fks adds it.
  4932. # - projects.target_sets: optional copies-per-file target. INTEGER is
  4933. # spelled identically on SQLite and Postgres — no dialect branch.
  4934. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN library_file_id INTEGER")
  4935. await _safe_execute(conn, "ALTER TABLE projects ADD COLUMN target_sets INTEGER")
  4936. # Migration: persist the timelapse snapshot-diff baseline (#2704).
  4937. # The list of video filenames present on the printer when the print began,
  4938. # so the diff survives a restart and the manual scan can use it instead of
  4939. # the clock-based matching that a LAN-only printer defeats. No dialect
  4940. # branch: SQLAlchemy renders this column as `JSON` on both SQLite and
  4941. # Postgres for a fresh install (checked with CreateTable against each
  4942. # dialect), so spelling the ALTER the same way keeps a migrated database
  4943. # identical to a new one. Matching matters on Postgres in particular —
  4944. # asyncpg binds the serialised value as json and would reject a TEXT column
  4945. # (mirrors the `projects.attachments JSON` migration above).
  4946. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_baseline JSON")
  4947. # Migration: plate-clear-required notification opt-in (#2525). Off by
  4948. # default — it fires after every print, at the same moment as the
  4949. # print-complete alert. Postgres rejects `DEFAULT 0` for BOOLEAN.
  4950. if is_sqlite():
  4951. await _safe_execute(
  4952. conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT 0"
  4953. )
  4954. else:
  4955. await _safe_execute(
  4956. conn, "ALTER TABLE notification_providers ADD COLUMN on_plate_clear_required BOOLEAN DEFAULT false"
  4957. )
  4958. # Migration: variant grouping for library files (#671 / #2570). The
  4959. # `file_variant_groups` table itself needs no migration — create_all() above
  4960. # builds it — but the two member-side columns do. INTEGER and the inline
  4961. # REFERENCES clause are spelled identically on SQLite and Postgres, and
  4962. # SQLite accepts a REFERENCES on ADD COLUMN (same form as the
  4963. # pipeline_runs.parent_run_id migration at the top of this function).
  4964. await _safe_execute(
  4965. conn,
  4966. "ALTER TABLE library_files ADD COLUMN variant_group_id INTEGER "
  4967. "REFERENCES file_variant_groups(id) ON DELETE SET NULL",
  4968. )
  4969. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_position INTEGER DEFAULT 0")
  4970. # User-declared target model for a file whose 3MF does not say (#671).
  4971. # VARCHAR(50) is spelled identically on SQLite and Postgres.
  4972. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN variant_target_model VARCHAR(50)")
  4973. # The model declares index=True, so fresh installs get this from create_all();
  4974. # migrated databases need it spelled out. Resolution looks members up by group
  4975. # on every scheduler pass that touches a grouped item.
  4976. await _safe_execute(
  4977. conn,
  4978. "CREATE INDEX IF NOT EXISTS ix_library_files_variant_group_id ON library_files (variant_group_id)",
  4979. )
  4980. await _migrate_backfill_variant_groups(conn)
  4981. # Migration: Home Assistant sensor alerts (#1148). The printer_ha_sensors
  4982. # table itself is new, so create_all() builds it; only the provider opt-in
  4983. # column needs adding to existing databases.
  4984. #
  4985. # DEFAULT FALSE, not DEFAULT 0: Postgres will not take an integer default
  4986. # for a boolean column, and _safe_execute swallows the DatatypeMismatchError
  4987. # — so the older "BOOLEAN DEFAULT 0" migrations above quietly do nothing on
  4988. # Postgres and only work there because create_all() builds the column on a
  4989. # fresh install. SQLite has understood FALSE since 3.23, so this spelling
  4990. # is the one that actually applies on both.
  4991. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_ha_sensor_alert BOOLEAN DEFAULT FALSE")
  4992. # Migration: auto-drying-suspended notification opt-in (#2770). Defaults ON:
  4993. # it fires at most once per AMS unit, and only to say Bambuddy has STOPPED
  4994. # doing something it was doing before — silence there reads as "still
  4995. # drying" and is exactly how the reporter lost two days to a re-arm loop.
  4996. await _safe_execute(
  4997. conn, "ALTER TABLE notification_providers ADD COLUMN on_ams_drying_suspended BOOLEAN DEFAULT TRUE"
  4998. )
  4999. # Migration: user link + photos on library files (#3077), the same trio
  5000. # print_archives carries (external_url / photos). Photos are stored under
  5001. # <archive_dir>/library/photos/<file_id>/ — the column only holds the names.
  5002. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN external_url VARCHAR(500)")
  5003. await _safe_execute(conn, "ALTER TABLE library_files ADD COLUMN photos JSON")
  5004. # Migration: storage location sensor alerts (#2824), own column rather than
  5005. # reusing on_ha_sensor_alert. That column can be scoped to one printer
  5006. # (printer_id), and a location alert has no printer to scope by — sharing
  5007. # the column meant a provider narrowed to one printer's sensors silently
  5008. # also received every drybox alert, with no toggle to separate the two.
  5009. await _safe_execute(
  5010. conn, "ALTER TABLE notification_providers ADD COLUMN on_location_ha_sensor_alert BOOLEAN DEFAULT FALSE"
  5011. )
  5012. # Migration: post-print outcome confirmation (#1898). VARCHAR and the
  5013. # BOOLEAN DEFAULT FALSE/TRUE spellings are identical on SQLite and
  5014. # Postgres (see the on_ha_sensor_alert note above for why not DEFAULT 0).
  5015. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict VARCHAR(10)")
  5016. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_requested BOOLEAN DEFAULT FALSE")
  5017. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token VARCHAR(64)")
  5018. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_source VARCHAR(16)")
  5019. # ``DATETIME`` is a SQLite-only alias; PostgreSQL rejects it and
  5020. # _safe_execute would swallow the error, leaving the column missing.
  5021. if is_sqlite():
  5022. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at DATETIME")
  5023. else:
  5024. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN confirm_token_used_at TIMESTAMP")
  5025. # When the verdict on file was recorded (#1898): the "already answered"
  5026. # page dates the verdict by this, not by the moment the one-tap token was
  5027. # spent, so a verdict changed later in the app reads correctly.
  5028. if is_sqlite():
  5029. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at DATETIME")
  5030. else:
  5031. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN user_verdict_at TIMESTAMP")
  5032. await _safe_execute(conn, "ALTER TABLE print_log_entries ADD COLUMN user_verdict VARCHAR(10)")
  5033. await _safe_execute(conn, "ALTER TABLE print_queue ADD COLUMN confirm_outcome BOOLEAN DEFAULT FALSE")
  5034. await _safe_execute(
  5035. conn, "ALTER TABLE notification_providers ADD COLUMN on_print_confirm_request BOOLEAN DEFAULT TRUE"
  5036. )
  5037. # The one-tap verdict route looks archives up by this token and runs with no
  5038. # authentication, so an upgraded install needs the index too — without it
  5039. # every tap, and every unauthenticated request carrying a bogus token, is a
  5040. # sequential scan of print_archives.
  5041. await _safe_execute(
  5042. conn,
  5043. "CREATE UNIQUE INDEX IF NOT EXISTS ix_print_archives_confirm_token ON print_archives (confirm_token)",
  5044. )
  5045. # Migration: take the capability URLs out of the outcome prompt's body
  5046. # (#1898). Seeding only ever inserts a template that is missing, so an
  5047. # install that already ran an earlier build of this feature would keep
  5048. # sending the verdict links as body text for every channel.
  5049. await _migrate_confirm_prompt_body_template(conn)
  5050. # Migration: Telegram verdict-by-reaction (#3046). Per-provider mode plus
  5051. # the table of delivered prompts the reaction poller matches updates
  5052. # against. create_all covers fresh installs; this covers upgrades.
  5053. await _safe_execute(
  5054. conn, "ALTER TABLE notification_providers ADD COLUMN telegram_verdict_mode VARCHAR(16) DEFAULT 'buttons'"
  5055. )
  5056. await _safe_execute(
  5057. conn,
  5058. """
  5059. CREATE TABLE IF NOT EXISTS telegram_pending_verdicts (
  5060. id INTEGER PRIMARY KEY AUTOINCREMENT,
  5061. provider_id INTEGER NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE,
  5062. chat_id VARCHAR(64) NOT NULL,
  5063. message_id INTEGER NOT NULL,
  5064. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  5065. confirm_token VARCHAR(64),
  5066. has_caption BOOLEAN DEFAULT FALSE,
  5067. message_text TEXT,
  5068. created_at DATETIME
  5069. )
  5070. """
  5071. if is_sqlite()
  5072. else """
  5073. CREATE TABLE IF NOT EXISTS telegram_pending_verdicts (
  5074. id SERIAL PRIMARY KEY,
  5075. provider_id INTEGER NOT NULL REFERENCES notification_providers(id) ON DELETE CASCADE,
  5076. chat_id VARCHAR(64) NOT NULL,
  5077. message_id INTEGER NOT NULL,
  5078. archive_id INTEGER NOT NULL REFERENCES print_archives(id) ON DELETE CASCADE,
  5079. confirm_token VARCHAR(64),
  5080. has_caption BOOLEAN DEFAULT FALSE,
  5081. message_text TEXT,
  5082. created_at TIMESTAMP
  5083. )
  5084. """,
  5085. )
  5086. await _safe_execute(
  5087. conn,
  5088. "CREATE INDEX IF NOT EXISTS ix_telegram_pending_verdicts_created_at ON telegram_pending_verdicts (created_at)",
  5089. )
  5090. # Migration: rename the ha_sensor_alert template (#2824). "Home Assistant
  5091. # Sensor Alert" was fine as a name while it was the only such template;
  5092. # next to the new "Storage Location Sensor Alert" it no longer says which
  5093. # one is the printer's. See _migrate_rename_user_print_template_names for
  5094. # why this is a plain UPDATE guarded on the old name rather than a
  5095. # DEFAULT_TEMPLATES re-seed.
  5096. await _migrate_rename_ha_sensor_alert_template(conn)
  5097. # Migration: back the one-binding-per-(location, entity) rule with a unique
  5098. # index (#2824). The API's duplicate check is read-then-insert, so two
  5099. # concurrent creates could both pass it; the index turns the loser into an
  5100. # IntegrityError the route maps back to the same 400. create_all() adds it
  5101. # on fresh installs only — this covers databases whose table predates it.
  5102. await _migrate_location_ha_sensor_unique_binding(conn)
  5103. # Migration: the stock alert templates name the colour and subtype (#2955).
  5104. # The forecast groups by colour, so two colours of one product would
  5105. # otherwise send the same message. A plain UPDATE guarded on the old text, so
  5106. # a template an admin has edited is left alone.
  5107. await _migrate_stock_alert_template_sku_variables(conn)
  5108. # Migration: the Password Reset template is for the reset link. Its old
  5109. # default emailed a new password, a flow that no longer exists. A plain
  5110. # UPDATE guarded on the old text, so a template an admin has edited is left
  5111. # alone (the email adds the link to any template, at the end).
  5112. await _migrate_password_reset_template_link(conn)
  5113. # Migration: the Welcome Email template no longer holds the new user's
  5114. # password; Bambuddy adds the login details itself.
  5115. # A plain UPDATE guarded on the old text, so an edited template is left
  5116. # alone (a {password} in it renders as nothing).
  5117. await _migrate_welcome_template_without_password(conn)
  5118. # Migration: supplier master list + spool assignments (#2988).
  5119. # create_all() covers fresh installs; this covers upgrades.
  5120. await _migrate_create_supplier_tables(conn)
  5121. # Migration: Add material_number to spool (#2870). Nullable free text —
  5122. # the internal purchasing/article number a business costs by, shared by
  5123. # all spools of the same product. VARCHAR(64) is spelled identically on
  5124. # SQLite and Postgres.
  5125. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN material_number VARCHAR(64)")
  5126. # Migration: repair the tare of spools the RFID auto-add gave the wrong
  5127. # Bambu spool row (#2909). Runs last so the spool catalogue it reads is
  5128. # whatever this database actually holds.
  5129. await _migrate_repair_rfid_core_weight(conn)
  5130. # Migration: drop the AMS slot markers an older Bambuddy wrote into
  5131. # Spoolman and the location sync then imported as storage locations.
  5132. await _migrate_drop_ams_slot_locations(conn)
  5133. # Data migration: printer locations as the locations API stores them (#2962).
  5134. await _migrate_normalize_printer_locations(conn)
  5135. # Migration: link a batch to the external record that asked for it (a shop
  5136. # order an integration turned into prints). The unique index is what makes
  5137. # a retried create safe; both columns are new, so no row can violate it.
  5138. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN external_source VARCHAR(32)")
  5139. await _safe_execute(conn, "ALTER TABLE print_batches ADD COLUMN external_ref VARCHAR(255)")
  5140. await _safe_execute(
  5141. conn,
  5142. "CREATE UNIQUE INDEX IF NOT EXISTS uq_print_batches_external ON print_batches (external_source, external_ref)",
  5143. )
  5144. # Migration: messages from other applications through the notification
  5145. # channels. Both flags default off, so no channel starts delivering them and
  5146. # no existing API key gains the right to send them on upgrade. BOOLEAN
  5147. # DEFAULT FALSE is accepted by SQLite and PostgreSQL alike. The backfill
  5148. # covers a table create_all() already gave the column (the ALTER is then
  5149. # swallowed as a duplicate and existing rows keep NULL; see the stock alert
  5150. # flags above).
  5151. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN on_app_message BOOLEAN DEFAULT FALSE")
  5152. await _safe_execute(conn, "ALTER TABLE api_keys ADD COLUMN can_send_notifications BOOLEAN DEFAULT FALSE")
  5153. async with conn.begin_nested():
  5154. await conn.execute(
  5155. text("UPDATE notification_providers SET on_app_message = :off WHERE on_app_message IS NULL"), {"off": False}
  5156. )
  5157. await conn.execute(
  5158. text("UPDATE api_keys SET can_send_notifications = :off WHERE can_send_notifications IS NULL"),
  5159. {"off": False},
  5160. )
  5161. # Migration: a code for why a scheduled drying failed, so the card can show
  5162. # the reason in the user's language. Nullable: rows that failed before this
  5163. # keep showing their English error_message.
  5164. await _safe_execute(conn, "ALTER TABLE scheduled_dryings ADD COLUMN error_code VARCHAR(32)")
  5165. # Migration: per-provider photo attachment opt-out. Defaults TRUE so
  5166. # existing providers keep attaching snapshots exactly as before. The
  5167. # backfill covers a table create_all() already gave the column (the ALTER
  5168. # is then swallowed and existing rows keep NULL, which reads as off).
  5169. await _safe_execute(conn, "ALTER TABLE notification_providers ADD COLUMN attach_photo BOOLEAN DEFAULT TRUE")
  5170. async with conn.begin_nested():
  5171. await conn.execute(
  5172. text("UPDATE notification_providers SET attach_photo = :on WHERE attach_photo IS NULL"), {"on": True}
  5173. )
  5174. # Migration: the filament id a slot preset was written with, so the slot
  5175. # card can tell when something else re-configured the slot (#3216).
  5176. # Nullable: existing rows have none and keep being shown as before.
  5177. await _safe_execute(conn, "ALTER TABLE slot_preset_mappings ADD COLUMN tray_info_idx VARCHAR(32)")
  5178. # Migration: printers picked for the chamber light while the camera is in
  5179. # use (#1655), for camera_light_mode "selected". Off by default, so no
  5180. # printer's light changes on upgrade. The backfill covers a table
  5181. # create_all() already gave the column (the ALTER is then swallowed and
  5182. # existing rows keep NULL).
  5183. await _safe_execute(conn, "ALTER TABLE printers ADD COLUMN camera_light_auto BOOLEAN DEFAULT FALSE")
  5184. async with conn.begin_nested():
  5185. await conn.execute(
  5186. text("UPDATE printers SET camera_light_auto = :off WHERE camera_light_auto IS NULL"), {"off": False}
  5187. )
  5188. # Migration: printer-scoped groups (#1727). Defaults off, so no existing
  5189. # group narrows anyone's printers on upgrade; the group_printers table
  5190. # itself comes from create_all(). The backfill covers a table create_all()
  5191. # already gave the column, where the ALTER is swallowed as a duplicate.
  5192. await _safe_execute(conn, "ALTER TABLE groups ADD COLUMN restrict_printers BOOLEAN DEFAULT FALSE")
  5193. async with conn.begin_nested():
  5194. await conn.execute(
  5195. text("UPDATE groups SET restrict_printers = :off WHERE restrict_printers IS NULL"), {"off": False}
  5196. )
  5197. # Migration: camera-stream and websocket tokens record who minted them, so
  5198. # they carry that caller's printer scope (#1727). Camera tokens minted
  5199. # before this have no principal at all (username NULL; every new one sets
  5200. # it, "" for API keys and auth-off), and would now resolve to no printers.
  5201. # They live 60 minutes; dropping them sends the browser to mint a new one.
  5202. await _safe_execute(conn, "ALTER TABLE auth_ephemeral_tokens ADD COLUMN api_key_id INTEGER")
  5203. async with conn.begin_nested():
  5204. await conn.execute(
  5205. text("DELETE FROM auth_ephemeral_tokens WHERE token_type = :t AND username IS NULL"),
  5206. {"t": "camera_stream"},
  5207. )
  5208. # Migration: electricity price on each energy snapshot, and where and when a
  5209. # print's starting counter was read (#1251), so energy is costed at the
  5210. # price of the hour it was used rather than at today's price.
  5211. if is_sqlite():
  5212. await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN price_per_kwh REAL")
  5213. await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN kwh_to_date REAL")
  5214. await _safe_execute(conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN cost_to_date REAL")
  5215. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_price REAL")
  5216. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_at DATETIME")
  5217. else:
  5218. await _safe_execute(
  5219. conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS price_per_kwh DOUBLE PRECISION"
  5220. )
  5221. await _safe_execute(
  5222. conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS kwh_to_date DOUBLE PRECISION"
  5223. )
  5224. await _safe_execute(
  5225. conn, "ALTER TABLE smart_plug_energy_snapshots ADD COLUMN IF NOT EXISTS cost_to_date DOUBLE PRECISION"
  5226. )
  5227. await _safe_execute(
  5228. conn, "ALTER TABLE print_archives ADD COLUMN IF NOT EXISTS energy_start_price DOUBLE PRECISION"
  5229. )
  5230. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN IF NOT EXISTS energy_start_at TIMESTAMP")
  5231. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN energy_start_plug_id INTEGER")
  5232. await _backfill_snapshot_prices(conn)
  5233. # Migration: library folder ownership and sharing (#3201).
  5234. await _safe_execute(
  5235. conn, "ALTER TABLE library_folders ADD COLUMN created_by_id INTEGER REFERENCES users(id) ON DELETE SET NULL"
  5236. )
  5237. await _safe_execute(conn, "ALTER TABLE library_folders ADD COLUMN shared BOOLEAN DEFAULT FALSE")
  5238. await _backfill_library_folder_owners(conn)
  5239. # Migration: printer wear cost per printing hour, and the wear cost of each
  5240. # run and archive (#694). Nullable: no printer has a rate until one is set,
  5241. # and earlier prints keep no wear cost.
  5242. float_type = "REAL" if is_sqlite() else "DOUBLE PRECISION"
  5243. await _safe_execute(conn, f"ALTER TABLE printers ADD COLUMN wear_cost_per_hour {float_type}")
  5244. await _safe_execute(conn, f"ALTER TABLE print_log_entries ADD COLUMN wear_cost {float_type}")
  5245. await _safe_execute(conn, f"ALTER TABLE print_archives ADD COLUMN wear_cost {float_type}")
  5246. # Migration: when each spool was last dried, at what temperature and for
  5247. # how long (#2863). Nullable: no spool has a drying record until one ends.
  5248. datetime_type = "DATETIME" if is_sqlite() else "TIMESTAMP"
  5249. await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_at {datetime_type}")
  5250. await _safe_execute(conn, "ALTER TABLE spool ADD COLUMN last_dried_temp INTEGER")
  5251. await _safe_execute(conn, f"ALTER TABLE spool ADD COLUMN last_dried_hours {float_type}")
  5252. # Migration: which plate's run the attached timelapse came from, so starting
  5253. # another plate of a shared Send All archive keeps it (#3275). Nullable, no
  5254. # default -- identical DDL on SQLite and Postgres.
  5255. await _safe_execute(conn, "ALTER TABLE print_archives ADD COLUMN timelapse_plate_id INTEGER")
  5256. await _migrate_auth_datetimes_to_timestamp(conn)
  5257. await _migrate_add_missing_set_null_fks(conn)
  5258. await _migrate_wallet_transactions_keep_deleted_users(conn)
  5259. await _migrate_notification_providers_go_with_their_printer(conn)
  5260. await _migrate_spool_usage_outlives_its_archive(conn)
  5261. await _migrate_spool_usage_outlives_its_spool(conn)
  5262. await _backfill_pipeline_job_final_status(conn)
  5263. await _release_stuck_budget_reservations(conn)
  5264. await _release_slots_of_archived_spools(conn)
  5265. await _migrate_pg_column_types(conn)
  5266. await _migrate_pg_columns_to_model(conn)
  5267. async def _release_slots_of_archived_spools(conn) -> None:
  5268. """An archived spool gives its AMS slots up: archiving does so now, and the
  5269. slots still held by one archived earlier are released here. Idempotent."""
  5270. from sqlalchemy import text
  5271. async with conn.begin_nested():
  5272. released = await conn.execute(
  5273. text("DELETE FROM spool_assignment WHERE spool_id IN (SELECT id FROM spool WHERE archived_at IS NOT NULL)")
  5274. )
  5275. if released.rowcount:
  5276. logger.info("Released %d AMS slot(s) still assigned to archived spools", released.rowcount)
  5277. # Columns that migrations added nullable or with a server default the model
  5278. # didn't have, so on a PostgreSQL database older than them they differ from a
  5279. # fresh install. Default and nullability are taken from the model itself.
  5280. _PG_COLUMNS_SET_FROM_MODEL = (
  5281. ("api_keys", "can_access_cloud"),
  5282. ("api_keys", "can_manage_archives"),
  5283. ("api_keys", "can_manage_inventory"),
  5284. ("api_keys", "can_manage_library"),
  5285. ("api_keys", "can_manage_maintenance"),
  5286. ("api_keys", "can_manage_projects"),
  5287. ("api_keys", "can_send_notifications"),
  5288. ("api_keys", "can_update_energy_cost"),
  5289. ("github_backup_config", "allow_insecure_http"),
  5290. ("github_backup_config", "provider"),
  5291. ("groups", "restrict_printers"),
  5292. ("library_files", "variant_position"),
  5293. ("library_folders", "shared"),
  5294. ("notification_providers", "attach_photo"),
  5295. ("notification_providers", "on_ai_failure_detection"),
  5296. ("notification_providers", "on_ams_drying_suspended"),
  5297. ("notification_providers", "on_app_message"),
  5298. ("notification_providers", "on_billing_charge_failed"),
  5299. ("notification_providers", "on_ha_sensor_alert"),
  5300. ("notification_providers", "on_location_ha_sensor_alert"),
  5301. ("notification_providers", "on_plate_clear_required"),
  5302. ("notification_providers", "on_print_confirm_request"),
  5303. ("notification_providers", "on_stock_break_alert"),
  5304. ("notification_providers", "on_stock_reorder_alert"),
  5305. ("notification_providers", "telegram_verdict_mode"),
  5306. ("oidc_providers", "require_email_verified"),
  5307. ("oidc_providers", "email_claim"),
  5308. ("oidc_providers", "is_autologin"),
  5309. ("oidc_providers", "is_env_managed"),
  5310. ("print_archives", "bambuddy_forced_timelapse"),
  5311. ("print_archives", "wallet_charge_skipped"),
  5312. ("print_queue", "bed_levelling"),
  5313. ("print_queue", "cleanup_library_after_dispatch"),
  5314. ("print_queue", "confirm_outcome"),
  5315. ("print_queue", "dispatch_attempts"),
  5316. ("print_queue", "filament_short"),
  5317. ("print_queue", "flow_cali"),
  5318. ("print_queue", "gate_acknowledged"),
  5319. ("print_queue", "nozzle_offset_cali"),
  5320. ("print_queue", "preheat_override"),
  5321. ("print_queue", "skip_filament_check"),
  5322. ("printers", "camera_light_auto"),
  5323. ("smart_plugs", "auto_off_after_drying"),
  5324. ("smart_plugs", "controls_printer_power"),
  5325. ("smart_plugs", "off_delay_after_drying_minutes"),
  5326. ("smart_plugs", "rest_energy_total_multiplier"),
  5327. ("spool", "weight_used_baseline"),
  5328. ("virtual_printers", "gcode_injection"),
  5329. ("virtual_printers", "queue_force_color_match"),
  5330. ("virtual_printers", "save_ams_mapping"),
  5331. ("virtual_printers", "tailscale_disabled"),
  5332. )
  5333. async def _migrate_pg_columns_to_model(conn) -> None:
  5334. """Give the columns in _PG_COLUMNS_SET_FROM_MODEL the model's default and NOT NULL.
  5335. PostgreSQL only; SQLite can't change either, and its ADD COLUMN ... DEFAULT
  5336. filled every existing row. The default is set every start (a catalog
  5337. change, no rewrite); NOT NULL only while the column is still nullable, after
  5338. filling its NULLs with the model's default. Two exceptions keep what the
  5339. code made of a NULL rather than granting more:
  5340. - an API key's permissions are filled with false: the code reads NULL as
  5341. denied, and several default to true;
  5342. - an OIDC provider whose email_claim is filled gets auto-linking off. With
  5343. no email claim it never resolved an email, so it never linked an
  5344. account; with 'email' it would start to (and with require_email_verified
  5345. false, in the combination the CHECK constraint forbids).
  5346. """
  5347. from sqlalchemy import text
  5348. if is_sqlite():
  5349. return
  5350. compiler = conn.dialect.ddl_compiler(conn.dialect, None)
  5351. tables = load_all_models().tables
  5352. for table, column in _PG_COLUMNS_SET_FROM_MODEL:
  5353. model_column = tables[table].c[column]
  5354. default = compiler.get_column_default_string(model_column)
  5355. await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} SET DEFAULT {default}")
  5356. if model_column.nullable:
  5357. continue
  5358. nullable = (
  5359. await conn.execute(
  5360. text(
  5361. "SELECT is_nullable FROM information_schema.columns "
  5362. "WHERE table_schema = current_schema() AND table_name = :t AND column_name = :c"
  5363. ),
  5364. {"t": table, "c": column},
  5365. )
  5366. ).scalar_one()
  5367. if nullable != "YES":
  5368. continue
  5369. fill = "false" if table == "api_keys" else default
  5370. extra = ""
  5371. if (table, column) == ("oidc_providers", "email_claim"):
  5372. extra = ", auto_link_existing_accounts = false"
  5373. filled = await conn.execute(text(f"UPDATE {table} SET {column} = {fill}{extra} WHERE {column} IS NULL"))
  5374. if filled.rowcount:
  5375. logger.info("Filled %d NULL %s.%s value(s) with %s", filled.rowcount, table, column, fill)
  5376. await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} SET NOT NULL")
  5377. # Columns a migration added with another type than the model declares, so a
  5378. # PostgreSQL database older than the column has the wrong one:
  5379. # (table, column, the type it has there, the model's type, USING expression).
  5380. # REAL goes through text, which PostgreSQL renders as the shortest decimal that
  5381. # reads back as the same float4: 0.001 stays 0.001 instead of becoming
  5382. # 0.0010000000474974513 as a double.
  5383. _PG_COLUMN_TYPE_FIXES = (
  5384. ("active_print_spoolman", "tray_remain_start", "text", "JSON", "CAST(tray_remain_start AS JSON)"),
  5385. (
  5386. "spool",
  5387. "weight_used_baseline",
  5388. "real",
  5389. "DOUBLE PRECISION",
  5390. "CAST(CAST(weight_used_baseline AS TEXT) AS DOUBLE PRECISION)",
  5391. ),
  5392. (
  5393. "smart_plugs",
  5394. "rest_energy_total_multiplier",
  5395. "real",
  5396. "DOUBLE PRECISION",
  5397. "CAST(CAST(rest_energy_total_multiplier AS TEXT) AS DOUBLE PRECISION)",
  5398. ),
  5399. )
  5400. def _reject_non_json_constant(name: str):
  5401. """json.loads accepts NaN and Infinity; PostgreSQL's json type doesn't."""
  5402. raise ValueError(f"{name} is not JSON")
  5403. async def _migrate_pg_column_types(conn) -> None:
  5404. """Convert the columns in _PG_COLUMN_TYPE_FIXES to the model's type.
  5405. PostgreSQL only: on SQLite REAL is already a double, and a JSON column is
  5406. text that SQLAlchemy decodes. Only a column that still has the old type is
  5407. converted. On PostgreSQL a TEXT column declared JSON in the model reads
  5408. back as a string, not a dict.
  5409. A JSON value that doesn't parse would fail the conversion, and with it
  5410. every start, so such values are cleared first. tray_remain_start only
  5411. holds the start snapshot of prints running at the time.
  5412. """
  5413. import json
  5414. from sqlalchemy import text
  5415. if is_sqlite():
  5416. return
  5417. for table, column, old_type, new_type, using in _PG_COLUMN_TYPE_FIXES:
  5418. current = (
  5419. await conn.execute(
  5420. text(
  5421. "SELECT data_type FROM information_schema.columns "
  5422. "WHERE table_schema = current_schema() AND table_name = :t AND column_name = :c"
  5423. ),
  5424. {"t": table, "c": column},
  5425. )
  5426. ).scalar_one_or_none()
  5427. if current != old_type:
  5428. continue
  5429. if new_type == "JSON":
  5430. rows = await conn.execute(text(f"SELECT id, {column} FROM {table} WHERE {column} IS NOT NULL"))
  5431. for row_id, value in rows.all():
  5432. try:
  5433. json.loads(value, parse_constant=_reject_non_json_constant)
  5434. except ValueError:
  5435. logger.warning("Clearing %s.%s of row %s: not valid JSON", table, column, row_id)
  5436. await conn.execute(text(f"UPDATE {table} SET {column} = NULL WHERE id = :id"), {"id": row_id})
  5437. # Float to text gives the shortest exact decimal only with
  5438. # extra_float_digits >= 1, PostgreSQL's default since 12, which a server,
  5439. # database or role setting can lower: at 0 text keeps 6 digits.
  5440. digits = (await conn.execute(text("SHOW extra_float_digits"))).scalar_one()
  5441. await conn.execute(text("SELECT set_config('extra_float_digits', '1', true)"))
  5442. try:
  5443. await _safe_execute(conn, f"ALTER TABLE {table} ALTER COLUMN {column} TYPE {new_type} USING {using}")
  5444. finally:
  5445. await conn.execute(text("SELECT set_config('extra_float_digits', :d, true)"), {"d": digits})
  5446. async def _migrate_wallet_transactions_keep_deleted_users(conn) -> None:
  5447. """wallet_transactions.user_id: nullable, ON DELETE SET NULL, as the model says.
  5448. It was NOT NULL with ON DELETE CASCADE, so deleting a user deleted their
  5449. ledger entries and freed what they had spent of a shared cost centre's
  5450. budget. SQLite can't alter a column, so the table's SQL in sqlite_master is
  5451. edited, as for users.password_hash; its foreign keys are off, but the NOT
  5452. NULL would make the user delete fail on the SET NULL. PostgreSQL drops the
  5453. NOT NULL and replaces the CASCADE constraint.
  5454. """
  5455. from sqlalchemy import text
  5456. if is_sqlite():
  5457. select_sql = text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'wallet_transactions'")
  5458. sql = (await conn.execute(select_sql)).scalar()
  5459. if not isinstance(sql, str):
  5460. return
  5461. # created_by_user_id has no word boundary before "user_id", so stays as it is.
  5462. new_sql = re.sub(r"(\buser_id\s+INTEGER)\s+NOT\s+NULL", r"\1", sql)
  5463. new_sql = re.sub(
  5464. r"(\buser_id\s*\)?\s+(?:INTEGER\s+)?REFERENCES\s+users\s*\(\s*id\s*\)\s+ON\s+DELETE)\s+CASCADE",
  5465. r"\1 SET NULL",
  5466. new_sql,
  5467. )
  5468. if new_sql == sql:
  5469. return
  5470. try:
  5471. schema_version = (await conn.execute(text("PRAGMA schema_version"))).scalar() or 0
  5472. await conn.execute(text("PRAGMA writable_schema = ON"))
  5473. await conn.execute(
  5474. text("UPDATE sqlite_master SET sql = :sql WHERE type = 'table' AND name = 'wallet_transactions'"),
  5475. {"sql": new_sql},
  5476. )
  5477. # Makes this connection reload the table definition.
  5478. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  5479. await conn.execute(text("PRAGMA writable_schema = OFF"))
  5480. except (OperationalError, ProgrammingError) as exc:
  5481. logger.error(
  5482. "Could not make wallet_transactions.user_id nullable; deleting a user with wallet "
  5483. "transactions will fail on this install: %s",
  5484. exc,
  5485. exc_info=True,
  5486. )
  5487. return
  5488. await _safe_execute(conn, "ALTER TABLE wallet_transactions ALTER COLUMN user_id DROP NOT NULL")
  5489. await _pg_set_fk_ondelete(conn, "wallet_transactions", "user_id", "users", "SET NULL")
  5490. async def _migrate_notification_providers_go_with_their_printer(conn) -> None:
  5491. """notification_providers / smart_plugs.printer_id: ON DELETE CASCADE, as the models say (PostgreSQL).
  5492. It was SET NULL, and a provider without a printer is one for all printers:
  5493. deleting the printer a provider was limited to sent it every printer's
  5494. events. SQLite doesn't enforce the rule; the printer delete route removes
  5495. the providers on both.
  5496. """
  5497. if not is_sqlite():
  5498. await _pg_set_fk_ondelete(conn, "notification_providers", "printer_id", "printers", "CASCADE")
  5499. # Likewise a smart plug: without a printer it is everyone's to switch.
  5500. await _pg_set_fk_ondelete(conn, "smart_plugs", "printer_id", "printers", "CASCADE")
  5501. async def _migrate_spool_usage_outlives_its_archive(conn) -> None:
  5502. """spool_usage_history.archive_id: ON DELETE SET NULL, as the model says (PostgreSQL).
  5503. The key had no rule, so deleting an archive a spool was used for (hard
  5504. delete, or a printer delete with its archives) failed on the key. SQLite
  5505. doesn't enforce it; core/sqlite_fk applies the same rule there.
  5506. """
  5507. if not is_sqlite():
  5508. await _pg_set_fk_ondelete(conn, "spool_usage_history", "archive_id", "print_archives", "SET NULL")
  5509. async def _migrate_spool_usage_outlives_its_spool(conn) -> None:
  5510. """spool_usage_history.spool_id: nullable, ON DELETE SET NULL, as the model says.
  5511. Deleting a spool deleted its usage on PostgreSQL (the costs its prints were
  5512. recalculated from) and left it on SQLite pointing at an id the next spool
  5513. could get. SQLite enforces neither rule; core/sqlite_fk applies SET NULL,
  5514. which needs the column nullable.
  5515. """
  5516. from sqlalchemy import text
  5517. if is_sqlite():
  5518. select_sql = text("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'spool_usage_history'")
  5519. sql = (await conn.execute(select_sql)).scalar()
  5520. if not isinstance(sql, str):
  5521. return
  5522. new_sql = re.sub(r"(\bspool_id\s+INTEGER)\s+NOT\s+NULL", r"\1", sql)
  5523. new_sql = re.sub(
  5524. r"(\bspool_id\s*\)?\s+(?:INTEGER\s+)?REFERENCES\s+spool\s*\(\s*id\s*\)\s+ON\s+DELETE)\s+CASCADE",
  5525. r"\1 SET NULL",
  5526. new_sql,
  5527. )
  5528. if new_sql == sql:
  5529. return
  5530. try:
  5531. schema_version = (await conn.execute(text("PRAGMA schema_version"))).scalar() or 0
  5532. await conn.execute(text("PRAGMA writable_schema = ON"))
  5533. await conn.execute(
  5534. text("UPDATE sqlite_master SET sql = :sql WHERE type = 'table' AND name = 'spool_usage_history'"),
  5535. {"sql": new_sql},
  5536. )
  5537. # Makes this connection reload the table definition.
  5538. await conn.execute(text(f"PRAGMA schema_version = {schema_version + 1}"))
  5539. await conn.execute(text("PRAGMA writable_schema = OFF"))
  5540. except (OperationalError, ProgrammingError) as exc:
  5541. logger.error(
  5542. "Could not make spool_usage_history.spool_id nullable; usage of deleted spools keeps "
  5543. "pointing at their old id on this install: %s",
  5544. exc,
  5545. exc_info=True,
  5546. )
  5547. return
  5548. await _safe_execute(conn, "ALTER TABLE spool_usage_history ALTER COLUMN spool_id DROP NOT NULL")
  5549. await _pg_set_fk_ondelete(conn, "spool_usage_history", "spool_id", "spool", "SET NULL")
  5550. async def _backfill_pipeline_job_final_status(conn) -> None:
  5551. """Give dispatched pipeline jobs the final status of their queue entry.
  5552. Jobs store it when the entry ends (services/pipeline_job_status); entries
  5553. that ended before that keep it only on the entry, which can be deleted
  5554. later. Not gated: it only matches jobs not yet ended whose entry has, so
  5555. a second run changes nothing. Jobs whose entry is already gone stay
  5556. unknown.
  5557. """
  5558. from sqlalchemy import text
  5559. async with conn.begin_nested():
  5560. result = await conn.execute(
  5561. text(
  5562. "UPDATE pipeline_jobs SET status = CASE "
  5563. "(SELECT q.status FROM print_queue q WHERE q.id = pipeline_jobs.queue_entry_id) "
  5564. "WHEN 'completed' THEN 'completed' WHEN 'cancelled' THEN 'cancelled' ELSE 'failed' END "
  5565. "WHERE status NOT IN ('completed', 'failed', 'cancelled') "
  5566. "AND queue_entry_id IN (SELECT id FROM print_queue "
  5567. "WHERE status IN ('completed', 'failed', 'aborted', 'cancelled'))"
  5568. )
  5569. )
  5570. if result.rowcount:
  5571. logger.info("Stored the final status of %d pipeline job(s) from their queue entry", result.rowcount)
  5572. async def _release_stuck_budget_reservations(conn) -> None:
  5573. """Release print reservations whose queue entry has finished or is gone.
  5574. Billing uses up or releases a print's reservation when the print ends.
  5575. Before a print without a user (authentication off, or its user deleted
  5576. while it printed) was billed to its cost centre, billing stopped before
  5577. either, and the reservation held that much of the budget for good. Such a
  5578. reservation can no longer be charged: the print was billed or never will
  5579. be. Only the reservation changes; the ledger does not. Not gated: it only
  5580. matches reservations nothing can use, so a second run changes nothing.
  5581. A skipped entry can return to pending, so it keeps its reservation.
  5582. """
  5583. from sqlalchemy import text
  5584. from backend.app.utils.local_time import utcnow_naive
  5585. async with conn.begin_nested():
  5586. result = await conn.execute(
  5587. text(
  5588. "UPDATE budget_reservations SET status = 'released', released_at = :now "
  5589. "WHERE status = 'active' AND source_type = 'print_queue' "
  5590. "AND NOT EXISTS (SELECT 1 FROM print_queue q WHERE q.id = budget_reservations.source_id "
  5591. "AND q.status NOT IN ('completed', 'failed', 'cancelled', 'aborted'))"
  5592. ),
  5593. {"now": utcnow_naive()},
  5594. )
  5595. if result.rowcount:
  5596. logger.info("Released %d budget reservation(s) of prints that finished or were removed", result.rowcount)
  5597. _PG_ON_DELETE_CODES = {"CASCADE": "c", "SET NULL": "n"}
  5598. async def _pg_set_fk_ondelete(conn, table: str, column: str, parent: str, ondelete: str) -> None:
  5599. """Give the single-column foreign key from *table*.*column* to *parent* the ON DELETE *ondelete*.
  5600. The constraint is replaced under its own name, and only where its rule differs.
  5601. """
  5602. from sqlalchemy import text
  5603. differing = await conn.execute(
  5604. text(
  5605. "SELECT c.conname FROM pg_constraint c "
  5606. "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) "
  5607. "WHERE c.contype = 'f' AND c.conrelid = CAST(:t AS regclass) "
  5608. "AND c.confrelid = CAST(:p AS regclass) AND array_length(c.conkey, 1) = 1 "
  5609. "AND a.attname = :c AND CAST(c.confdeltype AS text) <> :code"
  5610. ),
  5611. {"t": table, "p": parent, "c": column, "code": _PG_ON_DELETE_CODES[ondelete]},
  5612. )
  5613. for name in differing.scalars().all():
  5614. await conn.execute(text(f'ALTER TABLE {table} DROP CONSTRAINT "{name}"'))
  5615. await conn.execute(
  5616. text(
  5617. f'ALTER TABLE {table} ADD CONSTRAINT "{name}" '
  5618. f"FOREIGN KEY ({column}) REFERENCES {parent}(id) ON DELETE {ondelete}"
  5619. )
  5620. )
  5621. # Foreign keys that the migrations adding these columns left out, so a
  5622. # PostgreSQL database older than the column has none: (table, column, parent).
  5623. # All ON DELETE SET NULL, as the models declare them.
  5624. _MISSING_SET_NULL_FKS = (
  5625. ("print_log_entries", "archive_id", "print_archives"),
  5626. ("print_log_entries", "created_by_id", "users"),
  5627. ("print_archives", "library_file_id", "library_files"),
  5628. )
  5629. async def _migrate_add_missing_set_null_fks(conn) -> None:
  5630. """Add the foreign keys in _MISSING_SET_NULL_FKS where they are missing.
  5631. PostgreSQL only; SQLite can't add a constraint to a table and doesn't
  5632. enforce foreign keys (PRAGMA foreign_keys is off; init_db sweeps orphans
  5633. there instead, core/sqlite_fk). A column that already has a foreign key to
  5634. the parent table, whatever its name, is left alone. Ids left behind by
  5635. deletes without the constraint are cleared first, which is what ON DELETE
  5636. SET NULL would have done, and without which the constraint can't be added.
  5637. """
  5638. from sqlalchemy import text
  5639. if is_sqlite():
  5640. return
  5641. for table, column, parent in _MISSING_SET_NULL_FKS:
  5642. result = await conn.execute(
  5643. text(
  5644. "SELECT 1 FROM pg_constraint c "
  5645. "JOIN pg_attribute a ON a.attrelid = c.conrelid AND a.attnum = ANY (c.conkey) "
  5646. "WHERE c.contype = 'f' AND c.conrelid = CAST(:t AS regclass) "
  5647. "AND c.confrelid = CAST(:p AS regclass) "
  5648. "AND array_length(c.conkey, 1) = 1 AND a.attname = :c"
  5649. ),
  5650. {"t": table, "p": parent, "c": column},
  5651. )
  5652. if result.first():
  5653. continue
  5654. cleared = await conn.execute(
  5655. text(
  5656. f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL "
  5657. f"AND NOT EXISTS (SELECT 1 FROM {parent} WHERE {parent}.id = {table}.{column})"
  5658. )
  5659. )
  5660. if cleared.rowcount:
  5661. logger.info("Cleared %d %s.%s id(s) of deleted %s rows", cleared.rowcount, table, column, parent)
  5662. await _safe_execute(
  5663. conn,
  5664. f"ALTER TABLE {table} ADD CONSTRAINT {table}_{column}_fkey "
  5665. f"FOREIGN KEY ({column}) REFERENCES {parent}(id) ON DELETE SET NULL",
  5666. )
  5667. # Columns that used to be declared DateTime(timezone=True). Every bound datetime
  5668. # reaches asyncpg naive (see _strip_tz_from_params), and asyncpg reads a naive
  5669. # value for a timestamptz bind as process-local time, so on a host with TZ set
  5670. # these were stored shifted by its UTC offset. They are plain DateTime now, like
  5671. # every other column; this converts the PostgreSQL columns to match.
  5672. _AUTH_DATETIME_COLUMNS = (
  5673. ("users", "password_changed_at"),
  5674. ("auth_ephemeral_tokens", "expires_at"),
  5675. ("auth_ephemeral_tokens", "created_at"),
  5676. ("auth_rate_limit_events", "occurred_at"),
  5677. )
  5678. async def _migrate_auth_datetimes_to_timestamp(conn) -> None:
  5679. """Convert the auth datetime columns from timestamptz to TIMESTAMP (UTC).
  5680. PostgreSQL only; SQLite has no time zone type. Only a column that is still
  5681. timestamptz is converted: the same USING on a TIMESTAMP column would shift
  5682. it by the session time zone.
  5683. The values already stored keep their offset error (the host's offset at
  5684. write time is not recorded), and that includes password_changed_at on
  5685. installs where the column is already TIMESTAMP: the bind followed the
  5686. model's type, not the column's. So when anything was converted, which
  5687. happens exactly once on an install that ran the old models, every session
  5688. is invalidated (see invalidate_all_sessions).
  5689. """
  5690. from sqlalchemy import text
  5691. if is_sqlite():
  5692. return
  5693. converted = False
  5694. for table, column in _AUTH_DATETIME_COLUMNS:
  5695. result = await conn.execute(
  5696. text(
  5697. "SELECT data_type FROM information_schema.columns "
  5698. "WHERE table_schema = current_schema() AND table_name = :t AND column_name = :c"
  5699. ),
  5700. {"t": table, "c": column},
  5701. )
  5702. row = result.fetchone()
  5703. if row and row[0] == "timestamp with time zone":
  5704. await _safe_execute(
  5705. conn,
  5706. f"ALTER TABLE {table} ALTER COLUMN {column} TYPE TIMESTAMP USING ({column} AT TIME ZONE 'UTC')",
  5707. )
  5708. converted = True
  5709. if converted:
  5710. await invalidate_all_sessions(conn)
  5711. async def invalidate_all_sessions(conn) -> None:
  5712. """Make every user sign in again and drop every short-lived auth record.
  5713. Sets password_changed_at to now for all users, so every JWT issued before
  5714. is rejected, and deletes the pending auth tokens (reset links, 2FA and OIDC
  5715. state, media/camera/websocket tokens, slicer downloads, connect codes) and
  5716. the rate-limit events, so their counters start from zero. The logout
  5717. blocklist (revoked_jti) is kept. Used once after the auth
  5718. datetime conversion and after every backup restore, where the restored
  5719. rows may carry values written by older code.
  5720. """
  5721. from sqlalchemy import text
  5722. from backend.app.utils.local_time import utcnow_naive
  5723. async with conn.begin_nested():
  5724. await conn.execute(text("UPDATE users SET password_changed_at = :now"), {"now": utcnow_naive()})
  5725. await conn.execute(text("DELETE FROM auth_ephemeral_tokens WHERE token_type <> 'revoked_jti'"))
  5726. await conn.execute(text("DELETE FROM auth_rate_limit_events"))
  5727. async def _backfill_snapshot_prices(conn) -> None:
  5728. """Give the energy snapshots taken before #1251 the price set at upgrade.
  5729. Until now every kWh in the Statistics was costed at the price set right
  5730. now, so changing it re-costed all of history. Writing today's price onto
  5731. the existing rows keeps those figures where they stand, and from here on a
  5732. new price applies only to the energy used after it was set.
  5733. The running totals start from the raw counter: at one price throughout,
  5734. the energy to date is the counter and its cost the counter times the price.
  5735. A reset in that old history then shows as a drop, which the Statistics
  5736. treat as nothing, exactly as they already did for the energy figure. One
  5737. UPDATE on both databases, however long the history is.
  5738. Gated to run once: snapshots taken later always carry a price, and a NULL
  5739. one after this has run would mean something else, not "before #1251".
  5740. """
  5741. from sqlalchemy import text
  5742. flag = "_backfill_1251_snapshot_prices_done"
  5743. async with conn.begin_nested():
  5744. already = (
  5745. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  5746. ).scalar_one_or_none()
  5747. if already:
  5748. return
  5749. raw = (
  5750. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": "energy_cost_per_kwh"})
  5751. ).scalar_one_or_none()
  5752. try:
  5753. price = float(raw) if raw else 0.15
  5754. except (TypeError, ValueError):
  5755. price = 0.15
  5756. await conn.execute(
  5757. text(
  5758. "UPDATE smart_plug_energy_snapshots "
  5759. "SET price_per_kwh = :p, kwh_to_date = lifetime_kwh, cost_to_date = lifetime_kwh * :p "
  5760. "WHERE price_per_kwh IS NULL"
  5761. ),
  5762. {"p": price},
  5763. )
  5764. await conn.execute(
  5765. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  5766. {"k": flag, "v": "true"},
  5767. )
  5768. async def _backfill_library_folder_owners(conn) -> None:
  5769. """Give the folders made before #3201 an owner, or share them.
  5770. Folders had no owner, and every library:read_own user saw all of them. A
  5771. folder whose files, all the way down and trashed ones included, belong to
  5772. one user becomes that user's own folder, hidden from the others. An empty
  5773. folder inside such a folder goes with it, so it doesn't leave a shared
  5774. hole in someone's private tree. Every other folder (empty, several
  5775. uploaders, files without an owner, external, or linked to a project or
  5776. archive) is shared, so it stays visible to everyone as before. So are the
  5777. top-level folders MakerWorld and Manyfold imports land in, which are
  5778. every importer's destination, not one user's folder.
  5779. Gated to run once: after it, a folder without an owner and not shared is
  5780. one an admin chose to make that way.
  5781. """
  5782. from sqlalchemy import text
  5783. flag = "_backfill_3201_folder_owners_done"
  5784. async with conn.begin_nested():
  5785. already = (
  5786. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  5787. ).scalar_one_or_none()
  5788. if already:
  5789. return
  5790. folders = (
  5791. await conn.execute(
  5792. text("SELECT id, parent_id, is_external, project_id, archive_id, name FROM library_folders")
  5793. )
  5794. ).all()
  5795. children: dict[int | None, list[int]] = {}
  5796. for fid, parent_id, *_ in folders:
  5797. children.setdefault(parent_id, []).append(fid)
  5798. own_owners: dict[int, set] = {}
  5799. for folder_id, owner_id in (
  5800. await conn.execute(
  5801. text("SELECT DISTINCT folder_id, created_by_id FROM library_files WHERE folder_id IS NOT NULL")
  5802. )
  5803. ).all():
  5804. own_owners.setdefault(folder_id, set()).add(owner_id)
  5805. # Owners of every file in each subtree, deepest folders first.
  5806. subtree_owners: dict[int, set] = {}
  5807. def owners_of(root: int) -> set:
  5808. # ``seen`` guards against a parent_id loop: startup must never hang.
  5809. order: list[int] = []
  5810. seen: set[int] = set()
  5811. stack = [root]
  5812. while stack:
  5813. fid = stack.pop()
  5814. if fid in seen:
  5815. continue
  5816. seen.add(fid)
  5817. order.append(fid)
  5818. stack.extend(children.get(fid, []))
  5819. for fid in reversed(order):
  5820. if fid not in subtree_owners:
  5821. acc = set(own_owners.get(fid, set()))
  5822. for child in children.get(fid, []):
  5823. acc |= subtree_owners.get(child, set())
  5824. subtree_owners[fid] = acc
  5825. return subtree_owners[root]
  5826. info = {
  5827. fid: (parent_id, bool(is_ext), project_id, archive_id, name)
  5828. for fid, parent_id, is_ext, project_id, archive_id, name in folders
  5829. }
  5830. # services/model_providers/*/provider.py ``default_folder_name``.
  5831. import_folders = {"MakerWorld", "Manyfold"}
  5832. result: dict[int, int | None] = {}
  5833. # Top down, so an empty folder can follow its parent's owner.
  5834. stack = list(children.get(None, []))
  5835. # Folders whose parent is missing (shouldn't happen with the FK) are roots too.
  5836. stack.extend(
  5837. fid for fid, (parent_id, *_rest) in info.items() if parent_id is not None and parent_id not in info
  5838. )
  5839. while stack:
  5840. fid = stack.pop()
  5841. if fid in result:
  5842. continue
  5843. parent_id, is_ext, project_id, archive_id, name = info[fid]
  5844. owners = owners_of(fid)
  5845. owner = None
  5846. is_import_folder = parent_id is None and name in import_folders
  5847. if not is_ext and project_id is None and archive_id is None and not is_import_folder:
  5848. if len(owners) == 1 and None not in owners:
  5849. owner = next(iter(owners))
  5850. elif not owners and parent_id is not None:
  5851. owner = result.get(parent_id)
  5852. result[fid] = owner
  5853. stack.extend(children.get(fid, []))
  5854. for fid in info:
  5855. result.setdefault(fid, None)
  5856. owned = [{"id": fid, "o": owner} for fid, owner in result.items() if owner is not None]
  5857. if owned:
  5858. await conn.execute(
  5859. text("UPDATE library_folders SET created_by_id = :o, shared = FALSE WHERE id = :id"), owned
  5860. )
  5861. shared = [{"id": fid} for fid, owner in result.items() if owner is None]
  5862. if shared:
  5863. await conn.execute(text("UPDATE library_folders SET shared = TRUE WHERE id = :id"), shared)
  5864. await conn.execute(
  5865. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  5866. {"k": flag, "v": "true"},
  5867. )
  5868. async def _migrate_confirm_prompt_body_template(conn) -> None:
  5869. """Replace the one-tap verdict URLs in the outcome prompt's body (#1898).
  5870. The first shape of this template put ``{good_url}`` and ``{reject_url}``
  5871. into the message body, where a link unfurler, a mail gateway or a proxy
  5872. reaches them and spends the single-use token before the operator has read
  5873. the question. The body now carries ``{confirm_url}``, which only opens the
  5874. archive in Bambuddy; the capability links travel in the ntfy action buttons
  5875. and the Telegram inline keyboard instead.
  5876. Rewrites only a body that is still the old default verbatim — an admin who
  5877. edited the template keeps their own text. Same shape as the two template
  5878. renames below.
  5879. """
  5880. from sqlalchemy import text
  5881. await conn.execute(
  5882. text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
  5883. {
  5884. "new": "{printer}: {filename}\nConfirm: {confirm_url}",
  5885. "et": "print_confirm_request",
  5886. "old": "{printer}: {filename}\nGood: {good_url}\nReject: {reject_url}",
  5887. },
  5888. )
  5889. async def _migrate_create_supplier_tables(conn) -> None:
  5890. """Create the supplier tables on databases that predate #2988.
  5891. ``Base.metadata.create_all()`` covers fresh installs; upgrades get the
  5892. tables here, following the ``_migrate_create_finance_tables`` shape.
  5893. ``spool_suppliers`` deliberately has NO ON DELETE CASCADE on the supplier
  5894. side — the API refuses to delete a referenced supplier (409) so
  5895. assignments can never silently orphan.
  5896. """
  5897. if is_sqlite():
  5898. statements = [
  5899. """
  5900. CREATE TABLE IF NOT EXISTS suppliers (
  5901. id INTEGER PRIMARY KEY,
  5902. name VARCHAR(200) NOT NULL,
  5903. name_key VARCHAR(200) NOT NULL,
  5904. website VARCHAR(500),
  5905. customer_number VARCHAR(100),
  5906. note VARCHAR(500),
  5907. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5908. updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
  5909. )
  5910. """,
  5911. """
  5912. CREATE TABLE IF NOT EXISTS spool_suppliers (
  5913. id INTEGER PRIMARY KEY,
  5914. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  5915. supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
  5916. supplier_article_number VARCHAR(100),
  5917. quoted_price_per_kg FLOAT,
  5918. is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
  5919. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5920. CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
  5921. )
  5922. """,
  5923. """
  5924. CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
  5925. id INTEGER PRIMARY KEY,
  5926. spoolman_spool_id INTEGER NOT NULL,
  5927. supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
  5928. supplier_article_number VARCHAR(100),
  5929. quoted_price_per_kg FLOAT,
  5930. is_purchase_source BOOLEAN NOT NULL DEFAULT 0,
  5931. created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5932. CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
  5933. )
  5934. """,
  5935. ]
  5936. else:
  5937. statements = [
  5938. """
  5939. CREATE TABLE IF NOT EXISTS suppliers (
  5940. id SERIAL PRIMARY KEY,
  5941. name VARCHAR(200) NOT NULL,
  5942. name_key VARCHAR(200) NOT NULL,
  5943. website VARCHAR(500),
  5944. customer_number VARCHAR(100),
  5945. note VARCHAR(500),
  5946. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5947. updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
  5948. )
  5949. """,
  5950. """
  5951. CREATE TABLE IF NOT EXISTS spool_suppliers (
  5952. id SERIAL PRIMARY KEY,
  5953. spool_id INTEGER NOT NULL REFERENCES spool(id) ON DELETE CASCADE,
  5954. supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
  5955. supplier_article_number VARCHAR(100),
  5956. quoted_price_per_kg FLOAT,
  5957. is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
  5958. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5959. CONSTRAINT uq_spool_suppliers_spool_supplier UNIQUE (spool_id, supplier_id)
  5960. )
  5961. """,
  5962. """
  5963. CREATE TABLE IF NOT EXISTS spoolman_spool_suppliers (
  5964. id SERIAL PRIMARY KEY,
  5965. spoolman_spool_id INTEGER NOT NULL,
  5966. supplier_id INTEGER NOT NULL REFERENCES suppliers(id),
  5967. supplier_article_number VARCHAR(100),
  5968. quoted_price_per_kg FLOAT,
  5969. is_purchase_source BOOLEAN NOT NULL DEFAULT FALSE,
  5970. created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  5971. CONSTRAINT uq_spoolman_spool_suppliers_pair UNIQUE (spoolman_spool_id, supplier_id)
  5972. )
  5973. """,
  5974. ]
  5975. for statement in statements:
  5976. await _safe_execute(conn, statement)
  5977. # The model declares index=True on these; fresh installs get them from
  5978. # create_all(), migrated databases need them spelled out.
  5979. await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_suppliers_name ON suppliers (name)")
  5980. await _migrate_supplier_name_key(conn)
  5981. await _safe_execute(conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_spool_id ON spool_suppliers (spool_id)")
  5982. await _safe_execute(
  5983. conn, "CREATE INDEX IF NOT EXISTS ix_spool_suppliers_supplier_id ON spool_suppliers (supplier_id)"
  5984. )
  5985. await _safe_execute(
  5986. conn,
  5987. "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_spoolman_spool_id"
  5988. " ON spoolman_spool_suppliers (spoolman_spool_id)",
  5989. )
  5990. await _safe_execute(
  5991. conn,
  5992. "CREATE INDEX IF NOT EXISTS ix_spoolman_spool_suppliers_supplier_id ON spoolman_spool_suppliers (supplier_id)",
  5993. )
  5994. async def _migrate_supplier_name_key(conn) -> None:
  5995. """Backfill ``suppliers.name_key`` and collapse case-insensitive duplicates (#2988).
  5996. The unique index cannot simply be created: a database written by an
  5997. earlier build of this branch is allowed to hold two suppliers whose names
  5998. differ only in case, and ``CREATE UNIQUE INDEX`` refuses to build over
  5999. them. ``_safe_execute`` re-raises that IntegrityError, which would abort
  6000. startup with no route to recovery from the UI — the same trap
  6001. ``_migrate_location_ha_sensor_unique_binding`` clears first.
  6002. Colliding rows are merged rather than deleted, because a supplier is
  6003. referenced: the oldest row wins (it is the one assignments and the CSV
  6004. import already resolved to), its empty fields are filled from the
  6005. duplicate, every assignment is re-pointed to it, and only then is the
  6006. duplicate dropped. An assignment the surviving row already holds for the
  6007. same spool is dropped instead of re-pointed — the (spool, supplier) pair
  6008. is unique.
  6009. """
  6010. from sqlalchemy import text
  6011. from backend.app.models.supplier import supplier_name_key
  6012. # Only a table written by an earlier build of this branch lacks the column
  6013. # (the CREATE TABLE above declares it NOT NULL). ADD COLUMN cannot carry
  6014. # NOT NULL without a default, so it is added nullable, backfilled below and
  6015. # tightened afterwards where the database can do that in place.
  6016. await _safe_execute(conn, "ALTER TABLE suppliers ADD COLUMN name_key VARCHAR(200)")
  6017. async with conn.begin_nested():
  6018. rows = (
  6019. await conn.execute(
  6020. text("SELECT id, name, name_key, website, customer_number, note FROM suppliers ORDER BY id")
  6021. )
  6022. ).fetchall()
  6023. kept: dict[str, int] = {}
  6024. for row in rows:
  6025. key = supplier_name_key(row.name or "")
  6026. winner_id = kept.get(key)
  6027. if winner_id is None:
  6028. kept[key] = row.id
  6029. if row.name_key != key:
  6030. await conn.execute(
  6031. text("UPDATE suppliers SET name_key = :key WHERE id = :id"),
  6032. {"key": key, "id": row.id},
  6033. )
  6034. continue
  6035. params = {"keep": winner_id, "drop": row.id}
  6036. await conn.execute(
  6037. text(
  6038. "DELETE FROM spool_suppliers WHERE supplier_id = :drop AND spool_id IN "
  6039. "(SELECT spool_id FROM spool_suppliers WHERE supplier_id = :keep)"
  6040. ),
  6041. params,
  6042. )
  6043. await conn.execute(text("UPDATE spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params)
  6044. await conn.execute(
  6045. text(
  6046. "DELETE FROM spoolman_spool_suppliers WHERE supplier_id = :drop AND spoolman_spool_id IN "
  6047. "(SELECT spoolman_spool_id FROM spoolman_spool_suppliers WHERE supplier_id = :keep)"
  6048. ),
  6049. params,
  6050. )
  6051. await conn.execute(
  6052. text("UPDATE spoolman_spool_suppliers SET supplier_id = :keep WHERE supplier_id = :drop"), params
  6053. )
  6054. await conn.execute(
  6055. text(
  6056. "UPDATE suppliers SET website = COALESCE(website, :website), "
  6057. "customer_number = COALESCE(customer_number, :customer_number), "
  6058. "note = COALESCE(note, :note) WHERE id = :keep"
  6059. ),
  6060. {
  6061. "website": row.website,
  6062. "customer_number": row.customer_number,
  6063. "note": row.note,
  6064. "keep": winner_id,
  6065. },
  6066. )
  6067. await conn.execute(text("DELETE FROM suppliers WHERE id = :drop"), {"drop": row.id})
  6068. logger.info("Merged duplicate supplier %r (id=%s) into id=%s", row.name, row.id, winner_id)
  6069. # Superseded by ix_suppliers_name_key: lower(name) folds ASCII only, so it
  6070. # never enforced the rule for non-ASCII names in the first place.
  6071. await _safe_execute(conn, "DROP INDEX IF EXISTS uq_suppliers_name_lower")
  6072. await _safe_execute(conn, "CREATE UNIQUE INDEX IF NOT EXISTS ix_suppliers_name_key ON suppliers (name_key)")
  6073. # NULLs never collide in a unique index, so the guarantee belongs in the
  6074. # schema, as create_all() declares it on fresh installs. Every row has a
  6075. # key by now. SQLite cannot alter a column in place; there the ORM hook on
  6076. # Supplier.name is what writes it.
  6077. if not is_sqlite():
  6078. await _safe_execute(conn, "ALTER TABLE suppliers ALTER COLUMN name_key SET NOT NULL")
  6079. async def _migrate_rename_ha_sensor_alert_template(conn) -> None:
  6080. """Rename the ha_sensor_alert template to "Printer Sensor Alert" (#2824).
  6081. Renames only if ``name`` is still the old default — an admin who renamed
  6082. the template themselves keeps their custom name.
  6083. """
  6084. from sqlalchemy import text
  6085. await conn.execute(
  6086. text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
  6087. {"new": "Printer Sensor Alert", "et": "ha_sensor_alert", "old": "Home Assistant Sensor Alert"},
  6088. )
  6089. _STOCK_ALERT_TEMPLATE_BODIES = {
  6090. "stock_reorder_alert": (
  6091. "{material} ({brand}) has reached the reorder point.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Days left: {days_left}d\nReorder now to avoid a stock break.",
  6092. "{material} {subtype} {color} ({brand}) has reached the reorder point.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Days left: {days_left}d\nReorder now to avoid a stock break.",
  6093. ),
  6094. "stock_break_alert": (
  6095. "{material} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
  6096. "{material} {subtype} {color} ({brand}) will run out before replenishment arrives.\nStock: {stock_g}g | Rate: {rate_g_day}g/day | Lead time: {lead_time_days}d\nOnly {days_left}d of stock remaining — order immediately.",
  6097. ),
  6098. }
  6099. async def _migrate_stock_alert_template_sku_variables(conn) -> None:
  6100. """Give the stock alert templates {subtype} and {color} (#2955).
  6101. Updates a body only while it is still the shipped default, so an admin's own
  6102. wording is kept.
  6103. """
  6104. from sqlalchemy import text
  6105. for event_type, (old, new) in _STOCK_ALERT_TEMPLATE_BODIES.items():
  6106. await conn.execute(
  6107. text(
  6108. "UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"
  6109. ),
  6110. {"new": new, "et": event_type, "old": old},
  6111. )
  6112. _PASSWORD_RESET_TEMPLATE_BODY = (
  6113. "Hello {username},\n\nYour password has been reset.\nNew Password: {password}\n\nLogin at: {login_url}",
  6114. "Hello {username},\n\nA password reset was requested for your {app_name} account. If you did not request it, you can ignore this email: your password has not been changed.\n\nSet a new password with the link below (valid for 1 hour, usable once):",
  6115. )
  6116. async def _migrate_password_reset_template_link(conn) -> None:
  6117. """Reword the Password Reset template for the reset link, not a new password.
  6118. Updates the body only while it is still the old shipped default, so an
  6119. admin's own wording is kept.
  6120. """
  6121. from sqlalchemy import text
  6122. old, new = _PASSWORD_RESET_TEMPLATE_BODY
  6123. await conn.execute(
  6124. text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
  6125. {"new": new, "et": "password_reset", "old": old},
  6126. )
  6127. _WELCOME_TEMPLATE_BODY = (
  6128. "Welcome {username}!\n\nYour account has been created.\nUsername: {username}\nPassword: {password}\n\nLogin at: {login_url}",
  6129. "Welcome {username}!\n\nYour {app_name} account has been created. Please change your password after your first login.\n\nLogin at: {login_url}\n\nYour login details:",
  6130. )
  6131. async def _migrate_welcome_template_without_password(conn) -> None:
  6132. """Take the password out of the Welcome Email template's default text.
  6133. Updates the body only while it is still the old shipped default, so an
  6134. admin's own wording is kept.
  6135. """
  6136. from sqlalchemy import text
  6137. old, new = _WELCOME_TEMPLATE_BODY
  6138. await conn.execute(
  6139. text("UPDATE notification_templates SET body_template = :new WHERE event_type = :et AND body_template = :old"),
  6140. {"new": new, "et": "user_created", "old": old},
  6141. )
  6142. async def _migrate_location_ha_sensor_unique_binding(conn) -> None:
  6143. """Unique index on location_ha_sensors (location_id, entity_id) (#2824).
  6144. Same name and shape as the Index in the model, so fresh installs (which
  6145. get it from create_all) and upgraded ones end up identical.
  6146. Rows that already violate it — duplicates slipped in through the pre-index
  6147. race — are collapsed to the oldest row first, because CREATE UNIQUE INDEX
  6148. refuses to build over duplicates and _safe_execute would re-raise that,
  6149. aborting startup. The oldest row wins: it is the one the card and the
  6150. poller cache were already keyed on.
  6151. """
  6152. from sqlalchemy import text
  6153. async with conn.begin_nested():
  6154. await conn.execute(
  6155. text(
  6156. "DELETE FROM location_ha_sensors WHERE id NOT IN ("
  6157. "SELECT MIN(id) FROM location_ha_sensors GROUP BY location_id, entity_id)"
  6158. )
  6159. )
  6160. await _safe_execute(
  6161. conn,
  6162. "CREATE UNIQUE INDEX IF NOT EXISTS uq_location_ha_sensors_location_entity "
  6163. "ON location_ha_sensors (location_id, entity_id)",
  6164. )
  6165. async def _migrate_normalize_printer_locations(conn) -> None:
  6166. """Trim printer locations and store a blank one as NULL (#2962).
  6167. Nothing trimmed ``printers.location`` before, so "Workshop " and "Workshop"
  6168. could both be stored, and "" sat next to NULL for "no location". The
  6169. Printer Locations API trims every name it is given, so an untrimmed stored
  6170. value could never be matched to rename or delete it. Queue items'
  6171. ``target_location`` is trimmed the same way, so model-based jobs keep
  6172. matching their printers exactly. Idempotent: only rows that change are
  6173. touched.
  6174. """
  6175. from sqlalchemy import text
  6176. async with conn.begin_nested():
  6177. for table, column in (("printers", "location"), ("print_queue", "target_location")):
  6178. await conn.execute(
  6179. text(f"UPDATE {table} SET {column} = NULL WHERE {column} IS NOT NULL AND TRIM({column}) = ''") # noqa: S608 # nosec B608 — fixed identifiers
  6180. )
  6181. await conn.execute(
  6182. text(
  6183. f"UPDATE {table} SET {column} = TRIM({column}) WHERE {column} IS NOT NULL AND {column} <> TRIM({column})"
  6184. ) # noqa: S608 # nosec B608 — fixed identifiers
  6185. )
  6186. async def _migrate_drop_ams_slot_locations(conn) -> None:
  6187. """Remove imported AMS slot markers from the storage-location catalogue.
  6188. Bambuddy used to record which slot a spool was loaded into by writing
  6189. "<printer> - AMS A1" into Spoolman's ``location`` field. That writer went
  6190. away when Storage Location became something the user picks (#1114), but the
  6191. strings stayed on people's Spoolman spools, and
  6192. ``sync_locations_from_spoolman`` imported every distinct one -- so a printer
  6193. slot turned up in the Storage Location dropdown as somewhere to put a spool
  6194. away. Worse, they could not be cleared by hand: the delete route refuses a
  6195. location that has spools, and in Spoolman mode it counts them by matching
  6196. that same string, so every marker still on a loaded spool answered 409.
  6197. The import now skips them (``is_ams_slot_location``); this clears the ones
  6198. already in the catalogue. A row is only deleted when no spool in this
  6199. database points at it -- neither by ``location_id`` nor by a legacy
  6200. free-text ``storage_location`` -- so an internal-mode user who has
  6201. deliberately filed spools under such a name keeps it, dropdown entry and
  6202. all.
  6203. Spools in Spoolman are not consulted and not touched: their ``location``
  6204. strings are the user's data on the user's server, and one that still reads
  6205. "H2D-1 - AMS A1" in the inventory list is telling the truth about what
  6206. Spoolman holds. It simply stops being offered as a destination, which is
  6207. the whole point -- those are exactly the markers this cleans up.
  6208. """
  6209. from sqlalchemy import text
  6210. from backend.app.services.location_service import is_ams_slot_location, location_name_key
  6211. flag = "_cleanup_ams_slot_locations_done"
  6212. async with conn.begin_nested():
  6213. already = (
  6214. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  6215. ).scalar_one_or_none()
  6216. if already:
  6217. return
  6218. rows = (await conn.execute(text("SELECT id, name FROM locations"))).fetchall()
  6219. removed = []
  6220. for row in rows:
  6221. if not is_ams_slot_location(row.name):
  6222. continue
  6223. in_use = (
  6224. await conn.execute(
  6225. text(
  6226. "SELECT COUNT(*) FROM spool WHERE location_id = :id "
  6227. "OR LOWER(TRIM(COALESCE(storage_location, ''))) = :key"
  6228. ),
  6229. {"id": row.id, "key": location_name_key(row.name)},
  6230. )
  6231. ).scalar_one()
  6232. if in_use:
  6233. continue
  6234. await conn.execute(text("DELETE FROM locations WHERE id = :id"), {"id": row.id})
  6235. removed.append(row.name)
  6236. if removed:
  6237. logger.info(
  6238. "Removed %d AMS slot marker(s) from the storage-location catalogue: %s",
  6239. len(removed),
  6240. ", ".join(sorted(removed)),
  6241. )
  6242. await conn.execute(
  6243. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  6244. {"k": flag, "v": "true"},
  6245. )
  6246. async def _migrate_repair_rfid_core_weight(conn) -> None:
  6247. """Correct the tare of RFID-added spools that took the wrong catalogue row (#2909).
  6248. A Bambu roll arrives on the 250 g Low Temp spool, but the lookup that gave
  6249. an auto-added spool its ``core_weight`` asked for the first row whose name
  6250. starts "Bambu Lab" and took whatever came back. There are three, and which
  6251. one is first is up to the database: SQLite returns insertion order in
  6252. practice, Postgres promises nothing once the table has seen an update. So
  6253. the same roll could be recorded with a 216 g High Temp tare on one install
  6254. and correctly on another, and the reporting instance here got 216.
  6255. The tare is not cosmetic. A spool weighed on SpoolBuddy has its remaining
  6256. filament worked out as ``scale reading - core_weight``, so a 34 g low tare
  6257. credits the roll with 34 g of filament that is not there and writes a
  6258. ``weight_used`` 34 g short. That error is a constant: every later print
  6259. adds to ``weight_used`` on top of it, so adding the difference back is an
  6260. exact repair however much has been printed since. Only rows that have
  6261. actually been weighed carry it -- a spool that was never on the scale has
  6262. a ``weight_used`` derived from the AMS remaining percentage, which the tare
  6263. never touched.
  6264. Which rows: ``data_origin = 'rfid_auto'`` narrows it to spools this code
  6265. path created, and a ``core_weight`` matching one of the *other* Bambu
  6266. catalogue rows is the signature of the broken lookup. Reading the weights
  6267. out of the catalogue rather than hardcoding 216 and 253 keeps it correct on
  6268. an install whose catalogue has been edited.
  6269. One case cannot be told apart and is stated rather than hidden: a user who
  6270. moved an RFID roll onto a genuine High Temp spool and set its tare to 216
  6271. by hand looks identical to a row the lookup got wrong, and is normalised
  6272. with them. Keying on ``core_weight_catalog_id IS NULL`` instead would not
  6273. have rescued them -- the spool form's weight picker auto-selects the only
  6274. catalogue row matching the weight and writes its id on the next save, so
  6275. that column says only whether the form was ever opened.
  6276. Gated to run exactly once via a settings flag, so a user who deliberately
  6277. sets one of these tares afterwards keeps it.
  6278. """
  6279. from sqlalchemy import bindparam, text
  6280. # The same two values the creating path uses, imported rather than repeated:
  6281. # a repair that looked for a different row than the code writes would leave
  6282. # the tare it was built to correct in place. Imported inside the function to
  6283. # keep this module free of a service-layer dependency at import time.
  6284. from backend.app.services.spool_tag_matcher import (
  6285. BAMBU_PLASTIC_SPOOL_CATALOG_NAME,
  6286. BAMBU_PLASTIC_SPOOL_CORE_WEIGHT,
  6287. )
  6288. flag = "_backfill_2909_rfid_core_weight_done"
  6289. async with conn.begin_nested():
  6290. already = (
  6291. await conn.execute(text('SELECT value FROM settings WHERE "key" = :k'), {"k": flag})
  6292. ).scalar_one_or_none()
  6293. if already:
  6294. return
  6295. # The row that names the spool an RFID roll actually arrives on. Its
  6296. # absence is not an error -- a catalogue the user has pruned still gets
  6297. # the documented default.
  6298. correct = (
  6299. await conn.execute(
  6300. text("SELECT id, weight FROM spool_catalog WHERE UPPER(name) = :name ORDER BY id LIMIT 1"),
  6301. {"name": BAMBU_PLASTIC_SPOOL_CATALOG_NAME.upper()},
  6302. )
  6303. ).fetchone()
  6304. correct_id = correct[0] if correct else None
  6305. correct_weight = correct[1] if correct else BAMBU_PLASTIC_SPOOL_CORE_WEIGHT
  6306. bambu_weights = {
  6307. row[0]
  6308. for row in (
  6309. await conn.execute(
  6310. text("SELECT weight FROM spool_catalog WHERE UPPER(name) LIKE :prefix"),
  6311. {"prefix": "BAMBU LAB%"},
  6312. )
  6313. ).fetchall()
  6314. }
  6315. wrong_weights = sorted(bambu_weights - {correct_weight})
  6316. repaired = 0
  6317. reweighed = 0
  6318. if wrong_weights:
  6319. rows = (
  6320. await conn.execute(
  6321. text(
  6322. "SELECT id, core_weight, label_weight, weight_used, last_weighed_at FROM spool "
  6323. "WHERE data_origin = 'rfid_auto' AND core_weight IN :wrong"
  6324. ).bindparams(bindparam("wrong", expanding=True)),
  6325. {"wrong": wrong_weights},
  6326. )
  6327. ).fetchall()
  6328. for row in rows:
  6329. delta = correct_weight - row.core_weight
  6330. weight_used = row.weight_used or 0.0
  6331. if row.last_weighed_at is not None:
  6332. weight_used = min(max(0.0, weight_used + delta), float(row.label_weight or 0))
  6333. reweighed += 1
  6334. await conn.execute(
  6335. text(
  6336. "UPDATE spool SET core_weight = :cw, core_weight_catalog_id = :cid, "
  6337. "weight_used = :wu WHERE id = :id"
  6338. ),
  6339. {"cw": correct_weight, "cid": correct_id, "wu": weight_used, "id": row.id},
  6340. )
  6341. repaired += 1
  6342. if repaired:
  6343. logger.info(
  6344. "[#2909] Corrected the spool tare on %d RFID-added spool(s) to %d g; "
  6345. "%d of them had been weighed and had their used weight adjusted with it",
  6346. repaired,
  6347. correct_weight,
  6348. reweighed,
  6349. )
  6350. # Marked done even when nothing matched, so the one-shot never reopens
  6351. # a tare the user has since set for themselves.
  6352. await conn.execute(
  6353. text('INSERT INTO settings ("key", value) VALUES (:k, :v)'),
  6354. {"k": flag, "v": "true"},
  6355. )
  6356. async def _migrate_backfill_variant_groups(conn) -> None:
  6357. """Build variant groups from the slice provenance already on disk (#671 / #2570).
  6358. ``sliced_from_library_file_id`` has been stamped into ``file_metadata`` by the
  6359. Slice button (routes/library.py) and the pipeline runner (routes/pipeline_runs.py)
  6360. since those features shipped, and until now nothing ever read it back — the
  6361. link existed but was inert. This promotes it to real group membership so an
  6362. existing library arrives with its slice sets already grouped instead of
  6363. requiring the user to re-declare by hand what Bambuddy itself recorded.
  6364. Only sources with **two or more** sliced children carrying **distinct**
  6365. ``sliced_for_model`` values produce a group:
  6366. - Fewer than two candidates is not a choice, and a one-member group would
  6367. change nothing at print time while creating a row per sliced file in every
  6368. library on earth.
  6369. - Two children sliced for the same printer are not alternatives — the
  6370. resolver has no basis to prefer one, so grouping them would turn a
  6371. harmless duplicate into an arbitrary pick. Those sources are skipped
  6372. whole; the user can still group them by hand and choose an order.
  6373. The unsliced source file is deliberately not a member. It has no
  6374. ``sliced_for_model``, so it can never be a dispatch candidate; showing it
  6375. alongside its variants is a File Manager listing concern, which is out of
  6376. scope.
  6377. Idempotent: only files with no group yet are considered, so a re-run after a
  6378. partial apply resumes rather than duplicating, and a user who has since
  6379. ungrouped files by hand does not get them silently regrouped.
  6380. """
  6381. from sqlalchemy import text
  6382. from backend.app.models.library import FileVariantGroup
  6383. if is_sqlite():
  6384. source_expr = "json_extract(file_metadata, '$.sliced_from_library_file_id')"
  6385. model_expr = "json_extract(file_metadata, '$.sliced_for_model')"
  6386. else:
  6387. # file_metadata is JSON, not JSONB — cast before using the -> operators,
  6388. # matching _migrate_drop_library_print_name above.
  6389. source_expr = "file_metadata::jsonb->>'sliced_from_library_file_id'"
  6390. model_expr = "file_metadata::jsonb->>'sliced_for_model'"
  6391. async with conn.begin_nested():
  6392. # nosec B608 — the only interpolated fragments are the two dialect
  6393. # literals assigned directly above; both branches are constants and no
  6394. # caller value reaches this string. They are JSON *expressions*, not
  6395. # values, so a bind parameter cannot express them.
  6396. rows = (
  6397. await conn.execute(
  6398. text(
  6399. f"SELECT id, {source_expr} AS source_id, {model_expr} AS model " # nosec B608
  6400. "FROM library_files "
  6401. f"WHERE {source_expr} IS NOT NULL AND {model_expr} IS NOT NULL "
  6402. "AND variant_group_id IS NULL AND deleted_at IS NULL "
  6403. "ORDER BY id"
  6404. )
  6405. )
  6406. ).fetchall()
  6407. by_source: dict[str, list[tuple[int, str]]] = {}
  6408. for file_id, source_id, model in rows:
  6409. by_source.setdefault(str(source_id), []).append((file_id, str(model)))
  6410. for source_id, members in by_source.items():
  6411. if len(members) < 2:
  6412. continue
  6413. models = [m for _, m in members]
  6414. if len(set(models)) != len(models):
  6415. # Same printer sliced twice — ambiguous, leave it to the user.
  6416. continue
  6417. # Name the group after the source file when it is still around; its
  6418. # filename is what the user recognises. A deleted source leaves the
  6419. # variants perfectly usable, so fall back rather than skip.
  6420. name_row = (
  6421. await conn.execute(
  6422. text("SELECT filename FROM library_files WHERE id = :sid"),
  6423. {"sid": int(source_id)},
  6424. )
  6425. ).fetchone()
  6426. group_name = name_row[0] if name_row else f"{members[0][1]} + {len(members) - 1} more"
  6427. result = await conn.execute(FileVariantGroup.__table__.insert().values(name=group_name))
  6428. group_id = result.inserted_primary_key[0]
  6429. for position, (file_id, _model) in enumerate(members):
  6430. await conn.execute(
  6431. text("UPDATE library_files SET variant_group_id = :gid, variant_position = :pos WHERE id = :fid"),
  6432. {"gid": group_id, "pos": position, "fid": file_id},
  6433. )
  6434. _USER_PRINT_TEMPLATE_RENAMES: tuple[tuple[str, str, str], ...] = (
  6435. ("user_print_start", "User Print Started", "User Print Started Email"),
  6436. ("user_print_complete", "User Print Completed", "User Print Completed Email"),
  6437. ("user_print_failed", "User Print Failed", "User Print Failed Email"),
  6438. ("user_print_stopped", "User Print Stopped", "User Print Stopped Email"),
  6439. )
  6440. async def _migrate_rename_user_print_template_names(conn) -> None:
  6441. """Append " Email" to the four ``user_print_*`` notification template names (#1792).
  6442. The provider-level "Print Completed" and the per-user "User Print Completed"
  6443. rows were visually indistinguishable in the Message Templates list because
  6444. the seed name lacked the suffix that the EVENT_NAMES display map in
  6445. routes/notification_templates.py already uses ("User Print Completed Email").
  6446. Renames only rows where ``name`` is still the old default — admins who
  6447. renamed the template themselves keep their custom name. Standard SQL
  6448. UPDATE works on both SQLite and Postgres.
  6449. """
  6450. from sqlalchemy import text
  6451. async with conn.begin_nested():
  6452. for event_type, old_name, new_name in _USER_PRINT_TEMPLATE_RENAMES:
  6453. await conn.execute(
  6454. text("UPDATE notification_templates SET name = :new WHERE event_type = :et AND name = :old"),
  6455. {"new": new_name, "et": event_type, "old": old_name},
  6456. )
  6457. async def _migrate_clear_bug_report_emails(conn) -> None:
  6458. """Clear the reporter emails stored with past bug reports.
  6459. Every report kept the email it was sent with, and nothing ever read or
  6460. purged the table; the email only needs to reach the relay. Repeatable:
  6461. nothing writes one any more.
  6462. """
  6463. from sqlalchemy import text
  6464. async with conn.begin_nested():
  6465. await conn.execute(text("UPDATE bug_reports SET reporter_email = NULL WHERE reporter_email IS NOT NULL"))
  6466. async def seed_notification_templates():
  6467. """Seed default notification templates if they don't exist."""
  6468. from sqlalchemy import select
  6469. from backend.app.models.notification_template import DEFAULT_TEMPLATES, NotificationTemplate
  6470. async with async_session() as session:
  6471. # Get existing template event types
  6472. result = await session.execute(select(NotificationTemplate.event_type))
  6473. existing_types = {row[0] for row in result.fetchall()}
  6474. if not existing_types:
  6475. # No templates exist - insert all defaults
  6476. for template_data in DEFAULT_TEMPLATES:
  6477. template = NotificationTemplate(
  6478. event_type=template_data["event_type"],
  6479. name=template_data["name"],
  6480. title_template=template_data["title_template"],
  6481. body_template=template_data["body_template"],
  6482. is_default=True,
  6483. )
  6484. session.add(template)
  6485. else:
  6486. # Templates exist - only add missing ones
  6487. for template_data in DEFAULT_TEMPLATES:
  6488. if template_data["event_type"] not in existing_types:
  6489. template = NotificationTemplate(
  6490. event_type=template_data["event_type"],
  6491. name=template_data["name"],
  6492. title_template=template_data["title_template"],
  6493. body_template=template_data["body_template"],
  6494. is_default=True,
  6495. )
  6496. session.add(template)
  6497. await session.commit()
  6498. # Groups holding any of these before #1620 could queue, start or run jobs that
  6499. # went ahead on their own, so the upgrade lets them keep doing that.
  6500. _QUEUE_REVIEW_BACKFILL_FROM = frozenset(
  6501. {"queue:create", "queue:update_own", "queue:update_all", "printers:control", "pipelines:run"}
  6502. )
  6503. async def seed_default_groups():
  6504. """Seed default groups and migrate existing users to appropriate groups.
  6505. Creates the default system groups (Administrators, Operators, Viewers) if they
  6506. don't exist, then migrates existing users:
  6507. - Users with role='admin' -> Administrators group
  6508. - Users with role='user' -> Operators group
  6509. Also migrates old permissions to new ownership-based permissions (Issue #205).
  6510. """
  6511. import logging
  6512. from sqlalchemy import select
  6513. from backend.app.core.permissions import ALL_PERMISSIONS, DEFAULT_GROUPS
  6514. from backend.app.models.group import Group
  6515. from backend.app.models.settings import Settings
  6516. from backend.app.models.user import User
  6517. logger = logging.getLogger(__name__)
  6518. # Map old permissions to new ones for migration
  6519. # Administrators get *_all permissions, Operators get *_own permissions.
  6520. #
  6521. # NOTE on the read-flag asymmetry: write permissions (`update`, `delete`,
  6522. # `reprint`) are removed from the legacy flag and remapped to the OWN/ALL
  6523. # split — the legacy flag is dead on the API side. Read permissions are
  6524. # different: the frontend still gates UI actions (download buttons in
  6525. # ArchivesPage, preview button in FileManagerPage) on the LEGACY
  6526. # `archives:read` / `library:read` / `queue:read` strings. For admin we
  6527. # therefore keep the legacy flag (the `*_all` companion gets added via the
  6528. # backfill block below). For non-admin roles the legacy IS renamed to
  6529. # `_own` — that closes the IDOR (operators with a custom `archives:read`
  6530. # row can no longer read cross-user data) and the UI gates degrade to
  6531. # disabled-button state until the frontend is migrated to also accept
  6532. # `_own` (separate change). See maziggy/bambuddy-security #2.
  6533. PERMISSION_MIGRATION_ALL = {
  6534. "queue:update": "queue:update_all",
  6535. "queue:delete": "queue:delete_all",
  6536. "archives:update": "archives:update_all",
  6537. "archives:delete": "archives:delete_all",
  6538. "archives:reprint": "archives:reprint_all",
  6539. "library:update": "library:update_all",
  6540. "library:delete": "library:delete_all",
  6541. }
  6542. PERMISSION_MIGRATION_OWN = {
  6543. "queue:update": "queue:update_own",
  6544. "queue:delete": "queue:delete_own",
  6545. # Read permissions: any role NOT flagged as Administrator gets
  6546. # ownership-scoped reads. Pre-existing custom roles with the legacy
  6547. # `*:read` flag silently saw every user's items; the OWN variant
  6548. # closes that IDOR. Roles that genuinely need cross-user visibility
  6549. # must be re-granted `*:read_all` explicitly by an administrator
  6550. # after upgrade — fail-closed by default (per CWE-636).
  6551. "queue:read": "queue:read_own",
  6552. "archives:update": "archives:update_own",
  6553. "archives:delete": "archives:delete_own",
  6554. "archives:reprint": "archives:reprint_own",
  6555. "archives:read": "archives:read_own",
  6556. "library:update": "library:update_own",
  6557. "library:delete": "library:delete_own",
  6558. "library:read": "library:read_own",
  6559. }
  6560. FINANCE_PERMISSION_MIGRATION = {
  6561. "finance:read_own": "cost_centers:read_own",
  6562. "finance:read_all": "cost_centers:read_all",
  6563. "finance:transactions:create": "cost_centers:modify",
  6564. "finance:create_transactions": "cost_centers:modify",
  6565. "finance:createTransactions:create": "cost_centers:modify",
  6566. "finance:cost_centers:create": "cost_centers:create",
  6567. "finance:cost_centers:update": "cost_centers:modify",
  6568. "finance:cost_centers:assign_users": "cost_centers:modify",
  6569. "finance:budgets:update": "cost_centers:modify",
  6570. }
  6571. async with async_session() as session:
  6572. # Get existing groups
  6573. result = await session.execute(select(Group))
  6574. existing_groups = {group.name: group for group in result.scalars().all()}
  6575. # The permission backfills below that reach custom groups run once
  6576. # each (#3238): an admin who takes a permission away from a group
  6577. # keeps it taken away. They used to run on every start, so a flag
  6578. # alone would hand everything back one last time on the upgrade that
  6579. # adds it. Whether a backfill already ran is read off the
  6580. # Administrators group as it was before this start changed anything:
  6581. # it holds every permission once a version that knew it has started,
  6582. # and each backfill shipped with the permission it is checked against.
  6583. # Taken now, because the Administrators sync below would otherwise make
  6584. # every later backfill look done on the very start that should run it.
  6585. # A wrong guess can only run a backfill once more, never skip one that
  6586. # is due.
  6587. admin_at_start = existing_groups.get("Administrators")
  6588. admin_perms_at_start = set(admin_at_start.permissions or []) if admin_at_start is not None else None
  6589. async def _backfill_due(flag_key: str, shipped_with: str) -> bool:
  6590. flag = (await session.execute(select(Settings).where(Settings.key == flag_key))).scalar_one_or_none()
  6591. if flag is not None:
  6592. return False
  6593. session.add(Settings(key=flag_key, value="true"))
  6594. return admin_perms_at_start is None or shipped_with not in admin_perms_at_start
  6595. # Create default groups if they don't exist
  6596. groups_created = []
  6597. for group_name, group_config in DEFAULT_GROUPS.items():
  6598. if group_name not in existing_groups:
  6599. group = Group(
  6600. name=group_name,
  6601. description=group_config["description"],
  6602. permissions=group_config["permissions"],
  6603. is_system=group_config["is_system"],
  6604. )
  6605. session.add(group)
  6606. groups_created.append(group_name)
  6607. logger.info("Created default group: %s", group_name)
  6608. else:
  6609. # Migrate existing group's permissions from old to new format
  6610. group = existing_groups[group_name]
  6611. if group.permissions:
  6612. updated = False
  6613. new_permissions = list(group.permissions)
  6614. # Determine which migration map to use based on group
  6615. migration_map = (
  6616. PERMISSION_MIGRATION_ALL if group_name == "Administrators" else PERMISSION_MIGRATION_OWN
  6617. )
  6618. for old_perm, new_perm in migration_map.items():
  6619. if old_perm in new_permissions:
  6620. new_permissions.remove(old_perm)
  6621. if new_perm not in new_permissions:
  6622. new_permissions.append(new_perm)
  6623. updated = True
  6624. logger.info(
  6625. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  6626. )
  6627. for old_perm, new_perm in FINANCE_PERMISSION_MIGRATION.items():
  6628. if old_perm in new_permissions:
  6629. new_permissions.remove(old_perm)
  6630. if new_perm not in new_permissions:
  6631. new_permissions.append(new_perm)
  6632. updated = True
  6633. logger.info(
  6634. "Migrated permission '%s' to '%s' in group '%s'", old_perm, new_perm, group_name
  6635. )
  6636. # For Administrators, also ensure they get *_all permissions if they have any new *_own
  6637. if group_name == "Administrators":
  6638. for _own_perm, all_perm in [
  6639. ("queue:update_own", "queue:update_all"),
  6640. ("queue:delete_own", "queue:delete_all"),
  6641. ("queue:read_own", "queue:read_all"),
  6642. ("archives:update_own", "archives:update_all"),
  6643. ("archives:delete_own", "archives:delete_all"),
  6644. ("archives:reprint_own", "archives:reprint_all"),
  6645. ("archives:read_own", "archives:read_all"),
  6646. ("library:update_own", "library:update_all"),
  6647. ("library:delete_own", "library:delete_all"),
  6648. ("library:read_own", "library:read_all"),
  6649. ]:
  6650. # Add *_all if not present
  6651. if all_perm not in new_permissions:
  6652. new_permissions.append(all_perm)
  6653. updated = True
  6654. if updated:
  6655. group.permissions = new_permissions
  6656. await session.commit()
  6657. # Migrate new permissions: grant printers:clear_plate to all groups with printers:control
  6658. if await _backfill_due("_backfill_446_clear_plate_permission_done", "printers:clear_plate"):
  6659. result = await session.execute(select(Group))
  6660. for group in result.scalars().all():
  6661. if (
  6662. group.permissions
  6663. and "printers:control" in group.permissions
  6664. and "printers:clear_plate" not in group.permissions
  6665. ):
  6666. group.permissions = [*group.permissions, "printers:clear_plate"]
  6667. logger.info("Added printers:clear_plate to group '%s' (has printers:control)", group.name)
  6668. await session.commit()
  6669. # Migrate new permissions for MakerWorld integration: groups that
  6670. # already have library:upload (i.e. can write to the library) are
  6671. # the correct audience for makerworld:view + makerworld:import, and
  6672. # groups that only have library:read get makerworld:view (browse
  6673. # only). Matches the intent of DEFAULT_GROUPS without clobbering
  6674. # any user-customised permission lists.
  6675. if await _backfill_due("_backfill_1099_makerworld_permissions_done", "makerworld:view"):
  6676. result = await session.execute(select(Group))
  6677. for group in result.scalars().all():
  6678. if not group.permissions:
  6679. continue
  6680. perms = list(group.permissions)
  6681. changed = False
  6682. if "library:upload" in perms:
  6683. for new_perm in ("makerworld:view", "makerworld:import"):
  6684. if new_perm not in perms:
  6685. perms.append(new_perm)
  6686. changed = True
  6687. logger.info("Added %s to group '%s' (has library:upload)", new_perm, group.name)
  6688. elif "library:read" in perms and "makerworld:view" not in perms:
  6689. perms.append("makerworld:view")
  6690. changed = True
  6691. logger.info("Added makerworld:view to group '%s' (has library:read)", group.name)
  6692. if changed:
  6693. group.permissions = perms
  6694. await session.commit()
  6695. # Manyfold (#1471) is a second model source beside MakerWorld, so a
  6696. # group gets the same reach there it already has on MakerWorld. Runs
  6697. # once: an admin who later takes a Manyfold permission away keeps it
  6698. # taken away.
  6699. manyfold_flag = "_backfill_1471_manyfold_permissions_done"
  6700. flag_row = (await session.execute(select(Settings).where(Settings.key == manyfold_flag))).scalar_one_or_none()
  6701. if flag_row is None:
  6702. result = await session.execute(select(Group))
  6703. for group in result.scalars().all():
  6704. if not group.permissions:
  6705. continue
  6706. perms = list(group.permissions)
  6707. added = [
  6708. manyfold_perm
  6709. for makerworld_perm, manyfold_perm in (
  6710. ("makerworld:view", "manyfold:view"),
  6711. ("makerworld:import", "manyfold:import"),
  6712. )
  6713. if makerworld_perm in perms and manyfold_perm not in perms
  6714. ]
  6715. if added:
  6716. group.permissions = perms + added
  6717. logger.info("Added %s to group '%s' (matches its MakerWorld access)", ", ".join(added), group.name)
  6718. session.add(Settings(key=manyfold_flag, value="true"))
  6719. await session.commit()
  6720. # Backfill: sync the Administrators system group to ALL_PERMISSIONS.
  6721. # Administrators' contract is full access to every feature — fresh
  6722. # installs get that via DEFAULT_GROUPS["Administrators"]["permissions"]
  6723. # = ALL_PERMISSIONS. Upgrading installs would otherwise stay frozen at
  6724. # whatever permission set existed when they were first seeded, so a
  6725. # newly-added Permission enum member silently leaves admins gated out
  6726. # of the feature it controls.
  6727. #
  6728. # Generalises the previous one-off admin backfills (library:purge,
  6729. # archives:purge, the OWN/ALL read-flag set + legacy read flags,
  6730. # orca_cloud:auth, printer_sensor_history:read, …): every current
  6731. # Permission enum value is appended to the admin group if missing.
  6732. # Additive only — never removes a permission an operator added by
  6733. # hand. Run AFTER the legacy-rename migration above so the renamed
  6734. # OWN/ALL variants land in the group before the sync sees them.
  6735. result = await session.execute(select(Group).where(Group.name == "Administrators"))
  6736. admin_group = result.scalar_one_or_none()
  6737. if admin_group and admin_group.permissions is not None:
  6738. perms = list(admin_group.permissions)
  6739. added = False
  6740. for new_perm in ALL_PERMISSIONS:
  6741. if new_perm not in perms:
  6742. perms.append(new_perm)
  6743. added = True
  6744. logger.info("Added %s to Administrators group (ALL_PERMISSIONS sync)", new_perm)
  6745. if added:
  6746. admin_group.permissions = perms
  6747. await session.commit()
  6748. # Same OWN-tier backfill for non-admin system groups. Operators and
  6749. # Viewers are seeded with _own on fresh installs (see DEFAULT_GROUPS),
  6750. # but the legacy-rename migration above won't run on a role that
  6751. # didn't carry the legacy `archives:read` flag. Without this block,
  6752. # an existing Operators row whose permissions list lacks the legacy
  6753. # flag would never get archives:read_own and operators would lose
  6754. # read access after upgrade. Re-check by group name so customised
  6755. # rows still get the correct OWN tier on next startup.
  6756. #
  6757. # Operators also get orca_cloud:auth backfilled — fresh installs now
  6758. # include it in the DEFAULT_GROUPS bootstrap, so this keeps upgrades
  6759. # consistent. Viewers do NOT get orca_cloud:auth (read-only role,
  6760. # not expected to author slicer presets / sync to Orca Cloud).
  6761. #
  6762. # Unlike the backfills around it, this one runs on every start on
  6763. # purpose: it only touches system groups, whose permissions no one can
  6764. # edit, so it can never undo an admin's choice and keeps them repaired.
  6765. for non_admin_group_name in ("Operators", "Viewers"):
  6766. grp = (await session.execute(select(Group).where(Group.name == non_admin_group_name))).scalar_one_or_none()
  6767. if grp is None or grp.permissions is None:
  6768. continue
  6769. perms = list(grp.permissions)
  6770. changed = False
  6771. for own_perm in ("archives:read_own", "library:read_own", "queue:read_own"):
  6772. if own_perm not in perms:
  6773. perms.append(own_perm)
  6774. changed = True
  6775. logger.info("Added %s to %s group (backfill)", own_perm, non_admin_group_name)
  6776. if non_admin_group_name == "Operators" and "orca_cloud:auth" not in perms:
  6777. perms.append("orca_cloud:auth")
  6778. changed = True
  6779. logger.info("Added orca_cloud:auth to Operators group (backfill)")
  6780. if changed:
  6781. grp.permissions = perms
  6782. await session.commit()
  6783. # Backfill inventory forecast permissions for existing groups.
  6784. # inventory:forecast_read was added after initial seeding, so groups
  6785. # that already have inventory:read (or inventory:update) need it added.
  6786. # inventory:forecast_write goes to any group with inventory:update.
  6787. if await _backfill_due("_backfill_1184_forecast_permissions_done", "inventory:forecast_read"):
  6788. result = await session.execute(select(Group))
  6789. for group in result.scalars().all():
  6790. if not group.permissions:
  6791. continue
  6792. perms = list(group.permissions)
  6793. changed = False
  6794. if "inventory:read" in perms and "inventory:forecast_read" not in perms:
  6795. perms.append("inventory:forecast_read")
  6796. changed = True
  6797. logger.info("Added inventory:forecast_read to group '%s' (backfill)", group.name)
  6798. if "inventory:update" in perms and "inventory:forecast_write" not in perms:
  6799. perms.append("inventory:forecast_write")
  6800. changed = True
  6801. logger.info("Added inventory:forecast_write to group '%s' (backfill)", group.name)
  6802. if changed:
  6803. group.permissions = perms
  6804. await session.commit()
  6805. # Backfill pipeline permissions (#1425) for non-admin groups.
  6806. # Administrators is handled by the ALL_PERMISSIONS sync above.
  6807. # - Operators: all three (matches fresh-install DEFAULT_GROUPS)
  6808. # - Any other group with library:read_own or settings:read:
  6809. # pipelines:read only
  6810. if await _backfill_due("_backfill_1425_pipeline_permissions_done", "pipelines:read"):
  6811. result = await session.execute(select(Group))
  6812. for group in result.scalars().all():
  6813. if not group.permissions or group.name == "Administrators":
  6814. continue
  6815. perms = list(group.permissions)
  6816. changed = False
  6817. if group.name == "Operators":
  6818. for new_perm in ("pipelines:read", "pipelines:write", "pipelines:run"):
  6819. if new_perm not in perms:
  6820. perms.append(new_perm)
  6821. changed = True
  6822. logger.info("Added %s to Operators group (backfill)", new_perm)
  6823. elif "pipelines:read" not in perms and ("library:read_own" in perms or "settings:read" in perms):
  6824. perms.append("pipelines:read")
  6825. changed = True
  6826. logger.info("Added pipelines:read to group '%s' (backfill)", group.name)
  6827. if changed:
  6828. group.permissions = perms
  6829. await session.commit()
  6830. # queue:start_unreviewed (#1620): jobs of users without it wait for
  6831. # someone to start them. Granted once to every group that could queue,
  6832. # start or run jobs before it existed, so nothing changes on upgrade. Once
  6833. # only: an admin removing it from a group is the whole point, and a
  6834. # per-boot backfill would hand it straight back.
  6835. review_flag = "_backfill_1620_queue_start_unreviewed_done"
  6836. if (await session.execute(select(Settings).where(Settings.key == review_flag))).scalar_one_or_none() is None:
  6837. result = await session.execute(select(Group))
  6838. for group in result.scalars().all():
  6839. perms = list(group.permissions or [])
  6840. if "queue:start_unreviewed" in perms:
  6841. continue
  6842. if _QUEUE_REVIEW_BACKFILL_FROM.intersection(perms):
  6843. group.permissions = [*perms, "queue:start_unreviewed"]
  6844. logger.info("Added queue:start_unreviewed to group '%s' (#1620)", group.name)
  6845. session.add(Settings(key=review_flag, value="true"))
  6846. await session.commit()
  6847. # Migrate existing users to groups if they're not already in any group
  6848. if groups_created:
  6849. # Refresh to get newly created groups
  6850. admin_result = await session.execute(select(Group).where(Group.name == "Administrators"))
  6851. admin_group = admin_result.scalar_one_or_none()
  6852. operators_result = await session.execute(select(Group).where(Group.name == "Operators"))
  6853. operators_group = operators_result.scalar_one_or_none()
  6854. # Get all users
  6855. users_result = await session.execute(select(User))
  6856. users = users_result.scalars().all()
  6857. for user in users:
  6858. # Skip if user already has groups
  6859. if user.groups:
  6860. continue
  6861. if user.role == "admin" and admin_group:
  6862. user.groups.append(admin_group)
  6863. logger.info("Migrated admin user '%s' to Administrators group", user.username)
  6864. elif operators_group:
  6865. user.groups.append(operators_group)
  6866. logger.info("Migrated user '%s' to Operators group", user.username)
  6867. await session.commit()
  6868. async def seed_spool_catalog():
  6869. """Seed the spool catalog with default entries if empty."""
  6870. import logging
  6871. from sqlalchemy import func, select
  6872. from backend.app.core.catalog_defaults import DEFAULT_SPOOL_CATALOG
  6873. from backend.app.models.spool_catalog import SpoolCatalogEntry
  6874. logger = logging.getLogger(__name__)
  6875. async with async_session() as session:
  6876. result = await session.execute(select(func.count()).select_from(SpoolCatalogEntry))
  6877. count = result.scalar() or 0
  6878. if count > 0:
  6879. return # Already seeded
  6880. for name, weight in DEFAULT_SPOOL_CATALOG:
  6881. session.add(SpoolCatalogEntry(name=name, weight=weight, is_default=True))
  6882. await session.commit()
  6883. logger.info("Seeded %d default spool catalog entries", len(DEFAULT_SPOOL_CATALOG))
  6884. async def seed_color_catalog():
  6885. """Seed the color catalog with default entries if empty."""
  6886. import logging
  6887. from sqlalchemy import func, select
  6888. from backend.app.core.catalog_defaults import DEFAULT_COLOR_CATALOG
  6889. from backend.app.models.color_catalog import ColorCatalogEntry
  6890. logger = logging.getLogger(__name__)
  6891. async with async_session() as session:
  6892. result = await session.execute(select(func.count()).select_from(ColorCatalogEntry))
  6893. count = result.scalar() or 0
  6894. if count > 0:
  6895. return # Already seeded
  6896. for manufacturer, color_name, hex_color, material in DEFAULT_COLOR_CATALOG:
  6897. session.add(
  6898. ColorCatalogEntry(
  6899. manufacturer=manufacturer,
  6900. color_name=color_name,
  6901. hex_color=hex_color,
  6902. material=material,
  6903. is_default=True,
  6904. )
  6905. )
  6906. await session.commit()
  6907. logger.info("Seeded %d default color catalog entries", len(DEFAULT_COLOR_CATALOG))
  6908. async def repair_wallet_ledger_internal(session: AsyncSession):
  6909. """Internal helper that repairs wallet ledger using an existing session.
  6910. Used by API endpoints that need to rebuild the ledger within their own transaction.
  6911. """
  6912. from sqlalchemy import bindparam, select
  6913. from backend.app.models.finance import CostCenter, UserWallet, WalletTransaction
  6914. from backend.app.services.finance_balance import transaction_affects_personal_balance
  6915. center_rows = await session.execute(select(CostCenter.id, CostCenter.is_private, CostCenter.owner_user_id))
  6916. centers = {
  6917. int(center_id): (bool(is_private), owner_user_id) for center_id, is_private, owner_user_id in center_rows
  6918. }
  6919. # Build running balances per (user, cost_center_id) pair
  6920. cc_running_balances: dict[int, float] = {} # cost_center_id -> running balance
  6921. user_personal_balances: dict[int, float] = {} # user_id -> personal running balance
  6922. updated_count = 0
  6923. batch_size = 1000
  6924. batch_offset = 0
  6925. while True:
  6926. rows = (
  6927. await session.execute(
  6928. select(
  6929. WalletTransaction.id,
  6930. WalletTransaction.user_id,
  6931. WalletTransaction.cost_center_id,
  6932. WalletTransaction.amount,
  6933. WalletTransaction.balance_after,
  6934. )
  6935. .where(WalletTransaction.is_voided.is_(False))
  6936. .order_by(WalletTransaction.created_at.asc(), WalletTransaction.id.asc())
  6937. .offset(batch_offset)
  6938. .limit(batch_size)
  6939. )
  6940. ).all()
  6941. if not rows:
  6942. break
  6943. updates: list[dict[str, object]] = []
  6944. for transaction_id, user_id, cost_center_id, amount, balance_after in rows:
  6945. amount_value = float(amount)
  6946. center_is_private, center_owner_user_id = centers.get(cost_center_id, (False, None))
  6947. affects_personal = transaction_affects_personal_balance(
  6948. user_id,
  6949. cost_center_id,
  6950. is_private=center_is_private,
  6951. owner_user_id=center_owner_user_id,
  6952. )
  6953. if cost_center_id is None:
  6954. if user_id is None:
  6955. # A deleted user's own wallet entry: the wallet is gone, and
  6956. # running every deleted user's entries together would
  6957. # rewrite their balances with each other's.
  6958. continue
  6959. new_balance = round(user_personal_balances.get(user_id, 0.0) + amount_value, 2)
  6960. user_personal_balances[user_id] = new_balance
  6961. else:
  6962. new_balance = round(cc_running_balances.get(cost_center_id, 0.0) + amount_value, 2)
  6963. cc_running_balances[cost_center_id] = new_balance
  6964. if affects_personal and user_id is not None:
  6965. user_personal_balances[user_id] = round(
  6966. user_personal_balances.get(user_id, 0.0) + amount_value,
  6967. 2,
  6968. )
  6969. if balance_after is None or round(float(balance_after), 2) != new_balance:
  6970. updates.append({"_transaction_id": transaction_id, "_balance_after": new_balance})
  6971. if updates:
  6972. statement = (
  6973. WalletTransaction.__table__.update()
  6974. .where(WalletTransaction.__table__.c.id == bindparam("_transaction_id"))
  6975. .values(balance_after=bindparam("_balance_after"))
  6976. )
  6977. await session.execute(statement, updates)
  6978. updated_count += len(updates)
  6979. batch_offset += len(rows)
  6980. # Update every wallet, including stale wallets whose canonical balance is
  6981. # now zero because their last personal transaction was deleted.
  6982. wallet_result = await session.execute(select(UserWallet))
  6983. for wallet in wallet_result.scalars().all():
  6984. balance = round(user_personal_balances.get(wallet.user_id, 0.0), 2)
  6985. if wallet.balance != balance:
  6986. wallet.balance = balance
  6987. session.add(wallet)
  6988. updated_count += 1
  6989. await session.flush()
  6990. return updated_count
  6991. # The ON DELETE rules SQLite doesn't apply itself: hooks on every ORM delete.
  6992. from backend.app.core import sqlite_fk # noqa: E402, F401
  6993. # A pipeline job stores its queue entry's final status: hooks on every flush.
  6994. from backend.app.services import pipeline_job_status # noqa: E402, F401