From 1002fc21778c5edefcf20873e7c47b19c50d6445 Mon Sep 17 00:00:00 2001 From: sstent Date: Fri, 12 Sep 2025 10:16:59 -0700 Subject: [PATCH] change to TUI --- tui/views/__pycache__/rules.cpython-313.pyc | Bin 1233 -> 22163 bytes tui/views/rules.py | 406 +++++++++++++++++++- 2 files changed, 401 insertions(+), 5 deletions(-) diff --git a/tui/views/__pycache__/rules.cpython-313.pyc b/tui/views/__pycache__/rules.cpython-313.pyc index 9749d9470b3603e60420296a986e933ae2d179d5..15a8ed8199af33dac4a513f11bfa77f12bcb3ee5 100644 GIT binary patch literal 22163 zcmc(HX>c3om0&l}I0=FP0fOKS61-`Gw?t7QWr`FjQZ!_Zf>rgg5#5kYsdf7PKx@kcp)7s5xD!`0eFpK zDUM=kRc;(c|H|Py}C-5$>-m?%62LrrnByeIO%xfq7UkLYeelKqt3wnLf;E{k2iVO!i zJ{NKv_4}|ue;~|j;POdtz|X73yhr_WP`H22KON@tL;@E2IDOpDK?l@-G3%T0hp}5w zG<5eQc1!bwKNNz&X;AXa*lZ}wGlR2`Uwhz$Fdjf+?hl;~OtU}-BQzF-t^qO!2cGqF zCugVq)51&%DI;Fo9RuJ9EXKnWN5g_tur#M+6&%BAS><8XVKuJVPQ@hENwZ8b^{n<; z=2@E4u&Tq_P6ew548!SI4X2-^dTB1FnrhJTTC8*^$jyIP0GT5V2$E@^h8~4soLc<8 zSv+UVw2Rl_jC)`@z0(S@fq23-hQGTmNc!m4s3|xL#{@L(bI`^v@!CNhGR?_(XQ)fG z`;wAZg%(c00?>Lqu&}}&4_5)?fM-Jfxntyl*LghCbKX$M;|XC^4#$i9_Lk$pd4EeN zgmc*PBo}-Z<}B3GKO(MOvUFR*3$rb_utP0mxw=lA=Bl9p-YkuRORCil=#T}Hk6xs1 zYje+PVp{u}wm4Z-bvge8DkhQZ#@|dRX6tg94=?;ZZ4qJwQQcWN_ zGOlOJDyhdPY0ddyr5;l_s0vuY4$58FtE`}o(OXl@lnE5umB;9Y@`!x^d5WKi3gj{K zjydn?;6m6HAu`9|_RjlzcqRZRo{ALuT{Eu79OM4!;7LDs+A-)o?ZC%0LKsgoQUQlm zo%GHv`1za@bKYtH@!*^fSchWP7cmU_L(|+WVYxkgjxTi;G2ye{oQC1G^Iq;bU+`1_ zS#}sC1=j`x$!q7lftdy1KF0iCyod5X+ ze_$F)8j?W3tAb$=OuXsg9PaNVHbS^y*Tl3~3k{r7W2kT#Q%IT{Y| zniJeC49e+C3V}#-(#y?yK{~1jyn$)|-2J?1a+Y&kq6wk9LC0yZXk2fc0TX23yb{DD zhtCU_j|uWM4p|Gdq!!^lkc5ywIUF3WHeNF==mxwAhaRPpOT+-*qeA;&C4NY~Nc}#y z;M_C6&o8`ai09X=8ftFm7bd%Ry={Ekc;0s*@M<7otB=|0mu$=VYqri?dy=JHNlW=1 z4P!C>fnsuv8%D}vzi5o-*R2}rZs!*#`}VvYcsp=@;=++vk0c7}Vg+?exy#&IK~Hpi zB5A2f7CAFAN9}F#{PtBtJLcZ?_}lKc-J5dfERU}hbVr{YhkBDm6&bnZX4}jEpirCh z*NxOLy`R1rxEa{g{*vYA)@*yC&-k(R7JZTN%*eWpvXp-9z=oR2FMX}_wypHy!KJ)+ ziek3?tET;Uc_`YrGiKYhYT9*AMdg>@V`<80Kl9AJaT=~(E4?ou&OP%<2wBKWIYTP? z++fq*PUW@k;=NAQ^?WVB*Nb(4zwXrUZPQ(E)*z-+KcvuH@6!MtCLL#&tAwP%fFc{8 zN{@%vdpz?&A50v;29M|Y1@D}AC(q+KHp_*=pilY(LAb5)cznTW5c5S?5{g|Sk~vg* zI8=D}0ub{=zw>y*VQ%*5Lf8+IpU)MQ09cK49uG974v0grE~v-J65`6R1WXF>#$%9t z^i}G8rsEEyP&pUtmpZ@ix<>)HP7-ais`p~qJqk{Lz=Ug*vQGmbACh#w`z@&HK~3zi zs)}l%BssyU@fxHHwh9hTL#`S5T1&1~^0kg!tL1Awxt3)zm&0l~15w^}puFo3=gKO* zkzjKGYe>W95o|7CjcHgD!R7(hl!i64=EM1*C*~79(IQ@1$d#3?WUT}$0H}aKHh^pd zDr5@}+sE65KBlLvnN@W%tesF5169d5a-xhwq$(m*Wl(A{fyx0YAy5TCr37*SR7Rjm zfXWF}1yBWnoUDVZo>4Va0V9RAcMuf44{-%WDke5tDf7g5nFmdgm^rQqo|ywh-OZuS zI^-<{bx7}k!*$&61%cAc1y4D?NHB;MgjSE^xNM>9l<%FI^@Wf3INDlUYf?yHq%*UD z=A%KdNao?DaOX=T)24dDvtbZ3shXtHj&Z5Nrh{`quE$aBot}pFr|>9vnnU5!bN(L3 zQILZVRuTe@7-Uy-W-ZxsVo#;IL}8GsOz07G#nJj;Jw%8=)`j`NL;4~W)0%BeP(f&( zmJGtQl8y#Bn3NtzC^$Fkb5uXjxD#F<8hG+RV^5@-vNjkv3Ydx+v;3(!{2UkLn&$;O zFRf|PfRX#D;mX#cj9z5q_|)2AP=JTdH~VJ2bHSOk>NCnb<>k;c|MYe|P!kTX;DeQR0*v9g^l9G25aC%N$K6giWi?L0G~H~Ja%xt^X^0W31tV0=7f6q*3jx92 z_WM3O10_akL~Gg+!pHU0aeu(!6*S1%5a7@>_xoJD23MVD*7sT2qC}=$QgTjcgCLNO z00ao4t{^PP8|*&VpMu08wq$wM&d7%4*C4}P$*?q-&kn~7rE7-rWJ%SzF(Pw7l98%( z+ySiRNlc!?WE>L~5?%?Kq|m4d#7$yy5Rx?M#~adzk~<7{aX3STkf0dUcC0HDI_Kg5 zh(Mk5kC+G&Zw3ga;U^#1Lp=VR3ASz;6TuAdefT#}YNS5f{06}HgcIt3IVO$>m|~)8 ze5TXORY3!|8b~BJLfSNzWoxoLi|27bvdm&x?K!tFj&7j1wDlaOX*yfaA@K-_qs7fa zfjDBRDcpurShwiIDXhaOtP`pZ0@c6r6i%K%Kc4TyZ$ed(>TFXeP2Yk*`TuwN#OBJ= z_bq5PH;)O?@^G%W04AAF5t#uoH}^bHV24vP(7weCECAKN@(c_D>%$rNHdOWLGvE^_ zU4*TCwhz$fp>oa($wMC?vAOaLd>3=#5D7EjL&%Rw))^3UbEkj;JDf2CS=iVYv8*OY zvis-ufO(PWpF{Wl4m!g-hn_Np!=*mjBYC3;|6q@2Wt@9D0_g|T<}(rc0;XbcV`@E-r*s7d@@=(l`6+5l{1B$jU0b+ zUi6qrZLmtYQ)kJ8{$3i5uffb`}^9qkzM`C*q*=&P8-+&^F^< zMzMkH5Y!$PJyt{L0nf+SK@x7#ss9CbBs zAP@{Yj`|&7Is^lLSHuXrW@xN{^AzRP{d~Dha@~y1Mir@++XqG6H;aBnpM1N)D7Xue zUlN}oufaZpVZ#{_h1`TUOfPU87ziHhF^5Z?H;ev44~~-uhl4nS_*|*G&_hQeA{+5dD+jo-^7P4e{LO z+xhlrQNwayEPv0cVGmqgERNRqzH>U3|J17CDR9vlUF%xPV0vZOE4yyzS1kr&`CV~C z*YC}aC2OLgC)UstH}Cv?LHS}+yug(#E>9G<#)@0x#qF!s_Ipao*0Q0cY!!>m@q*Ud z#Z}*FT(wpsx=lcnOg(*xo-z3UZvA3s!r2vbcC8g}TN^(Vefqih_}r>>j+Aq+fXd&s zUP_s)iM&eqM_=MZqO2uW)^fQbW*b>GjjXH5-MU0x-O^y|M4d+6H261BtfbSljR|HTXZ{ZEUo4B04c2tqjDAgR9oy?IP#*N^h4}M{ByGJ?`is zPptg&tM<*ve57+B%ItkpQAMVlI>_YmR zz50DQx?36zly=Lg-&d-+W!C_HlL~{6ffIgr&%+YSRAD4>ouR~}6q}R~AX|VEt4|XF zM6F?UvSOnL^+Yd;8wWLo-hFbgIjm|2jayP#HyjP1mf%L3i8Xhrg&ZJArR^)FSO~@D zgW6@;p~w(XAmVTfOKL@0`(RUxErxp~I~Y-+$!HPFh5FE&L1Q%mOpYk$sUxMEAnsGmD1)kCPXp_Lbt+=l$@>1b15%(i>gv>UIUS#rNK60^BiO>STk`RyBes-$}HOR?gv zGy9W3j$!D)yNIX5UHdX&>-NSifqhKDhCF0Qz_6cnIS{F z#oLp=_{MZtv7hWro-0r3Mv4F>LI z7ya>5f50@HZf&+CZ3?wJqGgmC%dhJmONe-B$=k-2i}`A@=) z(Eox2+3^R3C1;qg8oajmd%>FDi>#-;Y}eC50$>1;_j zcf_1K;?A9kvYqFdZ|ajK>jmwr+Kb!&u46I$qsaFoKiD0u>WFp>M~n8YnMRVf;)Jaw zW^0Mt+7hO=sHyFqjBA|;FHhv5O zz*KaXQtH~1#Vv0sm;2vTFMT0u?>;kFa=qD8eYULFJJwU0i^+U-G;sb^RIc1h7(Sl0@!qZp=AIljP=##RJnWB(bC}C{bN>Ha9Y7b?U znhZvng{{hMu7ftySy{7IaEO50Pi)VYZPQCkn)EL#8@EvVeh%zS!R7>%AVg$mQECKO zIT%iAn|CLL;>sk^jLXbY!cbHdHK||?*uZg_ZKm#`jxNoV)W!fa$&BMRQO3dM$rQ@$ zBQk{{9OQsH%~+?DV~ZZ81-WfOVP$iq_NAAeb$q~LkZ9$4m26?k`gr0^ipl{EW{Wjw zmvZ4ORU(}@a^UPWF(7&>DEF5n5ix}vmmDCN`e5c%Y*AVZ<&X{{nAC?pKZm{wa%8W4 zQY}3Ja36zeGLF>x2f?KZsQOhiD&d?GTtomBM=G8axx6T1i1O?pk=ZDUToHql2!>{I zBT|a&8Unddod+rmXm+3z=NNAP64Aqb9~$G1*u-*CGD*}B?Hy4I^+b%KKPe<;C9WZh zj0^`IU^GCS-syjOM)I9wmK9z#yfSx8^ zGZ}a;05N(bR*ctxBt+wer`saA#9VnnbTLJ$(mNzcJf9=901*?R(6NQNxziE5AZW$A za*ui(4JKX(p>^D854f+0UBdkkYgbLr`Mn(XBW%;(ljfkc0)ZjKIm7{Pr1)GE=Ch$7 zMlL|7Ksb}AM3CEI?ij|rbN_(ZFJXd0miqxD4H{uf8Miff1}BC$Kx}i^BW|4W3SXG} z2~z(Qk`S^+l(%pQo0UtLFSS`I^*3F@{}0UgZD80aeAk&8T?zP)-f+F{S}OndMe*W+ zGyDGw1g8mtHCnX&ox1bdm}xX=vVDNYPg%@Rwq~dhWnQ$f`Nz}C)xT`~dE-9@qs@Jn zk3|dit{H|tuoPbCxzO{0sU%@?#!Sv=b?5TY%TQO^xxH= zXOb1wAm$-&_p99(#}bx?n5AJ|!4&MiT~?DQ>sT%8SRTAw8tWWNbh=}m?pvYg#Nl}7 z=i+5YE-I3B4L|zg_rJK@6R+#LXaMJ#xm2k8wzcpgb8+Hz{WlIIt@aE1Ufp-`;NsJ9 zYm0O>wNw+gwi2>8w6ALyi>>7RdoQb=zI2dDXua7t#EO@ z6K`Da(ht$9>$_Eexxwg%4aytF&S8!6ra}dGZ)#M4xoJ=W3~ttw;33_#0f;>MFX4BW z07GLrp^Q{MgbxuRges2two8o_<-R)LLMfq zWuKx>{FyCNrPU(u=gFOsUv&60Yf+P0U#A@{izm5`KFtsVR9c5tIuM zexMfSPRT0Sq(V|uP`QA*QAH;QJcU_oUTN+`n^)1Vm;viP0Ot(V!5Aso=MDHk4G?^l zjuRMv?jUR<Tq*kfs}g%lW$>i5t0dR5Wx{l{tYI`QidYNJtVHqL99iPthu~e zD3=IzK3B9|MPt=OHeLjsfb8P%MuI{Aqp+)U7K=ua$?Gs!o#YdV(9bGlf=GYdXCgNR zNlRodid*nQqD{+P!2~6$7_=Y=Qr-;iC!u{F(r>>n&A<4-Ju8UQ&`DXQ769wdv%d1e z*%w~<%Gs|(tGeQOUBni}@bkE>F=1+qnHrO3+lA6sOD|NsS`n?@5jXF+Jo)zFUmbqi z^D9sE$s_UJBP2^x%+?gQH788XF;jC|mc;{ca|fYlh}jzAwx)!sDQ0R)qli{_#m!yG zrk3-}nyDUzuHjX~YsNGRsSJ#bkK1Y!rrMaPHtoJx0$R~qSJE9fcawJ1#%#55TYbV* zAA{=BJGVV<-kxk~-Q13KaDR6Hp^P#&{j;h0pG{47^pv^occ!M_nd(1+Z7MXm6)=v@ zwzr(i-ZyKP?a|7fGlQbN4JNxFFI7PENeFcske7WRFNaZH4s|iVU;z3j#Qm~g1K^cD zdZ?SZvK!G?m59H}Ao0~UdT1wewVmL%5&ZTxz^~LJywZR)*KG7qA9Jk`X|9zZ{#q&G zuj}Yxin*>w{B@^FVwf#hTaMX)VEVZ@jDQs0B0|NGuykPg&Q04z5tB1xLnvYvqIwYp``_!6s9JcZM=^^_3;05=q z?x?dPZtYyN_9piXOPH>>b=$kv-RrrOwG)`Psq>?a{1lt+e2cwoe)Hh+zG!9dR&06~ z^!g=wu%3Rof5(7}y3&jAmA?LFxL9dY!o`Y9KUkw&>2?k}l-EjBfVt*S0p?l_-reMi zR6=2d?*#x5_}-)K?Z8tZb370n{EsGn6TBQjFgP~(Vv~oZx5b*P%d7E^2pNS z&<&osro7gtCP5M*HFeQHp*xtL0;kzDO87XEQQ8MUwozhH zXM_rce+l7(`jVq+ixI$GKVbx*mjGuT zvQz_y8(B&6sE{#|Endj|DK;dIkvvL88KZ>!P#hr!VR3vI!n7VXNH?ItJbCw&aG1^d z@CXjkV?aTMC?_wGQwIM@M=d^!Wywls`ZIaXy{@4Q<>G*~cWyCY2cfu!FBuTtRO-F` zhYk)y7LY0;4Gq3rf~zPM9Kk&U^$IUvxf__YQIS>t2iDK6EJGt(0Z-!4psp{ho~q5O z$61fpj?7h5*RXj|bdaAsM;Bp{34`*88z0#>NfcAKXvrbDt6|?bJFPMcEew9v%@<}$ z*5KrF8PLCEEl8>rwgTi`(iBeOg5Zdk?a9>LNo7_nldW`Cc9yop){3Kg0>h- zqm&cIQj=}-AbV+S9`@i*=%MNndRQs7HFeI`LuipRMIk}btY+5dK>uQmM3p*kO}{w^ z4B`$p%@A;G@VI~v#t!oRefT{KzfSm-!_Oo}D(H{HbNaAI1u6Iod^X5$$RYY{bgXHr zLBdVSt622e6v%g|anS2C52$Gm_F#gz2zgV_f$wtTt6BKzfmzP) zBUGRup{?LceIy}`!p$YI3{oVP7AegPJ|-WXK?#Y0y=_R6OFDMUI}2fE;65r8^m$}X zX@M0je;2VlzIYCT_`H&-woN#Xe(bq6R zF_YDL&$IH+lZM_!ZvM=b2ekl?pt@RLV^v4I9M8XLkP?CRV^ zs{E9CB>B7k0wr4^C?+V`)|nPCC5p=4D1W_t$?%pxUbO4XXtKij#+P6Ja@5s-tvg;Z z7B!S6VY9{fx^eM^w+_YaeXu7XUmuLycfpqg)UeZ$u-C`z^$EKxW_Q6}NV1|OQPCNz z=uDQ?zH#LBBY5(>?zy8=k#7$eDQnrqg9%IZZJQ%n*&T=IT+@!E1>)ae3y0wP;!v~HjZic&_-wpi`9<&*K+KCoCmD5*%4G{gVx^7?4QaJ+n9)V?oO zlf5)yZ;aU+mpWt6)rZxaL+Vvyy=qdgF?+phU@57o!V=+|6N1w(3QoVx&a|kh3Bs32 zNJ_l0@s1X{a>okg-m!vT?ETbDDHLT}%HV2!%d-5`o3>?bv~owfa+mHG`;V}2UZO|z z^veUe`%Kh|nI3LXujDHcF6af{(zSeg_%Zc0OMfd|+yJ+jOMQdcjxac;TGcmp6`}*m zLJzmAZ(13IA+Fg(-E`8!oy^T@2H|?F;AR7j@MAR6xJcSc(sq(|lDpf~`*Jlm!8x|u zaI@C{@GTuS<(2_UxRpy_lYT^_yjAQRQ7CWqssR6Mg$gjg)+hm{)mM z(o`~;AO&m^l}ZVp`q&aE6Da-2Au-!W2=mKoPOwTWlEiPI@|ba?oHY*`njqe?sAPc6 z%_syeQ{_lvAKXxA(#fPO16Md!dD;L&&NlMPigQ zd{{?*>0A`DWmzQ|@q2-F>=r#^d13*wI5P?s;O}5c(&W^y@sfz9FCugnlTQ~!LZBWe z%NFvMryy8xH4>pB$P_G_PrZpPc;<+}`x;7>kKwi;Rq{cqAdjwrf5OZkkK6jArv7Zq zoV!n8eV+725L2~%avRJmB4sA`W@wJ&dv zSM6Lg?E-dL+lYGvRq|c|GRxGSNp|*GAh6F>V4rHr+Wrii2^BrPNO3iZG?%}NbDP;XeNH6YxM z^{#Z0yxR!8LqFK6yry&xHYu;ws-VnkO)A8+Dgka#rQl&nC9*k8Rt(PQgX=$%zd-Z#Wnz0Gd&_VNk)PQ3a5(O;vL57;ROR z2rpCPSm0!{I3_6j_#9M6;Ui2g?}-!S%SwE{gn@V5gimgU(q76y;lXJ!XP!hSM23Yp z9p1t@6W-h~AcRs%co`2jAUNDV5k3G0yEWnqddAJ=aJa+Gnc4gs&%{jm=xpo#YSruxl=Kz+($X-AVVJIk^F!=KTKOYWmfy_g=lP->cd3at&~O zb^Yhzrv|_u>abY$oSAizIR+m%_{2AMNXOs?RlK=lb}Vjb-rBJ{4tF{(clFucsdTEm zO5rSA1m9T)-`%fnnx#)(8&tgN^z&MGk}^RL5_U-YIj39Ow0#t%Nj9MEJdB3)-e=p* z+9!z^r{R&Hl7~sAlw4iAqBk0G7Rn^gXqd&6#fe0NX6-};@<_4V&PU^1ur3pmRHC5T z&NGQ_8260A@g?4kiuzs}Mr@d;F^WmyJV@dJlTn#$NwGV6%`V|~4Ew+dcEs=rH+_R! zzPWE-H+*Xy9$DP+ZSD>n&n+sd!24LN_i;C=o=A@`-FtSKd{r;hokLzE@=; zQ-;lTqcjwPiRm?^{a^U#VUniwA)|4LJd5d&aklH3+``of@e$@!PDycUfmd@bSwY9*Dv}fU z68>C1{j~S8arXI|_w42`jXN)v?!0WAdwzcJ*T&r!OLyPeu(I}D@7vx#;tbySy1qro z)AeiS4^?EI)o!~#xybwoyI!TJ1VI*#SP&GoAQJ;Z65j*M za#XuP5a&@4a0N6!g{1f}2!x*DAe53P4=0idG-#l1hClFCMXC#;rciu<r< G`}r3zhCHYM diff --git a/tui/views/rules.py b/tui/views/rules.py index e2199cb..1fea4f6 100644 --- a/tui/views/rules.py +++ b/tui/views/rules.py @@ -1,18 +1,414 @@ """ Rules view for AI Cycling Coach TUI. -Displays training rules, rule creation and editing. +Manages training rules with CRUD functionality. """ +from datetime import datetime from textual.app import ComposeResult -from textual.containers import Container -from textual.widgets import Static, Placeholder +from textual.containers import Container, Horizontal, Vertical, ScrollableContainer +from textual.widgets import ( + Static, DataTable, Button, Input, TextArea, LoadingIndicator, + TabbedContent, TabPane, Label, Select, ContentSwitcher +) from textual.widget import Widget +from textual.reactive import reactive +from textual.message import Message +from typing import List, Dict, Optional + +from backend.app.database import AsyncSessionLocal +from tui.services.rule_service import RuleService + + +class RuleForm(Widget): + """Form for creating/editing training rules.""" + + def __init__(self, rule_data: Optional[Dict] = None): + super().__init__() + self.rule_data = rule_data + + def compose(self) -> ComposeResult: + """Create rule form layout.""" + with Vertical(): + # Rule name + yield Label("Rule Name:") + yield Input( + value=self.rule_data.get("name", "") if self.rule_data else "", + placeholder="e.g., Recovery Day Rule", + id="rule-name" + ) + + # Rule description + yield Label("Description:") + yield TextArea( + text=self.rule_data.get("description", "") if self.rule_data else "", + id="rule-description", + language="markdown" + ) + + # Rule text (YAML) + yield Label("Rule Definition (YAML):") + yield TextArea( + text=self.rule_data.get("rule_text", "") if self.rule_data else "", + id="rule-text", + language="yaml" + ) + + # Rule type + yield Label("Rule Type:") + rule_type = Select( + [ + ("intensity", "Intensity"), + ("recovery", "Recovery"), + ("progression", "Progression"), + ("frequency", "Frequency"), + ("other", "Other") + ], + value=self.rule_data.get("rule_type", "intensity") if self.rule_data else "intensity", + id="rule-type" + ) + yield rule_type + + # Buttons + with Horizontal(): + yield Button("Save", id="save-rule-btn", variant="primary") + yield Button("Cancel", id="cancel-rule-btn") class RuleView(Widget): """Training rules management view.""" + # Reactive attributes + rules = reactive([]) + loading = reactive(True) + current_view = reactive("list") # list, create, edit, detail + selected_rule = reactive(None) + error_message = reactive("") + show_delete_confirm = reactive(False) + + DEFAULT_CSS = """ + .header-row { + layout: horizontal; + width: 100%; + margin-bottom: 1; + } + + .header-title { + width: 1fr; + color: $accent; + text-style: bold; + } + + .section-title { + text-style: bold; + color: $primary; + margin: 1 0; + } + + .rule-column { + width: 1fr; + margin: 0 1; + } + + .form-container { + border: solid $primary; + padding: 1; + margin: 1 0; + } + + .button-row { + margin: 1 0; + } + + .error-message { + color: $error; + padding: 1; + border: solid $error; + margin: 1 0; + } + + .confirm-dialog { + border: solid $warning; + padding: 1; + margin: 1 0; + background: $panel; + } + """ + + class RuleSelected(Message): + """Message sent when a rule is selected.""" + def __init__(self, rule_id: int): + super().__init__() + self.rule_id = rule_id + + class RuleCreated(Message): + """Message sent when a new rule is created.""" + def __init__(self, rule_data: Dict): + super().__init__() + self.rule_data = rule_data + + class RuleUpdated(Message): + """Message sent when a rule is updated.""" + def __init__(self, rule_data: Dict): + super().__init__() + self.rule_data = rule_data + + class RuleDeleted(Message): + """Message sent when a rule is deleted.""" + def __init__(self, rule_id: int): + super().__init__() + self.rule_id = rule_id + def compose(self) -> ComposeResult: """Create rules view layout.""" + # Header with title and Add Rule button + with Horizontal(classes="header-row"): + yield Static("Training Rules", classes="header-title") + yield Button("Add Rule", id="header-add-rule-btn", variant="primary") + + if self.loading: + yield LoadingIndicator(id="rules-loader") + else: + with ContentSwitcher(initial=self.current_view): + # List view + with Container(id="list-view"): + yield self.compose_rule_list() + + # Create view + with Container(id="create-view"): + yield RuleForm() + + # Edit view + with Container(id="edit-view"): + yield RuleForm(self.selected_rule) if self.selected_rule else Static("No rule selected") + + # Error message display + if self.error_message: + yield Static(self.error_message, classes="error-message") + + # Delete confirmation dialog + if self.show_delete_confirm and self.selected_rule: + with Container(classes="confirm-dialog"): + yield Static(f"Delete rule '{self.selected_rule.get('name', '')}'? This cannot be undone.") + with Horizontal(): + yield Button("Confirm Delete", id="confirm-delete-btn", variant="error") + yield Button("Cancel", id="cancel-delete-btn") + + def compose_rule_list(self) -> ComposeResult: + """Create rule list view.""" with Container(): - yield Static("Training Rules", classes="view-title") - yield Placeholder("Rule creation and editing will be displayed here") \ No newline at end of file + with Horizontal(classes="button-row"): + yield Button("Refresh", id="refresh-rules-btn") + yield Button("New Rule", id="new-rule-btn", variant="primary") + + # Rules table + rules_table = DataTable(id="rules-table") + rules_table.add_columns("ID", "Name", "Type", "Version", "Last Updated", "Actions") + yield rules_table + + # Action buttons for selected rule + with Horizontal(id="rule-actions", classes="button-row"): + yield Button("Edit Rule", id="edit-rule-btn", disabled=True) + yield Button("Delete Rule", id="delete-rule-btn", variant="error", disabled=True) + + async def on_mount(self) -> None: + """Load rules when mounted.""" + await self.load_rules() + + async def load_rules(self) -> None: + """Load rules from database.""" + self.loading = True + self.refresh() + + try: + async with AsyncSessionLocal() as db: + rule_service = RuleService(db) + self.rules = await rule_service.get_rules() + self.log(f"Loaded {len(self.rules)} rules from database") + await self.populate_rules_table() + except Exception as e: + error_msg = f"Error loading rules: {str(e)}" + self.error_message = error_msg + self.log(error_msg, severity="error") + finally: + self.loading = False + self.refresh() + + async def populate_rules_table(self) -> None: + """Populate rules table with data.""" + try: + rules_table = self.query_one("#rules-table", DataTable) + if not rules_table: + self.log("Rules table widget not found", severity="error") + return + + rules_table.clear() + self.log(f"Populating table with {len(self.rules)} rules") + + if not self.rules: + # Add placeholder row when no rules exist + rules_table.add_row("No rules found", "", "", "", "") + self.log("No rules to display") + return + + for rule in self.rules: + last_updated = "N/A" + if rule.get("created_at"): + try: + dt = datetime.fromisoformat(rule["created_at"].replace('Z', '+00:00')) + last_updated = dt.strftime("%m/%d/%Y") + except: + last_updated = rule["created_at"][:10] + + rules_table.add_row( + str(rule["id"]), + rule.get("name", "Unknown"), + rule.get("rule_type", "N/A"), + str(rule.get("version", "1")), + last_updated, + "Edit | Delete" + ) + self.log("Rules table populated successfully") + except Exception as e: + error_msg = f"Error populating table: {str(e)}" + self.error_message = error_msg + self.log(error_msg, severity="error") + self.refresh() + + async def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle button press events.""" + try: + if event.button.id == "refresh-rules-btn": + await self.refresh_rules() + elif event.button.id == "header-add-rule-btn" or event.button.id == "new-rule-btn": + await self.show_create_view() + elif event.button.id == "edit-rule-btn": + await self.show_edit_view() + elif event.button.id == "delete-rule-btn": + self.show_delete_confirm = True + self.refresh() + elif event.button.id == "save-rule-btn": + await self.save_rule() + elif event.button.id == "cancel-rule-btn": + await self.show_list_view() + elif event.button.id == "confirm-delete-btn": + await self.delete_rule() + elif event.button.id == "cancel-delete-btn": + self.show_delete_confirm = False + self.refresh() + except Exception as e: + self.error_message = f"Button error: {str(e)}" + self.refresh() + + async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None: + """Handle rule selection in table.""" + try: + if event.data_table.id == "rules-table": + row_index = event.row_key.value if hasattr(event.row_key, 'value') else event.cursor_row + if 0 <= row_index < len(self.rules): + self.selected_rule = self.rules[row_index] + self.post_message(self.RuleSelected(self.selected_rule["id"])) + + # Enable action buttons + self.query_one("#edit-rule-btn").disabled = False + self.query_one("#delete-rule-btn").disabled = False + except Exception as e: + self.error_message = f"Selection error: {str(e)}" + self.refresh() + + async def refresh_rules(self) -> None: + """Reload rules from database.""" + self.loading = True + self.refresh() + await self.load_rules() + + async def show_create_view(self) -> None: + """Switch to rule creation view.""" + self.current_view = "create" + self.error_message = "" + self.refresh() + + async def show_edit_view(self) -> None: + """Switch to rule edit view.""" + if self.selected_rule: + self.current_view = "edit" + self.error_message = "" + self.refresh() + + async def show_list_view(self) -> None: + """Switch back to list view.""" + self.current_view = "list" + self.error_message = "" + self.show_delete_confirm = False + self.refresh() + + async def save_rule(self) -> None: + """Save new or updated rule.""" + try: + name_input = self.query_one("#rule-name", Input) + description_text = self.query_one("#rule-description", TextArea) + rule_text = self.query_one("#rule-text", TextArea) + rule_type = self.query_one("#rule-type", Select) + + rule_data = { + "name": name_input.value.strip(), + "description": description_text.text, + "rule_text": rule_text.text, + "rule_type": rule_type.value + } + + if not rule_data["name"]: + raise ValueError("Rule name is required") + if not rule_data["rule_text"]: + raise ValueError("Rule definition is required") + + async with AsyncSessionLocal() as db: + rule_service = RuleService(db) + + if self.current_view == "create": + result = await rule_service.create_rule( + name=rule_data["name"], + description=rule_data["description"], + rule_text=rule_data["rule_text"], + rule_type=rule_data["rule_type"] + ) + self.post_message(self.RuleCreated(result)) + else: + if not self.selected_rule: + raise ValueError("No rule selected for editing") + result = await rule_service.update_rule( + self.selected_rule["id"], + name=rule_data["name"], + description=rule_data["description"], + rule_text=rule_data["rule_text"], + rule_type=rule_data["rule_type"] + ) + self.post_message(self.RuleUpdated(result)) + + # Refresh rules list + await self.refresh_rules() + await self.show_list_view() + + except Exception as e: + self.error_message = f"Save failed: {str(e)}" + self.refresh() + + async def delete_rule(self) -> None: + """Delete the selected rule.""" + try: + if not self.selected_rule: + raise ValueError("No rule selected for deletion") + + async with AsyncSessionLocal() as db: + rule_service = RuleService(db) + rule_id = self.selected_rule["id"] + await rule_service.delete_rule(rule_id) + self.post_message(self.RuleDeleted(rule_id)) + + # Clear selection and refresh list + self.selected_rule = None + self.show_delete_confirm = False + await self.refresh_rules() + await self.show_list_view() + + except Exception as e: + self.error_message = f"Delete failed: {str(e)}" + self.refresh() \ No newline at end of file