From 86685e422ea2c11463acdf71c33d204b7e5aaba3 Mon Sep 17 00:00:00 2001 From: uxabix Date: Sun, 5 Oct 2025 21:01:42 +0200 Subject: [PATCH 01/38] Database schema --- Docs/Database/dbschema.png | Bin 0 -> 228190 bytes Docs/Database/dbschema.puml | 210 ++++++++++++++++++++++++++++++++++++ Docs/Database/dbschema.svg | 1 + Docs/Models.png | Bin 52592 -> 0 bytes 4 files changed, 211 insertions(+) create mode 100644 Docs/Database/dbschema.png create mode 100644 Docs/Database/dbschema.puml create mode 100644 Docs/Database/dbschema.svg delete mode 100644 Docs/Models.png diff --git a/Docs/Database/dbschema.png b/Docs/Database/dbschema.png new file mode 100644 index 0000000000000000000000000000000000000000..994b2313e0cf3208a09c41dc6a2ee0af7b17843d GIT binary patch literal 228190 zcma%jcRbbaAGWlpkOq-aWkQ!;G&D3U5#a~2XlR({(9n+eW1z!VUU;p# z!*4e&1e7hbP0ehKAM0A62|YG>Y_4hXSm&CS%{6@s3$thJ3=C$*nkE*OPmFJAn?A8> ztRqE3J4tFFuWa$_>uAT|eV&KSD-W3T-Mv)WC3n6=g|nMCl!aIC+OyVsBL3Z$!^j1# zD<=?%hS7q+rnaWp0@=$2us*2z7V^~m+&b^TDX)VF!qJj+67yyBZPv{d6PO^ zG+sHNuUy7!L_d4}UjG<6#Uv^9J{NC4Rfui3%H3pB0eb$1pkO>M8^55-8+`OFp~J)7 zsac%q=gQ%!@z z%mKD)eD-nPW5EaJbp=XKT)v)ZmcP-4HmI3+Ba~HUc*K&~jPsU9U-v*FuFMWbu{k*ZDw< z{c`Uw%7gJXWT$WD>X7g;)x|#yP^`0&Ah>iAd6w)k({3W}XZvo6aEXey_b1wJQa5N; z2UV=Q7YSh|He%<$oHA3aaWFXhP4QeLL_=)>!I0vJfBA;h3zxGCUGI5a&}a0w%OxeI zXa=LNz0sUER^-PU3~Q8fS7Rnbdb8F(EJ_j!d;Hyk%ciuRv?&kE^s4&Xiv43*-Fe$< z+h$sdcK1$`xStzO#nCKZYLMuuMdR)~_hI6}T{~{$An<^4aU(Wi`Fla zpPA2V_w@Ms;n42s;&=jESb(~ufUJ)+o&d+t^sRT^A9*8{-!Zh5j2hM5eE57wv@Yv8 z;c9q?Uys+lI>lM%XgZqr%*}gc=rPeA4__<}A3HBvwJ&%eu!FZt#};aJ^V|(riT+HF zp7Tn)POTjX;wjP50?v74l;_}Zm)6T_vx({sbXTX`(x|SJXZLAvCmwBX-%Lg4ivNNe zF5UU*Qyja+H>D0EK@0;+4)zeY@z-Zd3A&g#SC7xoRXoab*Cbs~;1MuZq!_4xTLdf_vK9yI(=Cg19#-xn73nR&+a5gQgmG5>a8P`$Y>0Ch=vh4 zV_dz+vL-f-;XsQ2a=S#Cll5DsOZ&A=m)07V`7O6oqER=+pS%!FUlD5v(3LPI^J;(0 zH5r?(<3_`re7maPa%_9H))?7s?LfDuyAg~u2&z0D10@Qc7v`;pCzPpskmw90+i9VD zg|)wa!S5LFnztWlJe-7IOAnE~n}rH#s_XQz?esPSeq}F%h`clnTNkfObcZ+$&5;&! zKQD61XFWqOGsePxk1x&1q>Rm&w3Od}nV_{E%V=>W*PQ~#jo()+=7I&GA-2A9S95aY z^)*px>?h0^7)1+%(dZV9it=XP`8$-mkUQJ2qViHU&qv_mI-hWuQB`BC_!KvM*1c%> zYJAJrb8yD(6Y97)@SR3=r0aY{Cvmtm)8j3kIrYG&!*&6-aqDiHOl(s7S+Gs0u3I4 z^XTo+(8?W;o;V*3zViIn(>w3~e5_*_ejQ>G65kG7cQr!RL#OgTM3mU=?Ci9Teaw$H zU~OwQ9Air{sP`VvJRA>}wSFu(YOv-ojW57(NoK^>;go7Y|h zOHUxNdgxKNmV3*pqfn!q&Jt zc)(m(eBH;8iV7>7egEYD`i5x^v*G(gOO6I^Io$l;KfO@+xZ|+nzR;f~XN-Lg=RY2d zk(h;rg@4q&ri`EJRQ1BhY%}8cX`D#BNidignUTm;WaTKz>=m`6g%G5`JP_2Gf9cTzxL9F3x4n4Z;rT= zo;rQHO-o+Bz2MoT@>v1`0*9S7gPzRC($dW?C(pv;DdX1s=L5X^@L|iO)6RUYzU~@9 z%GHbbgtaBJL?8*u@b?I0=01|LIMKe?3Rhi$;My3 z2%hw2dw6^6dRDmOC?yheKKDg@6fzuX2xh{>#H692;mO9n@SmHxhB=?L+?Ha#=6SOA zxj73WPiawID=H^gJoe3-Gk26K-d<(h+uI9ZezM%2ASYTF9sNMrxGnarpSPzchwWOt zAC&++1&6h+=QaLc89(R#Gac{yiVNMpU%t`CX1*ro<)c#5p|5s`=9JOW%gM!6UthmX z)cG;<`N$Wy=x7R3Qc}t+l`P$4goFfESx|aBMQ@(zP(vhYxgpFWs%Q#7Lf1K5|G{!2 z$71tGtvhJ>{w^-)860Svum1Xh`ugz))V1Se2a7yNirI@~u#v*TH6s=7RS$2~UA}mc z>Dl*&$jC@I3|L@L(}f++h=_>y3>x1(?;mB-qd4n9n@OlKqt}k35f;tO%_S$(WeB0M z{`FI;n?Lan^<^Nu9aRV@>{w?@J}>iE3L2^An~l`GBS-zDjzoo|%uGhg5~st1ol~`^ zu!-{Rx6Mm2){er?%69b|YZdM(wEP#S;9MT9((*wwId_yh_|DgYy=_*%e-qsfC)&?s z6u!%{?bcJ9RV8RtMvi~~d^q*) z>lr`d;sjsLu{*~TPG-o|!cpVVBii#!HT-MaVTtfl4)-Q`c&z3=$Rebr&*R~BMhcvD zaypC=dvDv$BrItD?3uQ^l zooM>e#2gfN8Ewl~YZ;1*_DkNn=QUpoBcYLC8)?CnagEv-U%*v5Zg%@0kDM%7TU$Fu z<<+-Nedj{#dfr&W!icsbqh2G?@ABy!exBl4{LIWu&okXkti{dV5VsZSuzeW={loO3 z(iO%I{7^KrEB|^|3yuA8e<|w~k?g11r)GNrI`Vu!xy7=>)$wKO&?P7VNX#9I$>*r?P@~g`b!A#dF`E zJr2|Ky0Gra!Zb-qNpCN&hs!N>bIe() zj5>m?t*z@%`TgW3O}h7GdNY#Y=j9RYKYy~Zqmou~f#Y6~=rV^+D)x^oNWbW!gUv@i zFH}J)L7Tizz=(sDjNXAWPgkI658_+pLn<&1Iyg-`3F3u#IM(kme42L+hM$gj42T)*1{p~~uJq!5{}#GA ztK>xcyWvd?N#I{i`KH4d7!>rT0I&Vv71mW~JMogxxFKIAJy zn?XN*9EX^l;yXfm_t>#x1qB6`$t`ZCV88J;w1?*qNXr5e_vl;KflOJ zPliedy1H0tX=lH_z3S%X7A54Pn?i$$csf+-kUa{=5GN6O_i^2&0zP&12*04|WRj~_ zch|c#Xgzy+dWbo!#;r+s8LQt_P+&*4HO+k+xXN;mos4~SdmMjo;9&4;h3XXjeV+hF z%>_?at*Ft_u_e`B?`xzEN4@#sABb8m$?o6vD2w}(!(GQh1#icUYXnlMg0mYl17d)5 z*4EYl5%|E*tM#L_v9ams=tzFu+tAQ3ZuGUX^2T(2em*>SCiJ<;#&8bWX{dcEs(G1Y zU-{_;EyuoM@$m3ae!KbH);1_0U{6{kG~HHH^E&MtZ*M#+b<5MkrM{@=?`>)p*$q`G z!<;AGK$WZ_=ly-hd8c(B3@J&;?^5RJ$UpIATC(0LMC!0cg;u=%9ij{3PQMb6ms7G@ zkrie?j@bKK135;r2DRoZ7sIHW$Q$(pwP z92f1)UuZ!_ zha9RqwKx~5;%`07_YDXb9~+CByQ}(6p&7?-I@xh|GPVRndRJx2i#ugUZ zuD!hDA;RA#rBiEqY{X~Ai->immKymWY>wOSPvjvfj-z@@v-1A_q7#zKZi98mp1gGa zvm%G}0^N-p=QZ@n+04gl;DD<3Pvt#W+y^u$%X2M)()+rUy6Zl4>2)3vD*R=q!)*!` zZSIOR>P_vL05<=*@8Ejg-d}=;Ae`q+hbbq7Ia<9 zJVxOSK&KhYu2q7TfdK*8Px=%^?1VM)t`HNaM4vu_Hj0&=9v(fpPeMP`pUgny7($hb zjow@wVh}2jm6a8IOBfUV*fKS>yo>%W1)KQjH9np!?WV@&gHJ!Eh_8dh=!)0(A1FT1 zd70&iY=}x-O>aD7nOWv$`_G(@_)KkotXwyLkj>@uuK0S`bSf2dnhVfXInHeBfp@x#EvQp?ttv9Z~Pe<`$H zY+O-+tRo{M8=a0razlXz$n@sT8%9P(W&2b(xNNa6jjU$l^g0NqW`Xx%PxM zF9If$fkK6%)IxSv&D{0v#9X>R#e@p$*z;4!rITo(Bes(mrxuxTRn?DOw3P_ z<>lp@LrzGIJFKj%ckkvZBq>59zV-1LDzavv6%`YkZHtqF;k?LtvGnPfAZ6Rw;F>Kj zFR!w)GEDb-`}^;2snxBhxHNM>Dfo=9#qD@Z)eX8+YIC4O>s5RZKy2z&1hwv0ms8 zw&G7k+w_#%2_D$x!SwNNwy~~(9y#axd9O%&WxRH*+UIc8li}vgn~g{w81HtLmdq4; zeaGc?AoZvv!-U1f?Y{@>ImX9*3y~YholNS@{8O+#FrOGEu>XROW~^JWo*ElV#lz#Y z&|iS*f$6}vyS1~kfq7PfDCC~$AZK0m=2kG)kRBnE^o!LxkMU`-(|5iXOwLRVyz}*4 z(jdLndFD9A7JFKX%9X45Moqi*{7?9Z?tc$({I0~L5fZZJLbNaIz)|CrB{$j3@;XPC zF?fqKW6Z{^W%T36k1R%=OEEAgiVA=DAANOD@jv_Om#a&yF=9|auc-zMMI_Q1&POz^ zvKS>-sVkpM`V#}rSpKijZG`KlE*-n#f0R1wC4EO~UE#at;h@Z8}GIVI6A4N^Mi*J#*Z{P2PrcB zn`p~t*VFubd{Y_4#e2U;RnZnWKEU)@nVBa2`7+AN_iWenA<;fw-kk3fqE#<*bm+}C zkc$<^y@_F34vi(pWFUmT&Q!`qAbgzIabgs|o8-Hj9Xl&% z@2TJ0_G>c+>!09(A`Q*FXVoccSn(Y9$XT?zSqHXCYHIsMY-O`o2bB(<;2bP&8XYD% zJvba6*GXi!J8~DbAvaMQg7ITR+6%gBnv1mC^P^Fq*pw7N@pku=W?I8Rc(*iH!k`CQ z4Q$HV)WEZ`IMs<&HcW5t&*%0|=E(}3PcIjrXVD8*J*R<>qwQzC+cYrDKW zJY-s*K79(D!(*uZr~zBC_1(L7kS~t~yxe>HW*|u&9UVnO6dqx2SbB`HW;rm zKSlmy&K*Wi4r6Zr?1Qb8rcQk6Qag+T!^4lRI^rU7N;%6XYGcfEV3LOUq`h7KI_Z7g zB}@&p^x(j*A|>52M&{`ZTL(eS@%(o%>S^&Wd>(d)eTshhUfManh*FF5V_|l0Wi2gP zN;tlde>t435LoGR-;YUF>1HS%nq$ktz3%#IJisxwQR)Z8x1E>r!<;HD)oY3n?d9hX z{ysfzv%T_Iu`-H%hd1-XhY!I_dU%&Fw}jgX2)NlBHC>Rm9j}2(S1JBBVr}n^{+LqQDZfQbG#1>6!6hH=KdBtD=Q0vtHz19V5Yn!`OZgXqw)IV z4?G21vn4jVmse|7+e zbIF;=`TcKrc3-d6Y_Qk{5<5ndl1mmrSL*(j!@!=->w{Ycom`r|jdQ)fm_kzE`}d*Z zRCn<2YaJbR)4S<5nGZ{!!%X3t&S7dySG4=mmbX*-t3;C(>$@V7UOzlr2pCJwS}6drnudJPd->!SZ8DWkt7%0sbsMy zR96R&;M#W0c( zdHL$qEkyR~g&r6!I!o-Vx6hDih{nIWOsjaqptr)~A~gz{3i9r5%txIKDvtbFZCqSj zGPAN42TPLZZvMfW%^}PvsJ;@@cSbae;X7XXQ)(dYPa*`INU#mkuPchr{< zT^!JadC1shBiOXw^SzL!w>4wVxt6V>qQds5+&M17c4oZRzmDOtu8z(F0Rha8nR@=h z@rkY8oKW;BK*5~Z8O`}C zKY)r{ffb*DU8rG7FYLk-_uy!v55&;O~Q~!(gEJnr%yM9uy`)S29hDH zD_&t;zkXf*(v>S)2fK?|ISoONl#PvzlN3{WbDvx#B`vdFR96fL3hIQejS7v$K?h*z zL}RN<&OZ3RaL~jFe}DgqQnJka{13MjxVgF2)zzUU2L}iLZ0H>-o)Neu=-)zE##pca z26oP!(Ib%po{m=@s7iY)*ScVxF=*G<^G$|G=t|R=d=}43NUJJWmm5o!I_ZgSEr<8*qpmoN($Qzl9fGq~YziW@M##e6RdB{Db1d_t6mr;JJ> zOibkDWuiq{GWEnS7gMF3cI+G*qxWWwX=D>t(iZ%vtVU3=*gU0_Nww zPml=p!VxuEo*ZMv*^L*}e=l1Tfe5+n7fY1kck=2D)k8~91n{(ZcAF;LKskl(2Yqm) z=?f}u0jjOCveNA={+VqwMpvmVbmwo`sn+7g_Q_mjlgkSmo8v>PA3cF6jqt6No-TFR zt*ft3(F7($i^}i(K6>UuproYI8QwWcd~!(qD5)&K%CI#w1~1G`t+1JYIYJCy3QnU*jwCx$ z*_le}A2#nC986SG=L$U#R6d+_cs0|CCH#jhN)f4mrSQ6*${-*Tt>+O-dUSjOuz;aN z8bR?Tr>audRCNR7hW-70A+IaEc3Kl%LTEpe;aQ2?PmCgUJ)`~%ieEW(;snN%86k#0 zq(OF1vI-L=vUTsV(j+y{wi+2=?%_aMC1r{54Y#4-&0)eYnERcN9XA;)u2pK*TH=eG5RY2 zv6&NE$DdW&P@1h7P~D)RG3d)p@rzx3d$=^JhjgIIJ1jqrb4BlhA_}n_J+R=Ep84ja zAda_(7s-#`pD~?;E}E?(BLn4od96(f$!R0I9n%1;>C}{d3^OycMN6c> z#KZ)I>+E1j5s{ko)ZTa;(bp?Xx(#nG(*|S*&abl3@pA0dko%;^W&_S{558}F`)3q= zsi^2mQMKNf>j5nBk2Ws~M8xvSL_J^+(91M@NVvu~Tz|A+ zBSCp%3rEM&?)aXE>gwF4gT=r<w|^uJ`z|C z7UPT8t9AEK6Jo|{|7!C=d$=Sn+LaecYfQr!e-c=k2@;t5=j6F!%eo&(0PLf6SkJd= zCX0jk?@M=hU>NZ3_O-hWeI*R@L7391>rxu*ufr!JBq_w>gaZv7f(#zhoE;4jk9Br- zen&23(wmKme0oB9;^zLjdoF+I4&Y~l7|{B)f6g_$nb23BUaY33;tFz%?3{FzKa6i2 z(|!e89Nc-~syPlU;w=+u5!X*A<~~Z>ZASW>&_gvw_Y-!btjj074`2Iw76=MMUo@`Wy+WaWPR>6 z!95BS3`d4K2!vzQ5D0AVQGs9;VMli(Fg$z!h^aqJ;rlU#H?|YA56ZU0**=4Wa2692 z_t~-jh<_b%yLIdNk58F?JVC>&Sv8P1L5xaf%X&poxsE+QKRYYtv+S{4`^=w)w5(eD zu07JA0d>xj6sXmA7J%q~(EqGyzuV=LH5js^DZd zmYgQFUvfVm?0%a54GXA6iNFzBovKCTnKN$&8RBXBRm2IhLmqoxin5Dm;>XI=TLh*9PAag9oVgxE++t$`fQi zFjfouCW*~B?%kE^KlW^lY_`lqMhqB2K;z#gb@cSIhAy-*Jnr*jMX0HTfA|2ZJ>PXw zw6MV@Wf4aWCU$|3Qop+vT8aq2k>FQ$ON%$jac!@EkVx#-2liA*=?{M;oycq+|I52Q zg%0{;7w3y1k1g(Z8{f@15kgQ$Lx7&UNfLO+d$um=E;rQ7Hz` z48W}(>lBy1OtR+ZuL1xnU^>$_F)>k5A>i8C-8I4r{$HT4lS-_=;8|RJygs15rRij07O8Z|djmyR;t)(xh_oe@HtBLE+Sj4P;O)xeq6w56`7OQKM%Fi<#L=Jr#9b zaU)0PA-K5k1J&d^-8CIFUgGw*x^SBK3cL0MtkXtLd!;ZjaVDoreb$5*6LHB%d1)*X zXk<%RE%5gMZ$S`@>hu+%MMlTOwD{(v25~KIx`_ST3 z`d1)q=Hl9r@v{4) zVI-RylFWGn_<4P)0%x@Xlz>d&ADuulr*p4fteELYN+9la2eK#}6P-SBeFB$F>Q-P9 z6rHN->bTh0GuyfenZ>Ct6K(!8Jz10QH5^rn`UMf8M#KCD`?&4BBd_$1YVNNSe|qut za3t!)KNp-H&V3kz%s7xtkxX2-G>P1XQZm(VbT#Nt-A4GoGWHD#3i>`dnFm%W0Ew;S zF+Eb`(&5g|Xa`pxnVW+{;}s5RsbR;>81EiNBB!0{xSmMod44Oa+;9z+sE{-!vPq_i z6AVE3?3*8#icc;~-B#x&b_0nJ;C_bkRfPqRo;p+B&3q?3kVZJ2P_XW%IjR+Hg@Z4+ zpja5HQGR|>3xuD`Go11#1)@CK`{9H@rR<05L)i)GEXmcZ{atfEvKZ8yN;rP%+;EvS zB|ZHUko}XrmsCgZ+_|$Oz&y9mnR0d47O!q^Z?#E<(?szk|Ua@sS2O+!naw-N$rsGlP?dtXuh20buV zU%wg+MwOt$mltDb+&gIK=_fpkOG;+xW?-%s6g))Z3xuVe5jTfDl5pu-*v(hPP zJY?^IP%^;kcdJXB4WZvgIU1)vxx*V#1wG}=26`HioRU%SiYLIBb%xU=kz?-C5soxa zTP#Pxg&eB$f9)?@2~tEtVqy_5r!=*Bha-nQp;IqdF(^^7t+4AZ-JhzSO@-BSO~E@^ z`671<4KU(u;Gw(mE-VbFOlmO6oR@G()Sq3_Ez2rk*E}yKb|+2 zurQJt8lo`TLZzMjfM(oL*B76)J-q?wDZw)EL>}c|kpX6;YFB! zb-9$L@{|9rj5@CE+1boaPEH5z_B~fZW-!W@i@@^0?pXsA47=qFv(tfnP}H0G3W$yQ zy*ncy4gz5`IX)hMkan?p1k_DuK;7|8DCd_hqf}c|LNJ@9p~|z(mk9m&D@Z56_2uRF zQ^;i*5<`vB`Dx0u{t?}Ci)xm(H^U=eQCOFG58i<|heJ?owOO}#L+EG!5i3*s6>4RG z3Q5rNZ^dmgHF0f*9d-0MTbapl!l=MB)cS5}lqDDZJA}4zmtcG_xV8&(QZxCX40U3b zX2mOC-}7K=JmR=iun4B^;%AeMpk22Uel(g0rN18e z`t|EvJkTkiNGk@d9Jh9hi=!Uk^ty7#Wg|K^_EpC?rd?!?HXru@j>U8Q}(ew z1;JX=5B#-vt1o;n^!}JW_274MK*N=rzQjUNu$!Y>otzL=1;DKHL}Z|aIss}oNcD}Z z8ba9L2BJ`!#lkkav#KFX&~sySFeODmj|##I_$ZL^%5B}+&`YsYWzz&v7EtC$^RG>q zLo#RcDk^pYTUgcq)$Q#{mCM(v1Ac>S{JJB`#9e*yT&qXMKz!Fhds1fk(L?Od*|aP~ zz2X^7l|+q})!}w%kIYU@>n$k1E*O6FC-CoCv`TlN%y8>73i9$4c+N=%0q(6wZ@imk zZLhBhRv73)M_!oDZKM&sUSk+)9QN^%0?`@>HjqP7$K^OcuZc?S&|!3Y`Eq<>!eMK~ zJsT6@_2vz`^+K5J{MvLII6gMuwXz~ADq3hYPpGO!{Vq6Iq%Tr|-D2vtL2LBrs1_wM zPChpR2K;@@O7>+24DaV-zi>9h&KlF-R;68Ptl6rHiYRE##wM>H*KM77bf3h~Jrnn5 zYE9ZIArL!tv}xx^xxpnzk2h42DgiDSs{>mhW##Tr(^3^#S#v<`fJi8)sBUh{^W3~S z12)q36_Bl=VqGLA{)T6O6KDvGDnJcd1_qRh3zaNZJot-Zm&n>wi#D*z?d`CCRFYx$ zG{2nqQdE9NlA6RVb`BHoD$-Z-M@0HgU50v!0#3f4K7|^w!tcYhaCu! zBa@SH(F)|0bTZP?AERr50qV^$qHfQ40h^hES&755w={U{5C;Wl(v?mL6}O=Hod>i1 z$DIXn_Psd#{|FRDFb#42My_(D>^?$lT9jw;5K9{y-+9bpEAMtkvgkI3^#Cym9L9z# z4mWs58SCS$wua*0;|HRMO+Pt;i?YhfTRXF9J_Jm! z5YR&2`O#!YYJF`TAt4`1*9b?#-=)a@!KZ-!}p75fo{l*>;j1}0yStZ2ix;$G6tigqYHWy6&SQp9b655 z&KGK|CYaL;Qz41t)(H5G-Zy1S2nbZ|ZLb1Ji~_2&vm8fOzzkU~OuHoig!6AK4A1<= zRw!9T+DU-; zf-#Iun2B6I>H5cGbKj^ny=ADgckje(QKX?mI2#?eoMb;lB6#6dbB#S-zXn^ykKw|G zFElaqc30ye*i=#DmhIlBZpCadKz`3>65=EyI1}cc2nq_y%Lgc@MPx^~;n8ZG2>z9e z992;8Kx7g`36+Z?|L}dNY%4IOj^bpmY|iyu#K+$pBRxbB4j^)ad0B2{efL(b^1m4- z{IlUFD$HA3Tft`#=+oAbD$5FIzw_b^1AXN~Y^ZW5RQUR!V3MkT>BRBdQOKvJe`<8R zB%y74?0E9Sg4U*{G!cKQy2#=)Vsxsx$)FZf`O{EPP%tnoR^afQ!^V!SijTkHsHhJ* zko=1kpxQc7E=TC4^EfCs_ZdTB1teRa^|&3fEU+$Io(zE2gPS zTs0glW}EkFTnlnxNa=dR*s-%$uTT`N7#sEoL}(CH<>cht?6c%m)RjK3OpC0~^~gCz zHrr;4fs^vWv&Dk4>iV6E=)%IAN9b2R&H52FtoFnG6F)?)#7UC3f{Z`38G}ph;J$zr z0ewa*z+a~G)$ERC z7tNdBK0|*zpJ;M}Y53vqJyW)KaB!4UbfDU2_kpknWX=sby69a)N*)Rd9k5thFgg8o z#{u8!9tTHX9fPQBy6mh{vJ&^9dB+iQ2hZc$TGhjZ%$dUPcH0){l~q+BTD`97jW=(K z;L2I33m_o6vl+2#SDefv4*WFZ4+iEB8caa{fd*gC6bMDVgQhSC#`IUTU``4-Qgs5z z!jJi}jLo>7#*sa{CQKj3_KXU;6eXnq&4qaH*YU)7J!F8HN$+UARIyIm(hTjq0S=@3 zymP~E!upz=pZ%VPv@^g2&swrarld^ke!*fkH5#~;mjZ=(dL3{?fy^ROQa&CYnx2_Q z7R@&WIW6G@NF>q^&*~mKJEA*m@6z++_MOYKflXx(kH%Z||9eV$w0}s*1ftKu!9nQV zCl}5(=t57U*!Ia2wZR#`2N=ujH1^Sphoj4S_a!rLP_1E2w zyQ*IeU>?`?!!ot=fE#ma|S+h5$yLS}>ZNrDGh zS#HIRIQFaCw_2$e)uK>=#B;b0+1roKyz_MJmcXkOA0oDuW`3O7)oYyfvx%u7dTPxV zy69|fKF?@ipG`_l{R!JZ>FTZ+cA%m<&-(-7{5WdxrJZ3D<bTF2 z@cLQ@jy|XDiTBm2pt=h@cyQwM>0QH%LRNU-ytogx7u8E9Dt$Rd))RG?bGLf*kOl+& zhV-^a?$B>|k1ZHdc3#9mfe#D}q?z8|-87mP4A-cpeiXB=CesP3>p+q9DjUGYxxC6X zv789(Pf`9qDY^eK=#5VAQK`U^;$@;L`|TDMmopD;C;UG46SYJre@IQFH=bcK?QfmtKy^8K2;(n{`$qG=X z0O!W>>`!kkkAd?8{tm!A>X@RT;S9j;IN**P_qK4!1s^ii6~b+nS@59&i__ z6oMLBuI<{?C(CC^Il>;^#2l^uF&sKSJ*xN`a`!E#R2F}`H$X=6@*^cAEYxp_grx!C zu>?q9#A>zBPh%|U#Q9A$MMH+x39$I*&!17wyNCW4;O-bAOZP2H%t{*=lqF?kS|GLI zo)TD)g!R1rn%Zo?owPsb$WAhN2b>3%senH(xKaGc)u?Wa&#!w z4L^c4QPz1&JY>Akh0VRJt)|Ql3$_||uqInYj2`Vc9mZ|Ox@_~%ag@MpkAD)AI6Fj?fS@M%W zKt}CYq>0V8Ixys{PQ2HU4FwVH0;?{|(9Vh4BP0RHGuDg4t%Z8v#c%P+bAuDKr`hj{ zM(-~CrV+D0p-i!9v2UyrezsOSgAD;E(8@J6HR%!AOPiY-g%$~&CcZFCwImGyM*|SH zZN~g0iH70C82i))}x_Fx(%T2BXiqh{xqhcag((y0*~RO9%UFI)Z^ zoh86?3~vO3x!~#9zvMRyIFuO#`}#M-d80f3=zAWR56+{Edy%#awNbc1+7oCZdhfm> z;6hjmA}4ZL$KHN#u*9z54Ug~Ui%&&3ghDMp&Z2=Z%Ax?_@Uv`;nRs;?{L)zTuQMXpg5wxNfxGxE^Be1EW)D#rb2TViS ztlAjZOMcbA6O$y)eb9!+G`D430q-&}q+t`Yi-#~L3ZAn4dJCc%`2e9wAUWd z2YPCYgea7o2$WpoA4;yTsaeemgkY5M2d=<^bp?D|P+NFu&R}BpfJh8s^XDE*t|(kr zO^}Pz4RCqoYHSQ;tV~0zNDIzaY=>ft=&4=cy;m+}b>AEP#ArL*dl8@7~>L z5h7iOTTI~9e(<1jX{3T?HLq-c9&URi^g`kWGNMzO?5o4?izJ^DGWQ!BGlzC0PHf=Z9$!*M931o za#UufAL7Q%n~bXH#sSb#asjLcoH^rPyEb4wlp*jH1zGK#eg$c~yVJocat^<&yNjeT zgk{zZU;PT)Li4@O4+exDQ-`;JygaBQ9+B>eN=c!{KK-it_Y6b%Px*}rE&e|qACJuM z8TgTa+E4*Q9^{pb%;yz1g={>?8)$kB_#IsgPTTchmK2SSj^^C#epHKt|q1d&#zSh`zIdmnYTaOm)0Q)__>3^C)! zE6xA1v8G*SZbcB?GaCk`6;K(N&1QRZXepnvPFztciC-s`d;UaKKWav1 zc$mMmJ&DwN{5VCeNDnzdxYt#efs;ZwiZ;=HY4|fFJJjRW zM|{SY2t~pC{^Y7$Z*I_+T+79JL@tXg16Dd_JpR$-IAd30l)SKLSq*?`=0s#sL77m0 z2TJIX9eT!aql+wl|(Gax`&pCTY&dC|pQD8Z% z2m@W&&P-CT!h^)Cj7F11I7;>>7k0Uk?3Q&PlNA>~q)Rt5;UVWy3A*?LrI7W`{{$(s zPhxWN|NF*`y@cT4=~!_1XXC8Zm?!rq!L3R#Bf_n>e&~CZ9v30gWK#aQX@st2dlL*X zF)>biW287l_k7>I<9)ve!eYr$Bl7X~#>T{4hLnU%v9~W?!AuVbxI~E-ghU&@8LrU=vi6cGsFng=~$w) z7eJ0nm*o>oFd9;~>+WeGNgt7uZNv z5~`)G4cu1;pf>QWgQjDC+3DKi@zq|VG#1mrFcKB-Ksy1zF_0H+KpcS*W(wCSY6Frw zHn#^geope`Ct;foZ_v~4!%Z4&qI>tXu?r&)7p%+dz+@e0%6snIIk;wYky8I@T>uRX z0&!;m^K`?_&gHRc6tz@WCz>H(#sV_sf3Vgjx#w=!1U!S!Kw#$r#P^2?NtTR(OY3l( zO3txeN!b(h-5c8da2w|2Sv)8U1AUn`(3%58q9y?-5FTVI$R{i~&*05cq*ZmsRKi+- zOn=N64EIw3>rR~twerD>`f<^*ny|%IeWNU*a}G@*%KC=FNBiCvaKq-t_9L#}LSg$e zU0q$-CUIg@@c2TNlz;dz`BG00b!ZO58OuVjyFv%wI+TrLp09<%V!81~%_i{g?k=iy z`lz`#piEjJ_p?VXm*bie{Xn@~n+=3|uNkoerjx(Cc}bqy>vBJS6=dZ$E@E1GT3Vx_ z(tY3wCqHo{w)fB)>CcDu$UevmwjiyO6sD9DUfPl^w0=I9QN7g)qz$S8b`XFg588xs z!p!-Bhj#pMTNsCH?Yt$red)n$R2{#qUM)kEq|!%(M$Y3D>X=acd&8WovT5#up z1%tP<{h}1-CkhzB^*4e2R(^ML`y6Xh_;EU+bq~At*!G7RS^_Y?Oo_CRbDb@vp|!JP zAH7H={>YOPWTfE-zk=jE-0MC-4M1&DJm2c-B99FL$?3G7r16%N$Fcb%??9pD>gJ}; z>(?JYjR(-k>3$0lP+CLXQ0vQ++QxfekI_I30^ln*x0dvx=;(1U2f-u;Jfi%HTi=C3 zn~xV?nG8;e19L$bE}V=w!W7@|oRq7Lx#8AZim*>M*+2te5GcGi1YlHz3efQFTa2oO zZM2-cveL(_<2kgQkqS4*vjjw&9a>OuCQY~DLkr|0*za=h*>9P}gfy;Z9Y3czP1(Hd&$>s<-Wg0MW?COMjy4+t+d@!HR z#+&K5Z#&V3!){llVFS|40$6w|$^ctj!o%|>;R;d68rfcBT>lHQ(}eHn)0Dm3$K++3^RT9*z4LAdl=b>wE#+=HnQ|R531ivrT3}QR|g%3Q#Jh zKFFpCcfc($aG)p0vXpRb&m^UR#tGN9pcIuC{IrbR=ieGS+}D_hdc>@|KNaN@bW06| zuz@^idwrXkIp9?w%KoA4!zIWTQ1*Oz6rdCh^0?dqfrK%3zYmz#eH_=wJDe8$O~tMh zzW8K!j#8qddmtgRrj_E>tpxz;#_#UJN2usgN^D-AyH#aez|heXdB6KbuUn3O;F;|` ziE9JIGzNu>7x1Qm6`ti}*wv6SvMR4w85$MsHMUs?jL}V|?2264M;>mI7QRp#)p;Fs zET0`1q4hAB$&6*T85~ph4!fJFXCGcVk=W$|%nS&^SptR<2er20w5ETdJjNygSDm}X z==Vn7y8h%78VSB2FW4jWE{Buf(iPcaugk;DWOyF^X>1bdl-k}xs4z1Zc;85mvpt>6 zHaRslHneAN&z{>~YU!nHA&u_4VX#Zbfs^C=EI5eF7oH(@_$}Bqp_*-dBrKy(=@Rl3 znzs(>tdH-wtlz;ZMsLJL6ONZX)gOX+bz*tH){~($U%!P+v{@Sr5rMDgVC3Ct6=PUV zs!8UlQ1R}(h_6U+`wSd-=rSV#e<#`yEe({c^|jlREA8 z4FNM}fL3U?xd3CqPb9^>K+32ioTgr)zm{NsX%1+ea3(!$H9vz(yTAazuov<5iIytb z1#qZ?MG-!jDWWk6WsC-dL#L5njdD~S-O+Lv$kP3$oCD1^%kT@L-JI!cp`2`~&%2bV z`FROy&ZLC?uyldoM(p-f{mYv4xx$zS)z`-bC_k+h&pC0W^c)k#NFynUFvd;(EVV_# zVPy<+++iv){c$w>7|W8?bo-V2H8K~hHfq`Ne-e0g_N1Vv6r|70U3Qva2^Jr6*d$if zF}8zIgcBdjuV?AZ@M~6VlmHYTVaKkA!EuA{*Gax)hry=4rsiWM5F(LLQ7XhKGBJ(n zDk6H+DlJfwp$P+(ye-~w*-e&x1oq3J(d9y2QDDdN+S<6Q4m`tg8KA(?$<^v% z{_8B?t9IJTveWCg6tG;po7jOw={n2Rq^zo=X;M7<%pIRBU`Bv}ue&hu_fdkMnI4W3 zJ~m3Gl|*(kwYM*nUc=Sn8bQskWaR3H4gt3(o!~={a*ca3b!z=!?gn4*Qvicdna^Ti zeB>Pmun2`7AX-|gat0ajCGZ7&IP7PW-reW#a~Vhw`oZ#m#&>A=aL1zWyfW~R)OVksC0gr@C^V7-EIem!jFOiL_j2tWLpFc~0DyZL74a7U5> z(eoqM5NwL2=m^gRvr4t_KM7a%9hGpiKKbJDSs)O@od_8+Ha3;*{yAM`Mm9hpUxMBYuWz5zeu+x6AsAn9PX{IQE8D1XbHS{@xo##CHgXge+@jH zKo!7QxTZCfNo1>&(tpY7v>{L^LJ!|rnN8SvTJ~N^+mQUiLVPJr}iTim&j{k?T z_m1a!@BhcGgp9Y9k-bOCtc+xqEh9=YvbAIs4YFz4qwGzQG7=R&+q!<+^%!3>&WXl9^-z$Kkm<=l6}%==D&Z>-ZL_HuH=?ln}2T0~Ton5=U#T2=b^Qst&Q@5v+28nn`JlAu(E z?6Z49qSX0}`!y-QA1@44K62XY-0Q@yz$C;m71xl^|J-z_UU9e5(~F98?L!H(e6ai) z`JEgPuDBys`WqGnYt7e?Eqrf2LcePj8Fa(_p}I|&Tj`v9$*FZJ3x0N{tNK%wS-Kzl zjRuRXg0!fa_*U8!IwjrO?t0(K8e_8`>($Gv~%@2(I*F*(i#70_xBRfv)d<+kwh< z@~UME6CGX7S+gGxclugwwW%z^Y&7mFQ1UE%HOoC2VC2F&?Y zfxI?}{fsmR+1#rnZ{;ueR&Mmm82EjDXVpeY!3Ssj0^@0CCRbDKDp!C10`K}GKiQ6s zhGwwVPuj1y*Nr<$O}no9z4SGE4BcPe_Lw6NhtxBD_RW4UC@@lZ+PH*^eZ z>XrHfBKw1=p49S z+Yu)4dd-&h?|!vjH=cZua@FahRahSeLNUpWR-c`ZuWaR1?q8!HjogLbKi>oKox+@r z@0C2mVf0{`{-d0(e@7zDr$hfm-BLlrS08JHPmIzC)?3-k7KYbWFNlwo=pt{HFsQ_1v0)cwjetm-kKy0ZGSp2O!G1m_iK&(X{iUEccj z8`iw9$Hrzi@fJUU)?jepCLBjyk?BDAqUeV27_=p-^#=a|&tIsk?KxKVKS~UtR=3Aa zuc_7~P5J8S4*b)ux<`(uxM0nvufb^QKV5vA!Do>Zy|;eQB_txX+ zbsTtTM5p85f~khORss~%skPPL1dTT`3u<&bI>cH}4FAz{E4pKbnxH&!((XYEoSb|b ziw9{=lrdS2yb<>{u_rUUM;pY>eO}R&uNcpC+^%z$3rLcL9JrTxmi~7GJyr1R|JYSo%@()+GroFV=B^HU-TH;JG@k6M zEaMJyCIN}17SM^MS)qxNk+CS57^xj{i zwcR${H^D^qBGc9KbAs#r{m*M$^7)ktC}5+r{K3H8m(p+kUvrIJ%iY()WbcE2^Twe# zTsFS4b@S#ijsseQXN-(4@7=SX2QAfFezAv#$CuCBi%mc)NdEOaUT}uOcx)J$8O+R`mV`Dl>Vn9+GBC7waQ{^SxVnWp2vASqxwRB<%-q zR%=dvz-UQ|w4z;&SN4MkX>FkbQB4i2CM*sy^k$m}Una9qeA`K7X5&26fh#xBox5k= z_4x5Eyi*%_M`5hY+W$oj~#@|InZ!>z6zaA^qE5MZOj$Y!1Q_=}>VBG|9=eYiuS5+pvke8}j& z))VXK8XOo2%13-qtEv$K1k9VjCH!CKLiN$&8@qqBy(%-f>=0Sf?^r z#~6*dKBQ`?xf_)*qdhp6+yM6%s8BvlP3hxHO!8tP4*lX3(I!!8eu^9&9R>ak-Nn+c zU!A}VU&8w~ zNveCy=~K!T-~Qc8&c(+a%Wa-9)85+p3s;E>FvX;(!$*lKLVj(19sO`0oH^m)BiM>~P=|HlQQ&PwDyq;^B9g!P0``(z?-O+LU z=|%Rud3)kCh9V4Qp^EjsL~Q1)9P8m7C)-29UcDsJEYb8aqopKV@Y;XTj3 ziv|a6Y%qxO(dqXtlIg~WucE5n2&FM40tNNJ{8MR8#r&67J~%3sJa=W259W>AvIItj z&L%XVEhhxfX=!iJ7eJb4py$l7{?xQn4810as+bqY7jg|GaFBNgADz2@3C3r-CGg35pkmJquVaT0ZoecM^^q1|YJ zyezI*Vd0T>tlp%ATAi*NXDkUzk5-qEQ-d);1-UqlDE*tQ>DjYtbWjo!65{Amq&I9X zy>&|>F+X3FUIV@!g2}gU--f-UXyi`ZfeTW*cCCSZ)Q+If9&3t!`sI97?n`oU_gBD6 zrcADMu<-FQy0&fhO}X-W<*q23t@mh_t*93)Fko}5E+@y_L)1;+mCl?|QB_?xa7SXW z>YAoJl_n`H{^Iprg&J#>;A+q$T;r6Z;IWuIb9UjAl4oy`?xl_EsHp(}Z59^(0C3yc z6-SdD#~cVg@_jt!KHuM9%dDN=bN#;h`eNQYu-%1O4=xTen2L%@o=vo%*g2Qg%*&YX z!bmxiD`LVxM+aksCtQfYnT3&a`XEq6eshy?nLn#jv z0b0RWhbO&sR3RXT3pQ@k)Ehebwzluxy9|%LZaNwCtbCMw?7P&svg_zKY!J@>Upw+U z)ntchOYfC4@3R$0lmmPtm}v#)PPwA2Jyh$vG~2orhZ5F+six$BW3Dx6uUAWSs8y5{xjWTx*c7{1 zFesNA=a`BLYcKvwA^!%V)$E#@MeI7t>#lJEsK*eCjg1YO(tA}^>4@uyau}{mhrv&L zy!M0+Nk^U(zbYiout_d!n+MT7KoZ@J{}WEZyS_elK06y-Q2@^9$O&yRj8Z~eoSnzS z=O{oV=BAxcQmTSJ174&(Knuu7A;~c2vwqU*<^$aY+CwyT4Q37XogE!zi;Lci6$*|Y zbLO<33=D8Yyd3@=YDmmq`JH7MGOt z+~XG&VKGXv@k)geb;|3&-^+*u-P<}kIv_`_Ogg`+cJCybXga&du#VP+u6yP_g}s@K#_Hd)PKj8|sGS$O zVPNpo?^}14!sXtr@+aJ!Y3dU9Zu|Fo=6?XF2z@NYPhT8M)OgiZRpDmw85w;3gUF`j z*S4vsaI2m0N^VJ`3DL^{F|v9{q5F1z{=!82CYz27#XJXZP&hcpgoRPL)2XNaL~|p1D%_qkzp-KP*|TS@t(VL->mFCxh3Pr$ zf+9O z;BLG;JOEi;j~-P!{$@wboS+zO(ej!`i4=3w}V0=QY=`&O8o`^|52eNIlztreQL%u*Y&E zV&o~Js-W4Z$F^Qn|A=O~!l&xAba74w3dkDs64nRwc>%jQ~94}SU^Bp!%p zQ#4I7O`_-Ue)sjHa?6S-zo|uw-#0Vj0H`8WIByuBEK@5#!XN5P6C%LC#c6-&;Ta_< zDLU$BU>-idudmk-xNtH#G_(;_2tp%-xp5g8XnTC*#X|({4kHxd#RPVx+bK{oXQ-aO zY89QPcx#yRrZ$k<)Fg{FUD2bDz#)Shc)3kcEugft6mI+O?9KySU0FS9_I#@%5TizX zS4f=be3~yh*5+M%Ikh|bYaEFmYyAzP*?w<0sXUsdA>5xF4>yP@V+Y7RFm+Jw zRvkMBS-_FD5hZ_d|adGB$b|$VG zq;U=BAXo~0MO^B+(RI9H*WRsnw>xehs0=IoF7EIxEF^%ftSnyoc7j=xzyav2vfk-? z=<1?2&Cbo+-q_gK-rioUcyobLiC1ohCjeIz^QVWO&Lq<_{r}s%n>5}ARo&IAR|(;TCFMi@Ra;xq{S=PY z6)UQ<@dh)t9Hx@pQ*`fInLknqXeRI_h(DwyjAq9a!+K`tr!4S-goKa35gMw`!^0Z7 z3DJeS?_3C6ftC-wx$RwNT3hbLaBwKJ{6HcTqvZ8LrW2Ym5mC(o2lVx6E1y{! z($vQU&W`D?J27V{;_gms`eCbBTdtR1`WMz1TJLw;E~aT5{ihcpb``xKv8#d%EKpw_wp(M(PG40D$xL524C@Iq(3?&r+`bhBF;a+1W6e1x6;H&!Hy|ucD^b}xSja$`vWm&8&EY2>^OHRExdATp(*)yYk z|I?y4S*y@IkH1H>E^@N0gW9EHhd0Z}IJz@99da+ecIt~KA|`biZdiL^F4Tt zVLpp}`>HA`Zm6C7iP~vvtrhDu82LwFU)}-C_YK3K5+=pFt z^_e|S&DAFf<@@9{nM0ks*e}w{lot-a3g$U^)N`HbmI6<(I5@#>beL31O-|mkiLC2G z&_bWJ<@Ie)s1IPmbL3fpy>iaJuEoOqd^3-Nw2+V6%1TdIJ^uV}$fO8GX^bjm75?sF zpK!W{-?M1Z{g}@WBL#f>o*-30pX!mZSxr>?;{IlzI_t+HA_JeMoH7)!TaP;~31mhe z9pcid{x@jOcA#g2rWaBjIGSQVj=Q-LL<(X7R6i@=3XnHcfr-#RY(7d8A|WRGrmw%B z=&nOUwb6K@wphV}0{uOX_04m$4sE`pa+b3tm!jw8ONf7T^IuU{HX(O*c7AgjU$Ynz zJ&(E(6N(!7#BkLnw08;18%~u9c%MA9c|fewPtGxk<)lto>xhcb9rRdF=yA=Wqrx&~ z@}IQyD!{my!n7T_dfaVzZSM-KX3GFm1vS+)bivR=;HiM#o33rQug7wcbmT_p;zY&7 zQ1|h&%$)tyD`~LUG6Be}nu$*>tB0(Tz3SLXfCK;4358!(XZB#jl9H0XJRO(v+~_j| zx8R4g=>^T>8+UnFNc(%s5h{CRGq2WeEn(qYR9`Qt5$@<-_VwlWR35;@i$25rrjL~` zUcFM5kccu+7-vJ_bL{+o3ZIjG94@f=CdQqBk`+kF%62_{8ZMdbXlidj6Z@0Tv{=41 zCUv6a$v*i}MaJ1TU*#NCTB^=0r-*m@eG`ldi#et$EcT^vVlK8x{WJ4a8sFawCuFY% z4`;|-5M_v-QNh&+F(1f&4@w*iRv}w&!Bi^{)kRu4Di43l-gEuGaN@rX-pSo7ktg|6AE5T75oLgR&?Ti_nII2V~6cGuVG(LCjMtzX=!ujv#K~eqPu|OG_7yR zGRG@2@;(zr!5UJ0T(jY9G5>%}*3GI7c}Aa^4;L$YyLw|#(t zS!ZPU)3*!Ijc7V;sIMH0Z~q3tB3#rkZTc2uF@!Rn9v;yz7g@cOo_uNS?qXRDH1?D@ z97C_71L$Eg9Itdhz9{S5Od?7hMqbg1mw6xirw(&G-z{Qmvn61i;rPdo+u`nOL(^Cb zP7l`gY9-_>T~_O1(^7yGi5uJVx>~ z>Wz+3bh&9p>YkAQpS|`@6g1sf7ROw1GG^%1wO0MIzL(F{0?N)_E7yrx)~cSqoT4|{ zkxcCLNfT3^O@aSbxfoa`sw?E3YIK~+Dk>q@Zsz7v2F{`G$8^B;loXl_>D2X_G1OG_ zmtjZz6M`sOst{UMu?9VM5iAQUT{sF)r$C2j;0R;;)2Eg0?vvH4uC87s?t0ac_0y*4 zd>u6!6B>%xpY{A#m*?IG$lTHE7i5T@?(VSSy!*3JL|OS&SJw=v?YuA{L(SPvV zx33SLJzCDEVxQ1iZTayu&^!KfZH+yL%SA(vwA_8+ z4D%A<{N!1b3!BaZoK>S6gH8p(mRfbJljtQG}Nfn6h@J7{dBxt+t z&Dqi;7hIKvghH&C15&$6;H>VYMU2oid-xxB+VcJjI)(YxokrXzRYiR5$|B1`av!Bo z!%D>;Taud`}>wIm|scGz8uI^)m_)aWh8TLKizeo?SAa zQ5re8Zy(|-x}+QRT;1|SjU-Eg-3Y&L9r_q!J-w{EivE0R{v>{WCYSMv30F_g*B~0! zuaA5m`0E%_1Q2PRickS>iXW%rG&E7FKSdLSO)xdsMJX+P6PGm3e2T)l5}3L9^42d? zhP1&y?Mk8eJO;K^QkV2pr+S*6o($>9&)0W2HH6vV@ZnJ~ZGf8S2#4k`yUMqf9EhvL zLBNp6G9>(m4juCG_FlcGy0NXH0*?s%rdG($VL_fi=i~{1^9$>4bBOd%X(9+Zq3k+l zi&sQ|TYPG~NVNB(zsnoZ-D`$weDgvh0RjYmZxP0vF7$kA#Go&PgJG;-hVp)jF=jp( z+s!{`=6c6*K%Ke;kf?|*Tx`*b7RRK(3`|3y>*dQ$a)+D1rJVjUcyi>)I{IkAsJrA} zClF8Ii^It91YQF`QEOa3X=`ia@s1zM3rA#RN|9^oPxh2+TDX0*OU)KPSG+!qOXkA} zWUVt^n)YwteG7wo&Z(W}mlG5{Yc%c$|GI;q0Bj0hhQ8e9zyae7HT-QmNXYaGrvbKf`ajq;CKBmW(nLmN zlRwOarUJ0y6Yd5K8BHry?s5#4(P~MO3ULT*4Ea_t_faA;M*Lp(tt99 zf8)suJ26NI0IC#C7GOkLdiu7O7QrjrDzJU*Y-_uG^{U;S!%y*{IFOZ9RXA=qN`aGI zf}E39*4FU>B%N6a_aO0)|Bb+hrM)?uciWcIL*xjUQVOS3Ffg%nRAxpXBN`L}VX-Zh ze&dGW(Lmrmm0an1CME?v4YrTeyxJJzUq3+Qba^hCr0psQ|KwdtQ&RZycma&X|C1-!-*Ht>VJs{ zy+i(Mt2-5!A<}@HfeV(x(b4+YjxW>GP+#$Ib6*scqtaY03pjBUcNwS-qB7tV{XaAI zB3INWu&@yk5ikTm1_hL9l&JTfTQebP`T9(F;Vk-Y{8Air;iwkgy?Y1k3|5-jy(%jo z+Kv=?mlv3K8$_rFYSf;tx*ZByQPFi5)7vfxu#km1$7MZwZA2Sv!l>0ifJ6Q$gL;I9 zP8A@W`wAG0iK%|ae!sp@%tGtVzw?aFiHV5`z{C+&X3>`~)9=~y95z|oOGY3^%o;<- zjhh{+bLPw$q(4bV1dm5W*(i^2vt3_nFGCt?$qlb+vJXg=7$ANB@N9C@rRW{WZ9&tc z4Q1!);{<6!qSjR|qC}1m3&c!4QYLUwT=Y&Ja1)rk@46cfZokIlvG(FAmyKb+9ZMnl^ z$n+9c_7TttxViI4+u-1W1%0tE!1Uj~c>`+=ocM?iCLuvtJuzC5Zr6TcA#oD3iz}2# zV~2#eX5ML&^22$>Vo5?nTtaP)RV^l6<)TL1(aa(hm6h`|Gl*lSa0&5pjSSO<4WIjN za){vFv`TdQ27~aEEsNs#Wn^w)VO?sY*RXc04PGXsP{~sy)4;LcxCzaR90} z7}6GVzz-aZ#~$^$X1Bd|c8u3#4s^{np*>7l%i5E`<4%q~u(?i0J}T{jtA#xuBa`0_ z9p1YFG+K>}@p?3|)AZk0ypP>wRXj=?zx&qs#;Ma!CNE>{Ku=`#;F9EyBVVGoSBL%b z{rk#!l7e}^S5}G?$vad3a!CoZLLH*0Dk^W?`r_m=?eI5std4*)FHvthUyH0FdER&H)CmXC^pd<8}6g)qeu2G|u1UqHbGw4`-#a1oGp z!h`CyTW-G+gqUA`nez~#vvE0m*!*S{TZ)Z>LdCVjH3v^mgCVr*gn2zqV{bt-uatP# ztH;BU2A3>0XI3ud1*dfVx{pp5OLytwMfLu*P@Pa`MX3CTH|t(DN4mRgw{Bu zV(jbJcj!34(p~tah=*)!Gnkinh9wV{JCHwX`4o#lMj&jP-2+Au6kx$L7SKu)o6abt zH2liG(!EpCq9TU5X;;l@P>rXOj4o1a~&n>m(l3oc53DGk!z}$uWk;+20FjXx3 z7uo1A(yNY{T^()RTVk^u4O1>`RGa%B%E-dflC3m{g_@cg_(-T7(@FqrEhiW?QS&^) zc!~|nrcFb*k6^)39Ac0hi2~i7nbfEe@Px#upw0|uxb8~UYRVmtj+}a`9C=2-b zdWuF7``@@}VBp~~g^P^bBOfs9jAj(J_&^jU$UQY!tkF<%)v5yu>j|nz-1UAoBkrbF|ns4#`&a4n%km zWb^BxAq57~Mes_!-*)}fwX))M`1-t~W6S2vm$O%b@>oT3CAVyOS9OG=>avvDVk8rP z;KC<%kGy$}n~512jAd*)EMaeiIv7>6EFJ2%KLoP?&3cT#L*7{5x|3JMS3r06Iqt)c zZ|_Yv-dTZ8S97*EpCW3KzAPL-;7N=+=L($h1NE9ca=EYn;|~Nv4RE`EQc4Hlv*_&5n^Iu zdNFbOssN_&UjvmvhstDk6is;1uEX1xIb&=ep6P38DJBXEn<@%xB~{gfd0Y#vVM$SH zF^lCtjVK8!CHRJ6I=c+Pq`ZdNIi4phs7C0=aCYUDiV7b+J(^ONiMRDZyKFZkv|yB* zDP7CNgz?z30%oT?pu~8kq}dYjL|<1oAD@=c^oO|l-MY0t%zkfo5}D-@Fe4g$$T&L! zCYDI5n6k}$^k@}oYKBNaQCtrP*I6I(YSHb9BCpbKy?-&hdPId}`i|~vD>*!cG@7@t zlD!C6oEe!HYyZ~65Z9fMm^ck?4U}1LYc4RLbE_-L9$_nnkPAao0W0XApza$5c<7En z)POe!y*ddEIQ|VV_GS`r`_i~b3MU?`!#Vu^{d=#gfdSq8ECgKq2L&0paHiNFWY8w> zY8>Y^I}oPj2!%c#u43#E5j{G8=fm^)CLNWu%Awtl+sq$N2W%T%XjiBGmBpT8kX2Mf zLoMI-L5mz$H@eM)yzo25NaaLngj@z4S!f^kb?C8;M&Fdda1W=FC!6FkjlVdU;(qYR zpaZW{E(r?@gTgSr9}L3-TR8avWI_=Em>7GraF_0gi;F0|{I7m8 z+KDIfmj3k5(qwZAl1_i1i{s+v4jDp4cOx^CY5GQKsf?)jo5P$Qs8rZ?m%|Pp^S zfe-W)suhe4YZ!GJ-chbgelTj^#~-vJ2Np-G*+|yW6VJC5lFIq=zjPI&??$0eIYo!}7)+VC&W^UO5;k+6kh*(a{rzhokN?8;f-X=H z-u(PrTrIYX6sMZi_$N#-v%^?LUBCCp|J39cZ*Hj>vz2YNN;{l=tcI~EKKY|J0qH!| zND;?1`-iF7ZKWxH{o-}@wxWD1n0ku)he3>|Cp_kq6@~1@%EqRM1Bq{8&M;P0tpj5Y z&70PyOItq*(yga%VdM5Z{{`0{h{fWsUru30%FN0E?H^KS78PvYF)s#oSrPN7$lqR} zu!Au3e)8Sv%waV~@kD`b0cxgv?=N2%RAvzTC`DdF+OfrUM;`1CioupdI=K}}Y~9L_ zBSy4#>q7LHPo?N+MICh$xzfDH)M$95uetL=A-z{{#F1T~@PJC92Q2Qhhk3f`80eM!Vr*~EPaO$)MqaI}{(dR7 z-9cp0(t1DO5fC+RAAk+dX%t8@@L{Na#ZpGeT>ev|8wv`#!0ha8va%O6l`OEyf|VGe z%UZvJcZY!CITK>Nw59XRu7qU9hSBeB^|6Cz;*Ev-2d<5jW$p7#V7#yiwLGz>s4fz# z%geD)WThdz@|}_QS&tk!QnP*1SmGSgkyQIW4qNXnDK63`ce=LOt{-h_^-%`6CPtSO za2WAxZ|J<(l{O@FRG$}N45Q*FwGc9e%ILA6;1!aYMmN|Kna!Jzg0X@v3LImmUluYY zI6F8DqwMVt&>rKbkCWWecDgyMyZY|k5V1+jZsqazbp5ZLG(#9{*e;O2`T4b}=C12k*IYP&9q|c!UNwoH`7FjC!15Z&0^Vu_+ww{E zV5KpFS51g>)AK-GzDv}VSdX0TK0ZG1?bE)A23_kWOs5%a&an0fWbbP-N%`3LhBo2H zf23$vIIEuhvxx)5CeoHsejgjZ&m*zBOrAXJ>zJC}ZgluJ&`FxTuQ++?)H|FNDE~ad zyyoz$#HV8HMYicy)v^LO}r9+v|_66W|3q6g{1{E*u>q4^(bG-7ryy-A~KxOfv zo+p6;@mDQ__8Jj>gEvtg;(VyDy(7@*^rhz`c{(3kL-CM3nZAyu<#?MhUuXRD?X0X% zG7724AiXD{jJ$-jW((3lmzs@P=X!ag2#c1pYH0JuzYV-D5%rObVD*qH=Y)w%U352x zY;95hbMfxNrClk@2u&@Z5qyFWrZS-Pe#7}MGiBxEhGM>l@XXjbZ!Wt{=O!r?L46TgXF)|bd zlfW-?dMddtKZ7zs&7((TxxTj2VBbC^Xzc;zf3Y}hn4-vXwj*TcKFxzHn(}%+>899M ze~Lr4evd^tXy>AA3m+%Q?h|7A6D$2GBH5K%WlD;RyKg9ZbBg2wkik>9B9(%#U%y7` z!171~lDR^X%Tky41qHvx2T3-%DbwrFkIJGp=NodPD&EA&Y2n)-OtC&XFtY9a`}a*v z#9S)wm)C#p7dy{8<{iN}w6QlvRnz9hu6=Cza3h-5vD2Oc4WkDhi<$4O)EOJmp<~v0 z@&DP10CBvJqo#Hak5Qr5;O6EIrDm`}Jqs%C+nf718i;(+fT559F&{!h6#@+|LS({H zKzZj!+*oiekopmAZ2HeOl$~%2JJDCdo)|$cwP3Ij@U#r3i;Cs}%T8uXI`(4>9AUKU z2DX${Im(4k&q(KMWJob`l=;4W;XYa6=+RNF7BEP8sxI=7Kj9I7GBw&8HB< zj<5iovX>49gC!TQF*R+=5>-oXtSDlJwq|m4RO>`fvro)rysNs5o&=~ycW-al%|Q&X z@=}b^Feq9ftoMqygCfi=D8Qg%0}xFHx{Y78-CKzJ<0z|FFlmeDoRP`9)L(v!f#LRz z)Ks%fY3&5^|Jlpx$gi@y!`yYdew!8KZTkTMoIwt zjMG*%NO^(xA#jKJFM&Nn(`Z3Ggmh^ zV18iSQ8$@+L)-)@@|pAOU;!~23#tU~cY&9U=i_5_Meu;BToY@W@6|BKCHYY9QEucGi|LFFM5Kt6RebazyIF?H+7Gdb=$S9H8yn>GX^qrMzdaL=A(( z(cRpFRb!BXBM5~I;ZJVt@84)cy%?^j2X0~ZQ=havWk~P(G44zBdKNm50#+99?3D=n zI`!?^d%;GURC7V^+3U|0M!vh%JP;lXMO9S;K%*hf@+P_oMpxB1J{(#90 zh_Ap<03F4%NJ4#nA=qi*eNfTDBDorA9YLtqt1`#6@>WR+p$w)aG^bojT9h2QKos~A0Fg#PWEJq)(-t0sr^dm211uy zyoI*1+5nlP`gjgnIIWJ3*8ghv7{jaobbF4AOt__8=}LbImL|)`G6R6U;F)c%tt~7d zaN(h^h%x*x5)=0!k_xaB@Q1xF zEZp1%AEADydvm4D^hLLXRm|lHq#c~x3|Yqz4FNA7pHOD*j%WQGkdYHy%*0_lQw?ik zudR;{efs*9ur~v4Y2EudNPrYZLFsgHLnUpO&V_rs4QF`e$90@zZiNgf6c7KvA;`VR zZEa<5zxMfdeLup-Lo=->M-JB>v}F_@xu@{6VLV{=m#b0DSd*r-P9j;2r`MCV@JF}> zBXOD?9d?h;Z>EYBGv5%mulKTT!xEvDe8_dn?&04!?KPLP!OY)~tS$GVD2-Gb;xU97FAw1V^THlV_ zm}%_cQ4EgmGE1RRDut7i`EM?-buFZm`yZVPK--Q%px7pVLIbO!X>Ri#Z>I1f+9ya+ z>^*|#2?X+$B;2u%fT+57%fBn3Kn~%>NU{#denQt4yE8lwehHWjaHDe4eaSAPdtvCV6a3*D&? zek%xX-#*aWi$9#<&qtrpa3gKn1oKi4H@Cvgg2p5*@{CnUxzJ7kdICX6V z4rfiGpF!;w9vNw2mIf(XIZ5bN1nruj+&*L*?4%)!LU%jZ7(WV62?jz@UJ6q1LzSE8AJV@ zr+a?y{{1z8vw&^EOtVGzx164s5_fb+Tzr9_5YU-DdkS$Ox@5{w7%l2i3W+g@At2h? z0@VLBHkOA<8B;-=1*24DI>8jMQL2F@k$+*AvBn*Qb!3n}FlvyC3Y9YJu;CMR@Hp~g znWoN-LioGCpI5&tF!Ct!BieY}o_T2(`AEBVxQLtwB)Gc2SO;SlnlZ~nodGjoIBSC` zfZ`5M;|8~5Rw4Ejn(DX_EH(NbssRfLwlbTlPe6w9baxWuKc*wW-#<1Z%H)#I>;wiM zAHgA#i$|iE=;<-=av?bQF^&|b#Pw_hSh9eoVp@cd)+lZ3w>-s>9U8#QZSnga3Tm2>J?So6H?2b5KKpM5(L$h1spI@9RxpgE7#%OdL&7 zQR7VYLjojVM;}mVpjZ*U@+tbum!klVyu9E$Lp07+R1Fvmk@aZ6Da5^9URnY!cq1hR z(LhB(A-+;Wg!+vW6yxwm>?*`(?m2YmJe*C4$0L}S>I3EqSWI{XgdPi|9zOYfR2Ojk zQA1|2e>UnxYzZin;9Yo_#NO7H{?Yk!=hQp|g&Rt-yC_S?@VyXC$RTZ`F+tzh(w^Te zPW-ERUjf@3LA8nwr?;mkWScKupeFKnd%Nf~!F9HQg~AbMdgO}u0e+Kw%T^Dwpf_+~ z!lUrDwYBi#iJ1(hk%cnMC_zwfH}O8(i4!N>M~@ymhB_VlLLaBQ%M zW`Cqt2jS6xCPvP+pbZfu7@#a#W<6vx_9$8pJW~;5Vi6h#gPYf3t>Hi`wVFv zCQ%y2{IB6m!n{*#I*(C-W#Y@1w79q@Nz$}`KI_!j*cf_MqPD;l$;74M>(WY0LP{pX9uLWv`dyeOcHA_V4F)0)AUS?QX@tT2&L*UU{ zj*UK7Dh-$owTnZX6@g~euP)!j%iWb{fp;$&^B(w)0v?eVUUcVyEr0+;(~Hg;_&4*8 z7ET2VDI87-U~32V?+=TNjHlC?Isp0yrh`+?y?j#JsOtee;#}u(Ym$_OfgOv9MI2=o z!2{JHj~)dgMWB;Yj*fwBp;1y9pK*M>u4`(5+!J;7wgPA{IRnfzs zswdWy!S!Tm|6FVgW~k6epty$=8QL3oh^-|@lHV~^*?L8<0{eRE%V6x8=oue@Q?0@) z=lo9@7EQKe1PCPucqff|rbdy1D=S%Pn(`NsPEfjFKkaa3(v&T73|oap3CCHC^Bx_l zW;k;Z@GtrjpwmI$N1wuy;Ex+(YnB?$i6Gh5lNcTYX(Gr@GyC)Xumr4u_|oXUb&`FB z<9gi|`$s%IxT=^Gbt+kPi%d{k6dPkRgnxU-J{7bBmV1bVUhxfh+P&TPX4>VS+z zMP1OP4)--QAI5B!EH(11b@lgk1mDlhp!f>YJG1liUjUBmI0whCSAaUfis}<A(yg3&OM(iiK(5_)|moZePFt8gHH?Z9YJ} zlZG+aqY8`+o9N?Oc0rWyb^0{y(WFH5ctd*mK~`X@B7~}P4tvwpt4#ssZiXP$m4t5Z z-P%7~e=uhGFm_)|Obj>0fo@z}>)9Cg42OKafMxk6aqq^2vktt$^DP)o#&4CI`vLzu zF{)szNUceFH9GJNGj8e5A5jooa zgbL}&R{CC#fd8QG@8M5?%!P+FdrBbF_$%kZ9mAN3w2VytSouMsVmH9BkB1Y*L^dEk z2PUscLWmI&(ZB7Ta8CyB@54{}@3s$n!z;ZG<%5>pV|mw)`B~#$W?l+_f5)B9^D+nQ zu-RX~AR4H2r&CH?{3B!%!eQPN)Ga9}Gkz^DHoTpMDE>*hK=>|_z^L}qHqeQ%6!bWD zjG+@`C}nogE2s;fwXyLM3*B{~p=e_+TU1`QlM{x)tdttW=9@vQ=1B^@htn;gPEHWg zn+}dm#?8-pV}j!j-HmAe3o7FHEx8FT;z#0<#;*SUuQ-A9uVJhr_`7pjfF`6IM9`0) zKQSAEceZnrI)r6J$S%7=2UD7#Uxk>9!ZFU1>wAj;*J2f-2}VxA?9FT-JdiD=YXyy_%>!a`526flnJ)9q1oQ4GawH@?G87 zf8uXM6+HtfuaM0RpA+ZZLS_F-n4OqlCX919+3(!7o!iWdS$5DtP^1XTc_VIW0 zrWN<@t^c{n=BR%d?t`hT#uz6SS9xZC>Wv%8Nl9^ZRIy}$H?9M6x^w4bCil!P65T}* zE8ra-MlVG5GVt^BbEdOz+!#j}-~s^^^d)(gsYFJWknDQdxVQq&g&zfnh5(YlGNv}z zik5bh@*L-IXkkBUl(qxo$36f`f&Ar#J7W%Q5oZ7mIOx93CyYnML!Eb?{Q+0RtZ{50 zR@HJD&yNs5bZO3=>S_}MgPiS;cWY~h8Z;h0&#&#oDr)n*chdQgl!w&09e|_(Z=h&8}@X)a&{GPT*g5XPWmdfwmHMh1t?d7!} z+>rkS=4)#-($YdT60)WdqJ`dp@KM5V198?Tiv&c5dGRPxIGHE>136wZ zK!O1PA8Du+nk00-wDoOa=hV?lK-`mswN8C<^QXMg#{^{+dntyrH|_R33d5S2?kRN9NZ1 z<($*MT0Die5y+mrmAE+0Ee}#4{=Dh2F{&#=C~rU==xxyHfO1jS(^G1G&u|$gN};2K zsBG}%OF+ylPOjcG)GcF40IENK{@nOdM~akZ*V5d4VZsJ9<du~727!iB^kKmY;? zam})o!H*`c3QC)I=spj@;SmO`4VW_YOCX*)ZR4b8jWy#zd|k@jnwbY79S~?E+AoAg zlx2@0zu}lBLR5BP;U0qn&xGczR-_|)VD|I@_@JKB*4`V=#N8XUu(mY2O~lymCthsg z6uHU1cLBv@IsQ{w`R1;RAs({z2yPZ7=361)V`R*LH=iZ{Lc0T6p~-5LsoFNe=IcY9 zXIr40z+8U-IBfG^frUMg@qbQQv}9R zq{s;e;PbcEf|_VCPo8P}{98OH7ZHrm-}*QP4p!~G$cK6%u}pjWQES#tlQ9x~TpvSCJYE4VEG||CMFLc} zq(rKRBGzfR!xUUH9o=<#7XvUss9xZpDeE0BJ=*FPIhrk*YM1pM^8STse zo*K^DHTt4M^mY;LEP_Z?SvGotLvREtyRy__X*o&^{lp{fy3CG_SwndVX~ij3Gc^sKP5v%3j;M) zzAJK0&)hvcG^=Xw-#_-I<~v&ME|$1{z4K^(0-PWj$EBuI*%RF?BWpuk)$HsMaK%7` zG)iKr$q@xWOJ^N>?jSCtjt26SpC9HpODNi9NE!pFX=z3ViS$GaJq$dWm=17gAVaea z)({EqODhu9L0m35w+mfnJUeQm_0nG=mg~ph?rM>;irAr4PHZ6B-#J%iC^N1c7i@kuMK=T??mu4AG zk$B>{>CKyoN9BmSnTT}&01T+VGff%DOT48p0~W>+INu*KPZfrxl>aDp@ahYSlDu^}eIJC z*MEotoI=G7kxr#{gm`w=|=Hz((CBz4R(YygiS4MB3+b#!8UJk~Q$^TXt%KWa?# zivpXGU#Fo`m9c56S}oBcYd-|oO0RhKPbndSw`&lqpzQ)|4Q>$yqe~A35?qEqMsmSf zEzHjYKrIBY(JYk~1P5TK<^Ylq`K?!yQJy}o`!<&xSa6+MT*@5V`aoXl6%3Sjoq7KB zsUs;r*bMLs+GIfKgl*S9tvnzwY_pk}89E?=Qs(23W{Ts3_W&Knh%WeS&rZ+)V%X#l z$%u#2llGruI-LPRK&z3*rl~%GBY@TvZLSFW#b>B2#f`7)7$hG3U04TtD{ z$|zb9A3}TCKC4whPL7tA)~qJ{RqPpOjA;OAz-Mrdef)^yjAurYEhPlJ8e!@cn;`WE z5?#s%hlfWCK+$Mrcl~i0Ka#f&nyWN`ICE%AgX;l0)B%JAC?B)2C+ER~bN}dVWM3TP z{H8%9-xPeubxc<;EiR%xx?o?6NmYrU9tsZ)n2Pi53gq!T2b>LC1v-%!{iLBGkXDGr z^^S}a0PuR-c1(^aUG^{lln!`=a7|cVsHm=12dLc0xkDhc5eQ`R<^x&=%wzU2W55AI z1E&{$821XJH~((P8ze6bm117b4eU8EX2Sb-ezY1xaSxDX7 z-6a;5u`En8lEE5EMayLMdOAD zg7%RC8;!w!bWg(U7invL4i9e6^N3eLMNbvW;SfW+^E+l9ivl9SWR8@xYLb>d;vyhS zBVS7RUxeE2>C>lq;lBC?23QbpKR-ZbTZ%2b960MyW$H%9qXl4Qso72wttHUmammSI ztKPJRViWVLJ}Fj6fcdaM2%PF7QI#OMm>3wUaLOwy>1k<$jj?fA8ikp$v3IZ+fU8j_ z;bU=uP!rD~6%ue)6$>KwtGn>H0fI78#8pf=Y;yEXO?N|s11|I3-)H%SB7rM3c63}# z5NduOr0tV^`*zU$q?vhkIg&m~t@s2fQ38Q@1%HM%q};v^cNoZ5yP zH}5B70je31NI@n}U-%HyX$9q|nA?oI3_kV@ldzOOXJ>`jSwd^i6{&jq;5F5r`#q)< z+XvC&G(V?|U55_W`}FC;jMHB3`bs@r!H#%>%Rob;0scdPS*~8~0NW0UIfl=r5}b@p z(AmbIo1Fjpc2nrQ-Z4ojDdM3ID4=(2WB6w3r}uCMT)Z!tM1qI5>b&H$8tIzx!eX`m$g1Q;jSBsy+s6V_Atd4a6h1 zZ{Ma%24HB$r{Z0(O=xW>w~@YnwlteRcH((Nl(~hC4L2KGFP=y^cyI{ebY$&eLJ|N( zXj)1ExS({wKMbEZiB*G4ss=!J+nT;GA$ylb;Dh};z%N_?uQ^nwI6sd`&tAT)$jZ84 zDNI9&Yge##O#o#HSO?5#h_TakVE72Z1zDVh2&T_-Ma+9yAS&k@ZAN0HL*|Xh;4!!N}?rv?B1^}{k>$OU&{rGxTpvVl5B(}sx zMwtQugJNx@8m|VN|M7*ZcsFSyZs?<+HFZhdgv6N{FV0i2ql=?^>W1Y6NGF_cfD}#A zIfF9l)}gwM+~`;ff%<&-!V)5IQ4662^5kQ%bSW);teG&M_aFsN1K+Q!1C=EzA`+vb zvs+^Tt!FJ>MetNmQUZzU2j3_DgOZ$0pAXL`Nc<92kR&Sv{wRmhN++ie=mAj5J`9+P zP%N>=N~R?0Jur_DIGT0x$J|`#!QRJ@1?A<!Eky^H76TpY|ss4Gj%g9&8$` zYek?$*Pyz^)`%z#Vt3$g4E6LN08Idx4@9)$?p?hNz-j~XYib6cKVN_SHY6)AI(KB( z)~bkz;DNyBXuTurW}iVQf$M>zf=Ub0Wbj@u$qq{)&xYU3Qn%GKj+Phg$*!#Q1-=L< z1pV;AT3<b#;|` z+3lHI05YM()-+%qCkozOc+(maQN`{gP%}hPnrC_7F|aK?lKbxMM+e~{hGqqv2cp|Q zD=`kD0{W(&-v7tcdB^40_i_JB2`Q_LD5auoLM77BB-x}yDzhj$BSmGVy{wQ*k&#kT zRtlFCAw|i`h|Vavk|^tWAFlg(UeEo{{kq4+c^t>@_Z^?l=ll6S`q`LQQ@duv7XS>3 z90c^=6QwV8Y`?-RC3;cNE?hvz70f(wv8*Wqd4}noM2#O;-am5%Q601RL2>cLEp6Fg zPn@UMRWDvRZKxT)_3MXy@$t&PmQ6f)Pq2LVGtwy6#(=#jxv?yN-&XcooIh|9uxbnV=*RtZ{cA?R0tG zp}=ugz^k?h!=t04#{jZ$j;VT))?7Pw%!R7d%LTeGwOh^f8wjs1(+pAw;RNpC!dj5831{Ea&Ft^6s6fCl2n6E z_|yIotAOF$t(P7*`)~k^cDq{k#zqI)WAZy&U7~>E0W?@@(r5LsPC_-5?r=d)WuAPF z=Hf!@WgNb;yC_4qoi{(-GgVL6aM1p8iiWYt(s7XTh?{jt@V3gtv&F>4F zd&Z7si5b?;aEGW4YPcjyMrN(w3e>b26W`HdY+(sAVT=Ta7s$kgTEO=~Y!}IimTws* z7?&c(qz~`4dYk&+j*6wZDJkP1i$E(2{J%x!m_!Hijkj7_7SW^=6W6X@T}=w+L1+MP zCVXK8tlXgwuVD?8*^a1T+Fv7!2fW&hiMW}gqdySNceUlIOPHqHxPJYh<9jxUr1Kd^ zS`8jZNJ`CE_PVwfZ_v$iPNxgi<%dpQcbyAQWM&%E#SzXqLU3&-u3p6oDRIqp>`6z@ z8R?E!+x4jzva@fT+Pq6}k>N6KpA@3jpjgE#0XIKT*T zdUcjz-j!d)ZOW5hs(VvCUJN0sJ7x^z=#buf%7q+A&X`XxbNB9;+&U}3PcE_2II&$D@o4|5*DrlNn&PHk*n%eP;f{%g1XCtcc(z5At z$J*@@r<~2qOx(Y}2rbqN9Iq;g))7rS+m*o-`-PGU!Gz`{rve`8DBhQ(tkWu5mhCK z0nC-Uk3u)E8;pxspXw#lxThvT#!^u9mfOXMOSwuo)clqyu!f&Led0a}-4Ali#~Vx* zEZBekyzmwU0py1so0mfq&RX75b-sTuvIa)==gyxORwMm1b9nz}R15u{zURQ9`{cL* z+*$$q?_m1jAHEPWN*50g`FjBk7Y) zOgo-PP5q~|bi}Y>q4U(Il>&mlwh12KO3nU#S6A~5D2(~|v2#*i#~wJa5(1aC9>e{qSL!k{s)Rfgnv!zZ0!m zNDhMFc9w(%*34DMfx%_PiUss~!;})qXjoDV&%+-ZnK&+WJpnEWo1#(kYU57y_}L5K7KN>Cv)*7vuQE1 zcPLP1*xF)2XJc`5IRTjZ0$(&Ck3H((br;`2HxiOW@}1v3?B>V3&fCZLbJkK;RaKC_ zE2KtC%R6g>{*Y3}v_O8Oiz{QMehACS8PAne4(MljjLifcKiQad?iHB-5%(gRCEYgr)1 zZFo|mD}2T++W{TW1Rx5xKYa8AcDa;u;lVH{Qu?k#az0j6+ zi}7R{M!}~qetE9={L2@MwK`Z$;b- ztv)gng=&;j{6x9JlpyG+5+#F__eHBB>@Jb_>xS0woPME_d$wY97^5D z8e17A}@RB73L+6Cva=G<#fCs^^S4b^?pSVNPkHuFuuI0R@E@h?C?J$MQ5c8XH zC29F^9BS0w!6A0xQ?M%>mF?7?b_jJddQG@DOCB?Pc$l5YINCU$^OCe68uOpHF z#M@uyXI+qtAEIqWYz{ca)*|UxH^;B7vmET^N$D}_q^8IY0 zqqOAOmUuf+_tfLZXB;tz$@vUv!^KFc5aoyEw7~|6l`gvA8R6D^tkCIW?=X1}lIB#a zHU$e^kpoUv;J8eB;u>dHCld9;pNm92-NIryI5cNjd}tZPE36x&W;@}hY@%})-E#xk zAFH-;>KYFZv6mP74z~bqF?H5vJ20+uNJa24Gbg76bB#-zzYIx`I6+rbm6zXP`IMsQ z#o654ji@wHLXi=e@F5E(M~Ng;+_pf8X28`c_wSF2rUuU^~r<_28L^T zmKS9wsv4}V&0rjWv^S5ns(jlZVAmgCKU!IBQzR4vqki9VbX5E;S8Yc*KG&Z*Vh1jA zmF4A3RiQ3ARv{AE`p{(mH$s5*k?x_iiqg2lhd*NE>F$2_^I7%Rs+7?lQ(dE@B-Mz4;(LhTfAvTK_FP=J8daF#3 z+1I8TwZ0QJ*m`@PFDNixy-~=Nb-p{Uo-T18@bRS*7wfm6tVWPBSg`@G0NO)C1ySKV z1aOBpMkjlF>oh6R!4H@TQEnpg;5H0*JpIFM3^GAC z-Rk1f(p2xlfwfHx>h@DqoT~kOTf9YfmFLC+PG(X{=s9X!BcptBj-gap9Fme^_nCje ziF&X}D4K-o<(_WF!2@$=%>v_Ab(GC1>+J^VsXVk-2qHX~ z#QO1~xac89W5>osc9;}1YWGi5+SRNycYAw#XJ>WQcScGjK~=yZw8}9D>Nzbi8{h|H_A3X z2OkAsg+udX;Vj`0An~Gf6L&DsB2eAFEsRz`<FxfFaCXqC4&_3i^mJ|{eyp` zMfQ#GqPW|@JxxU7nBaO9(*c7kcx@U$(X2=2MdG7cAsZ1+;8r0N?k^4qT zj8(f=S-BAfm$-v{2CtMWKxgxxL(9#lUC%i%r9gI9?YRG<|F;bL5s?uQ#}$9-!QzmN zAI>(3k@r28lvD^dMz>^SlyF~4G-l?84cUxX$2+1)IyiCR&f+FW96Co#XDIi?-Rx0h z;UoHy-!I;N;Ig|-PTdjAu5t#3KlKjeX99ztjFVEFw_t&#mDO~uSg61R!la$F*nDP- z;8Ty3*PX#vzBHd@60QKKIw@$qPCYS^%>$94lk`w|XXZ}@3l?d2Lgdyb4zegs>Pbk> zO^WnmDTTh&n{=Sqq$Dd|UAcB`0upp9DGS|KJ76LAm}@LHkrkzTfkl8`p}yl5k69Dp z7~tkNqDN=C8*}ZT1WF|8>udxThSwJwWobQaL`cn&m->@tNcR-D^mY0<((l#3Y{#jI z$O*{YykhmLRY_5z=&3Wmb>>|lAt#BR&&sN=%1lg2ab=?$XZlG%_w)E5L-n~ca5 zD4iZ1b@@b*X!j*@G$anul^?mtVc?mJjH8ICPn}x5U_qY*>5XgFtviYf5W5&8GErY! zVpruMDBF%_b4BH^z~5~vp#)gqYABUt!2AD*A2T@l`YIL4&X)~6t>112fn~0YY)@q6 zrCC{6ytuaYuuorCFqxT*KQK;ZC+f=A8wsLkVX-T`cN4ZRd=pQe>Y{F}6KyYbM^?Az zC%|op(uQ)LN!GGQzJl>1nxvF*^;8o%U!(<|WMDwgL$u8L_UKi5`UpiuMdr0I7c=aX zj+Sx*e|@(a#p8;8-ZnYIEhAMfxmS%cJUt6LD{eW5(qLN8!to%cc+WEK=!9aZ@{?&m z=!c%98oQ&DFTtaM+VC7#{)!f2dJmSa>z<(wy&6yQIxxpL(KrFE%4EdUz->u~y2oT)4H4Th+sdgA`X7cJkOC8_O3}o9t_%?Hko) z`mqfgn2M%~QJpW0_On7he4tVPE3c);38w-#5mFlT=Ecb!UPi1o>ah7MlN&X+ZwK23 z6JBgwnK)aBSE*WBXHo^>#O?WX?I5H3iN;M5iS-f(lBEz``Uiyx4wisZhH|CnnaE-E z>PT(NI&=$E`X^F8%Z9oY4rNNki+^q0Skxy>RA^7`1FSMj_M*@CsJS)D*tmu)iuL^2 zQMb_%hnXMa;7lvf3Y6r1@oak~9T1@%pR3j0jr@f+bEUH|UCe~d`qoJRIG$V3Tn2idk%B0O^uSmKIYswaN~^H4LD~ zkH=O{zhcy!PLkOiRf4iu>EOz{xwRf2rb!tmlD) z%8P0@)`=ZkO*xPB%*0ALDJ$#Yix*qz@a^5Dhxn5#$p5@*!RB#YO+iL_dU!XfXXlPv zVW_hwGIGd_47fmw$IbK8Qd3*r+sV?a9G<+H=O33KTA{hMg%scN^((Ss{3iAksDGYi zV^abeBGDNmyZQuXT!tHJtt}Ed-q4_3($thPlq+~$bxhXF9uCYz9hO}tK_qexVncCs zbPvSYEct%(#>McSnV@6J`VKilE{n7sXBpAIcaI+GVaWUcq_AVQ;rxTAXX28ri`*y~ z6+_HRpDPM&HyIfwg@kjHI6I6L<)stQF_-v<+HJZ+`S|B&oiQgk+Ailtp5A_^CyzC+ z26%gJfAr`PGQX$Q)x=|TB%=%s+qvE?>tBTa^d^yCKdV!{%XSIGw3X!#BtHEdo@b73bF3j2}Lns63`s=1F_IA36mg3k7sdH*v^iC_Br(0aYfr zs^Q2)6r3C}>!%sNAN=_lh5Fe)GjP$D;qI$XSv#C6%Nm^CSs7w=yz^5nF_1CW|PG?30r!OyFzlRxJU z>T29g=llMMB0zQa?cH0vviYE$^ykMY6p$xKbaYq}fmk-43O0A!KE-%%j$qocW83Wt zJ*lJn+$|{?JAS;@T8(vPvLf5K=xCGvI#`7g{U1)OoHk%fMlpI-pJh*|FVrLcup@p?SlF3Ur;xB6WmbGVaQU<$ zdW2uXmMVw}psm{jjx~NW+q`)fE6ipWse9|8;@Pz6Tk8iIk?q`t3-j2` zQN99+)W(*E=BG~`%7^88EEFmb1aNr^OZH#L${y{g7kl=6b|-WmTTzS%hCz0%8w#(E5;_`O5@_8uG(o)-Y^@;xl z{npksvX=BQk-@>ir%$h8x7ny4(n+PDeuOl!l2Wj(9+=BZ|B+J!o>@Wn$BC3OK(@jK zU^`TYRP*8vB9mlNlrh{pF9|ZGY@*;Ue!xWi)syZWbrB~mm_L7TqN6OypslLl_x#-O zA3*R+D33w-Mn-LZ;W9%w7Y9c6p~L;gsA2neh8586rsfV%Me^J5sF_s5v}8^B?b>Pp zHMiigJq8>wXRw{y4OVHX-49q}9q=$e`eyvhLXBFVD=hGdv1XUkUcm*Texr=6Y{0*_ z`evV)|KXWu!Qcds!_Rc@KDDntFmgai<)UioYMbsH+}gV^jd!p8tc{!L7F_bQkwMYo zr)#4X2l&gh#T<}R?!ucv@jX)`=KJ^WDib%P=C2vGtHl6Z0diK{q2GJHia`8?-4eR9 z-m;>#*sVC-9iX?vC8p;U z1AT(_ddz5L5`;b{X3CK6#Rtl70~bM2eP>{@2LYQC&{wRP_OYm@A@+6>I8Ubo|j{Zmab{$X}(jstVx zAO z@m}85=+R9GKIp)@C@A=B+LV`*qZDGkbnDmNt9@9BRRI^5JNe*cQ_aWL0aO~<18k-Q z{=TYcp}s9%EDM5rRn}VGt{MDaHToHR1?w3O^i?!58vN^Fm&fukO_mg+v1Ct zdNIC7?Mh?Gdz%{E_4AVdTT=PG;N1e;gUCo^icLrKdAlO6si>Hqij$=C54gG0HhV zy&{*U*pq(-i1#il{I8)Al0T^FXLs6ppO2> zVd-!CdGlI`dp>Z8OgqmzJ%J-OW5zv@;y=G-H>*3UKf`EGd9RSWY3E_$_Y1k3rL849 zzfk932J@cj*^?_>N3@O#i8Ve$htqJ)Je&;ytMON1*@vN2VK4Y-h#QT@0pRo%_1)wY z6n@=Z`n!C|*WpG+P74CMilbc99ltG^gco?N{1%)4cumk6(| zud?+KCp)!kPGj&7>R^ISTyskpPz zGMe?&z~oPf3eB2d{-ws+7J1{@!E5qT>hqT@S?B3F)AHHbzRe<$Ukz{I{~V{WW6toH16lUR!hRMPXsmy{$^f<3E4@*OdnOoSzNz5nd=O#CtbWBJA#_I_yfn zo?J^ga?H$e^g9(B{c=KE0WNMj808dN#tQQ`#tz)r#1$O~* zaX^zO=tj(ShWrla=C*PzJbBK=xE)g= zRMgM}e5PUGvR_+U`^fF0my@6Tth+__C>Niovi#yb+rx)*dX~p<1mLKc>)dn81$+_- z3+P7f_imC!blg-)sTE6^lDpYtEmyb&azE8HC8g{OYaCla+}l$Xx|F~KmOsIKdkaKKsHAnp*f7OguzKMWf(vD44zsamL;(?6{Pldtd{<}6(r2w)|C zqfg(6N_6n*atn=z6hquDuZrxW-ZWMiMp8V)Ozf(+M_ST{kz{6DG*x#UH7}Y@^{uID zg-LM2j!2}R!ie+21LrRHEc&N#6;IEFCAd*=B=wE?*L>c*y+jk`GBMcZVG$I@N|;=Y|_JT9@zGMz6uf{ht=qH|@mH;k1QPrv-`v0mXC zkEWiNJ1T2~ya@5nE(bBL@sJ_N3C7av(m!$2{`s&{-w@{t@Xn)0m+;TBtt>${=)MCe z2-|!mIxPn=!KOa&_x~XP%bipOtq)j@q1;&#eJPHpqk4sOQ=4L|%|&>~twu%<&+;Vu z#Pf+DYLvgc4^L<;@4`W-N!|7cXWLL}!`s5xRc>yn!NHwWm!IY%&YfF9XUKuFMuu8p+9;{+k<1aMVdW0dFqrhevu^XwQEQ6JupvjWUG+st_2j1&V^4= zw4J|rv4@+R`4C^uR07yCBR@nP9`Tbx$r=?&&&xWKCnJ44J^XNw=)9ZMsI)OFc_|=e zd^mcu15-LbH4XT}O>*le03-ns;{Kw`CAGe7-K>(l%YEzEbI@mOs)FsVDO&j*u zXEPtdRt&^Vh=8;)loFK@r*C;W46aW|6I(-H;id;65ZWUibgoSGxU5khrJ* zeskoDg&AL#F_Vl(Ft|&-jL04BHq|qElj5-Zr2AP)`hGj@CV9OILhg(VGC`6KY|cy= z1LCwoqa1S^kUO^Y{CpV3i?67=n?kmkrUTwdJiIw_u5ftEUKD#3-?$+N{|wU3Ujc`V zIWVdyz8N&SUmf6t+sWXBb}5rV#r<4hD`}I$ONHWQd|ktfD+{%HLC=?Wd3A&W18Fs# z?ZYk-^>rgcKzvZUpLfs8gPw;uV?QWoXazk2APTV+cKL>leEQ9Zfyv&*k{H`({*q(0 zTo^23L3hbn*y=-UwRavU>`Yoc9tk7!i@$_GtspW=VK`@tz1l4G@{2sGYuMLM5V20p zj>l8V`oPaDqDh#!`>Bv`IWtC4rLCZqh*K%TBr1IcH`%#!zx>Dl)X_Pn`oAmbdL?cB z3L7!XKfe6Dn!%vT^rkntpb_h?26g{0>fdwrzmIeVqMpXsT06;IFj&Tl*HCV) zDX+SgDxkwQQCf(UNs~X{0_0e$R|#RVUd4))Q>>^J79!Y*>Y%nlZoQ!i=S?f!=S^2t zVQ`wY?)twdaG{WCs7@$~8mwMM5o?*iUh!f;T%JLNrns1LhtSP5s-?Ey92haKIm1h| z3MhVnyG+%tuTGuewSGOME{UM%!U&PvSoo~eR4qff7lTHQgk@By>+)5GeiG`jZ1$?I zv}a1?anIK)D2rOMX-y`$S3*Z{0BLX@t{w#lz64!re0(*nwnW9t4x~CHq>J61kCuxa zdI|MD3`xBXo7~?~U$7aitc%EQPgE3J=&)#bK=kk~9JBaC&suZK`xrh@bsSU7AI$3p zSPFN&kdsp^5va$Op3fmc=`!{CViX<#oDWxvBqNFOm{BUK?ivN7e#}JOL@GP@5 z#%l55!>r`wZFVMoy*zBj<4lG7L1eb+#*J)LC?c0jWJC+`eiFP%X3Q9=r?+<@yvdW*MH-+L@4}?)2FtA_SR}PS16u^G@e6K2fQFH}Cjltbc zhH_3I69cpCj~qQ}r=4^d*{&xV8?n+;V0;~W*pk(X=#xx@G-U~x$2GuXZ9(P|r=?40 zrd>5XWm;$VMXWRv2>V=dgRCuy$KVP2MiEaO^dfH6pxvMj*QBX7(Z2@ z%whc2tnni$8g>dT${P6k1$q#cV$P?|*FLySypkGUG4GC=jd@&1AobJ6uNT(|uj-2xBGqZ5CMyD7u|U2y_j>d5`|4nTEeaJ)%3y1x#g?Fcp3XfS@Mcri~4D?pR?Kl$AJb`U$QK4Aje zB_qm<{&tE_D5z0!mQ~T|_RVi+YwC#;a=XkHJQtBo+lStZW}&LDl*R9I;zImRs29d2m4o4~GwBGa5sf5KXk zM3q7jiJdYlR!Mdel`4WbFxN=?b3w<$UX{(}I4a5Qn!M%Iw{D+ChQ#i_P7()k6>lFr zaI&v@pD-qxthGfKkE6_^)F-rqT5|ox{gt-)8<`!3;IYu?`7!r5L2a|S`A#UX`nQC> z8>Q|aA@S(`>A#o|nFkw@CKY5OcnJ#3n4Bt}m9-x~VnYo*Y*v@#h#nt$W?8|yHsl?l zs0NL|fpo8@5aM5)MV*K~j=vpFApO~<;~(jfd2klb&>+o2J_*Q?CFS(sb#1H!X#qE_ zdQu3Jgh!DYkO_G4b?gfeMCiRjWPUszC#CH#Gi}kL_`k*1hy>>-DAE5mI21Cz#n7YJ z!IWi4AKt$oq9lip|G`Ct(xN*^671KTj&yxoRmIdX>Y`p3ik5(-N*Wq$t&+Fu#BS?= zsxVsrG$%s1uj_zJFfs8Ki%aj{KjA%j7VZA-W*z~YFw|C%h%!JmO*`p_mygY(qN3f8 z-9I5=rSx~x6W+Pbgb4`Zo?pNIni@si&9CP|e;H-njDAzL_#z)ka89P$Alu`{z}zV< z$ANwO9$K~RCwIiuoM!k6;FO^Nih(hY`JyF14n%I>Le?%B?S+V(`iDP~VG|Q`a~aiL zt-N%(SMS~lMzzoTb`tr`+q}8$f$^UaeZm&yH$3YN6w+_}DvaCuC5S#Xx3&&Z?A~yu zI_2VJE#VH*NH6IA07blo2KIHm5=5_#L0TO=*pGS4y1L-Ivy^t7G1^{A(&I9gX05ky za5%&r%g$#1WCeOi(=>luz9pJt3VLQN;*XxYe6!=Lw29vDv1iy9xm_hS&ze~@vx+Kr zW_u`XBsw|WEp#Po|D%MtQY1yui#lj>VrRI^<|`g8;Ox{@4cW!R|AWCL%l!KXA27!; z44LFQ!#l(Nn=Ldb>H9qeUM74a@IK||#04eJ9lL$&Y|e%OSabR~iHtVhg&%Mp0s_v- z&5ewTYI-=by5#SYB{XfRiRA9_h!ywaxc{nX--Z12bLYw)JsPjBE|T82goTWc(`KqI z&Z!!t6Kk6J^y=pG+qRkF-XI(i-pAzi8#ZX{IQJJyeu*d-iS-g19^NzU0+iW<2M^K| z%U&2<)gq;wK6~~>O4nWauYg6!ZAc*qh*x;?Xov_!{$H~D`QED^xQsz`iUqz}lH!rL zBS!$=HTr~MJ9GJ0-N@m?S#a39ejnQ)eFa2bL6qK0O|9wmYg4P&3812AR@Y1KS%NdG z;QaJ(wy(BQNz%6?=vm~AzBD(JMNBrG%*>4XyX0gMqDpjV3GWqF?+P#d!gZ{I3F_Fv zlv9}yG_dgX;D=S7pDy%3b!Aok9*JD7DkE`=D7#i8Rvmo)ENZ@(7*WEGqt_n~d<}BQ zQ&AR)w_o9xK>U?Vc0q|q0T}jKZiSOlVJ^JUZFeE8EJqMS|ew&B`C( zgLmPMAUoc_XTI78vIC5q5h;-M<`r~{07Qij=#VrUrB!!rjDC1=md-yT<_{tgfc@9& zTvZHpD;oeFLGklfIUy2V4liB7K=`A_k5L0ntMe7a_KzNkZ-w?2cer>aHy6>ltbXFh z8AlfPT8k17mEobt>@tK)0(?Ywc<~d#0|i~`CSm_o!j5&f@&|5{AHwzjhQL$%dn$pQ z)(_DJ9i%YfSkZU|Zm)hSO$?F2vnfoza9Kn|1cYb7lWXHPHwmOqiGhkw|6O>N6Mj6A zbS4CCGK-P-TRdY%#Dxn!e}jZblFbf8-ULI{c+(o*9>*IcC{U%QDHe{TW*J$)+DBY9 zJe9W{Ey^D}dzz)??n8$h=Fjg}uAq^*6G6oO@F9j~!#iaZ$p*C|3;kOc`aYweBuJ%Y-rhAz zV{0WBoVVVcw|>63#e=1C;Qm6!al&p=7%4}F_g50~8wjQ!9@iTa-d|dF`HiQDr^Cp(NZC$kt zclXK+dwH8Ww-u>_wR#V)_i``zm9Oq!EL;g!M!8FuL|{txE}KAozJHXHA;v25XP-S{ zqxgUU!(*&*4TYm#T2MWDC2x!v=+?u|Pq}@7C@AH`%*6X&6js-a96x>@0LD|h?Z?7Z zCN{$q9#8o^h1=NO5ZRXUgug;VQPBNiIy%_eGLmmIeR_)b)kjaBSO*M3mBjm@s#?zgnjw&~N4HIb?=Yy#l*@btaR&#^YeaasR+DC@PMwI!z}N^s;-+*K8LZ_xT^t}jjEEU##B<%c z-vUY7DIaxe2E8aH6AG11E*w@5pXg*|60=m9WL67_H zm;U~0bB(O?=LhJzb>3{NCTA2rj^EviVNvhi^bop+*BzjV&60aeo%`xMIut;0P8*$6 z`9z7*x|Yu9oit)3f2Hd$_a`PcH8Bnv7|7uP4X=XsuLLb_4X9qO}jqn^IL=hkteDL7RolQbGSXui^` zw+s{f8&X~(5GG*TSZdg}G%bH_-)rA(bhjLVX;#%E;5M~-C96pt)%291iH9M5o1L8T z)+EZS>7`Nftb?(!X4*-+=<{ibbcPQf?T}-Ax#BC@W8S;aEgZ>^DICs&QS*!U)_{kT z#p*tM2z6XO&eZy-;vG2fWm%a?op1P0GYp(i zpbL?k(?Kx^sOTXZOVNGJJUK^yy|c|cJ76#hBt(AV?e~fuEoaOSR_&54XF}%p8MRoa zR|v)k5*4w`Gyk0^e|1BJbrcCfHB{p;sImIf34aqlWMpP`@74{R^?Sz-pKpVMAjhcJ ziHh0%@c#XEAfALqaW|!9s2b@NQ&ZGCkt=^d&qCX)xL1qHe9+7AxO@=gooU`b>Cg38 zym#cv9eos~uq+()k@^niyV4J2ZMDgn`m-h}WyB`@!fC1E<88D>zVA^+5CKt}65o$| ztE(;e*4gc+GX+Gxo~py7^>20}AW` zGmCdVDlHY(VhD{%Hzicf*Erq=F_*n(Y3*3YjZCF|{4u_RN#B@dm6VVCldwTyaj`lm zP$)(46zGy5vYW&Ou3vWi!dQ=-lP2j)kX4jx5!o~ktbJ_m7QC0 zuRli~;}F-2gN>W7Xh_ck0xZ{ze&C^!PLp?urpMpEqRY!OnVB_M(q?6iZMz#gC~tCL zHD8L8gk1)x$is=ES4SXLl=}9qnEjhW^ziZHTk(CuV-FnynzFGn6=KUKzvkKyI%!&1 z;kfU?mC4`V-_g-A-8pw7GuJWJdvQh84yvFICdWD~xOMK~v16Z4TU$bk-Dd_qGMmcj z2QQN0Y6_h{hNu-=~5D^#qQlgFxO1&8yz!o&D^<)32%*bRkRC^2+1hqn_voqB8%Ohy3S|e3^vz)5QbuV*>K^>qQ?PsI`6uHKdN)p#Z8Vx4&Ks9^^j9C+c(or(QCH*WKVvBlE@=qfMa5?kyYii21=CvdhK?RRb3~uGdi{iG>G-xd&(WP$ zSuI}toIzbll|s%gPeG2~zXzp~;hio7HcH(iBcwWr&DO66$qi;o;QkTcUz%3tTpWe8sQOy@Ab~||HN6ortxih9u@2Vdw72QUOM3koaxAqVgp9qv&c4xkLM204g&TZyJK&rM2(FeX!$_vhXX;*H7D==61S12huS3`kCN}gu5 z*%=@oG#-Ef`MUhYL5up*V>->}fUgx^^9&%NS}#lmNhDzMEFi6z`28LY4a&{S8{+ho zN?zs9(uz|w4s!!$)m6|WfNY>15PIu6-}HNT6vs8$6dm(h zGJ4gzbqED=&^=UCghfQ0J#!`{O0uBbPiDcFW(m(UAC{{z9FW39es2~ zpHLB>GS^5>Vc13=VOx2CP*F{E4rdlxZ_?Ii*tD|2ZiQCuvJDHmi&TA}Sz!SYWu-`M z&SM^a?b^nSnxWgIEwb~2TkHEt)_hn!qdB4fWqp?~nPdOW>lXK*c5KM15AO8}<;I7+ z_WsrrPTGbyYh%J~^NUYrezoYbIGf0h>^?*Pl1RmyK~n%9OfBZc94D5LH{6#kdr0Fe z3`05ZYM9`eq2+%&F$MS(<>-Huxm@+nG1zdZ_c&tYk9u3w)y8oEvjDoP>WRwFzHdq) z55xZo$u)H%V6j$e)Z5n`BVTl-Lb!@83e=GNcTv(*f(46Bkp%RIsH=Ommj8)_O{pe6 z_SW7>YxvNS@rD&nCkHi0IVc5w^zv8JI&&gf&%HGr0<3+gNjII?l8`(u1XH{vB_WX; z$oxAI)d{G!jHm##hYet{|30I*lROv12;x1R`UN-`g>Bs3kkdMPxt47m+@WwkPyfm8LNEkkn$-`ga&XXq>|EkG9FTsfbc!Zlr5ARUKel}!H-xp|K z!H=Q7ryz>&x5d7E?fD)C3L}K3eT#j|_pU+Y;yLDR2 z9jl_ZmUXL^s`bq};YZ5+B^H2)l8`!!YU?uQV9~MI;as1S3Ue6Hc44!I z#flX}>MNP9GFMZu^+5;3k*k<3Q74<<f3ZY5gkxOfd zRiRH)uB^w1*n9})S4rE>Hchqcdpn?_xnHxB<&dIUWr_CI0d1oUAK68B7sC02Ft>lu zJ3x6$bo!25l>JY6dG?We(@RB0!SeGUM1HqD2t;<>`25nBuZLrIU%J!=2T?zD;!4g! zdDmj=DE0i*^Z}W_n@$qvpbcS}><*4GZHyqXL%ej|W!6n>PczI2Rd`4UI~vE18~5YW zl_rPEQrd(kK5K7NL{Jp1TQ`aiTI9OWT{B8AulB;ZbLuZ{le_9uwp~Y>*#i?AtK=P& zWZg8qlOa1?^2Upzt;FJ-)2BZOdJUNzSNKvGp0hLOeALkEQxZ9N@R|?qvwLUnx5%zM zwO>tIq0>=ApU{BvybFuEyN@gGBz%8q_edw|q9DmiZ{9>`RJdQYr8Ak59U-8Qjd z(gmNd)9-7<|9HNd!H(mJEo;|S-gLVTP085m&qmL{A57^Hk@$SWC#@hW)Qz z+R3k9zG!A{tp3JJ4EXwZA(1}0!8z9?#@6n+qj$qjd@1P@jvmpqc=;B!YGbQ+Uy(u5 z<;xEI{HFsTifXoe5n9mt9T#_9UT0gWS)4KQN5NG8=^w9b-0bVigz2^SF2P@kdDOgU zV03S(kMsubKD^K@x^tXoejk*!?Pc8dhrF6GdbxT{mG3XC2mffu*NaR9du{*L^x$RH zAa76>Rp4AVSJ(5b03xV=&P7~t=FA!Vc5-N#?wd|xI0#|eMxm^HsQScSYup$;`4@^0 zFSid~856!GGyanDuT)R}O)=p=Cl)s~l7ztkaj+gg?nli^a7ZR=1}Cz{VBO2BtFfnY zzTTnw8>JKd>caNHSA^o>)7gm|&45YbjkUG3%DO}znzV@xIco*%P4p^{iR9H|Pvqpx zGyJTXXtT5Ba7*%H(-+&)mq!&wAC80EQ&84TMQAi3*l>jIG+?nad=dK*-Q?xBeE+B}fN`4%{1q8d+RQ}TV&9~5f{C)o5{Z?3=0HOAH=DZ8yDxAO7mMKS5eD}!U2 zdWY_8iM!UgV^g1!-!*@J1adh1C$*^oJkc*?k1iQo6Zj{;Wo+9^COt~#G;G(1KVoqH z{Syl$ZF;)8A6{P3*6pMGi+*h-Yg#W}8WJS0eA!y7*J>mF+O=rTGHVvE>dkD&8?EVq z(myU-Tg;nC`g}O9Co;mvWo67Y?hPo3=mvbcK&#h`1jBtTld```xaAb_jg8XPJ6n!2 z8dn=}^F!~AqA76~avC1L-{+Qo?QVbL7@KJi&T9V4%(yxN&JmQR9*1Q`P0@Hai6v!7 zlHjq(cG|jM634pAsy0p`G;aNMugX7DZI#onV|QHVob9ez^MgH`Wi#7zyT4qd#RAXY zDA|YD9u(dx5MQ;F*7v2JKLu&z=bPP2{;kbw@Mm$yph>Y#LIl;XRJpQ?Z;yCY_1($$ z?^jU#foSdWNR#y-LSIrJ<$%1V4act$N^&LEJ3_tX+mboU#+WbdH!au9`5Eh0GB&jR z-{XI70=}1MJPM4;?;tJh7%&lm4W0V_(U(45aJeOjQ1^ZN{6Yt{3Dtjb4K@g(w=a;( zj6dg5VCwnNKQ@0wn^Wa?M8;89)uB*Tcqs?acQXA3ty@JnqVAm7jDbDi zR+w>#`M1BP;$-`$;Ux}f4gt|ka9+ZJsF-Pd@9tf9Wq^)f%p;8cA*@%S3>%05e(CS# z1DrCO9dtMA?;ct-eeHr5PdZrWPE58Q;!PW&JN)f~+{arruoBHdr4&K@vIQ=7V=9rabtykyFqifMC(paZ zBq2_fqh%^I_pj=ei8jLA13xK0;m=Z?*ng!$-9|yHf zIIE&cVVoTE3&hkk66XEmfdSOi5;@Uqb&+Say|_o}uh&nUDt%XRT|uqHN`@URbbSjz zeK<7S1tBuF``Fn<9zXsLCuoP7mfkNvLaB7vCa_iFV^?_W*0`pbaa+@JGXhG4lm9J# z(xy>7JX7hz7%)alXrTwiwqkfWLX zdjH_=2#ahw&ea1XIlPciH>D3_rfN=YmT!s66(G;!$G;xXM;^&6+@R?~0Dki1`^>=h z3MBZ;+}-`@xE2XJF3QS2qwdvg8%2M|;ymlOYDv~b(mJuTk0d1E=woIz@hlZHsthdF z-p=>wVTh^6ETvDqOQ_}F-xzws%IH_on{cgZi@Z6GdWi#a(r;`5p>74zu85#HUo{1rN z><*1DVZd;Qo9}3)l7qYDyEFI3lJ^msb2YbFn>hG1S3fCri)>Qqb^Fz}EZDPKF8>15 zO3WWf?rBti)OTDVnC3w762Dv*=lku;F-zU59z|n40e836N3wi$Lvze5efCEz}Aj7vwULo1K`Zv&^pT}dsAS;<*N&)JBDo;{l5mF3$-D%>qYz~fF3{z6xPvf?v?fw+;v&An3U<^onm6Jf1wwBh z-BqxJwkEQ&ttk!nJnp?ywu^R^?S6iP%G*7!WfH#z#58<8JgEP4xk9X0~VCi@nLBs!{mO}yz4P|~B;=P_Pt0$|tv4(w}}+3RP)h0S!L z=`6pqsA>il`-ZD?u=dba6&kX&+(9@4sAqVQ!i7|J^v7Oe@rY5QPI#;5Q4Fwe7PPh^ zWMI)tjYVyTcb%Ph&3k;#_&#TsKJ^zfTW?nmEkA^Y^iJ2K0e@!u@ zj)1Sn(^{5inmNZ-KFiNuRaxC^%#*hR;pbT2_~ZBa)iw@}V@8%{gymfYNvSR^wt|J3OE*so_qG`qer}dS+GsvOqkmo?iRx4_8sz9e_pNnCo}G) zx!cG3#*eL@NAjKTtEZT1g&p1_?)cwB8R4M7TTDv%Im^-UqeNn`wO!m2Th>3+O>UFc z>pj*}BQD>n@6;HrY7%*1)oLBDRi?kki97qn91C^Jtnv7u*`qV1)Ku-DD^x~HuWhj@ zkdyKgDQB*Ga*lC?0kr1fthJ~~3|H#WqwvAn$$q@cGTCzdFmsLY5|POoZWDtKV9w$W zhI@W?EAh<6)zGeGayO-t&gDj!aIE2vXHc0^d1*k~|2&agU8*hU61h{Y5FS`lvH_^P zOMoDtMwZR-piiXm47J$Zsok>u|8?32gHGP!y)?^@5hfYfEaF@k#G2iMVntFpc6TkN zsx4Fva>olbl-C35qIY|_@zmshB1-z7+n$y6FY5{=IzNYKY3&RM)a|95$n^4#NVC4Q zJ!=mVJWv7IT1f>3ViyH|g>OSbV&crYq#bBzGw69Jy$l}?AJeq`o7vycoiD9y()xR2 zb}16BMiyh>x_GZ&(qN?$rH}udiaXTqFmG4-@|bj=)9-?hCjSHGS%u&OJsiT?j83A z3J;ZqxQBoaCQ+p>)KOw|f?W10p4tQ@5D6>S<4&m?Q5Wd|`1uLN{ElBn`Ho&t+UcOk zB72kX@1GOn&#_CXyvs|Btol=Qa@d((Q|8Ct{g@X2{|cAGdY`b&-?nk85CuD%(p&P21O9 z4{Z}ECRw+x!}FddHZ5h#od0k7XUi^rk8-~OyFZ1{ti+zTw!BtV`aB#AgK4y*2!JAg z?{)*La=`NFzyW>Q1z79VM}Ah&0is;n7jTIQnZREkmvZW$Uu(a>`bYfth?aB~#N?%c zzxJVW_VUf%fAHWc50}YhUauY{8cqFXY3(^;27DU1??XRHzy8X@K7Wc5DJ`%KWp>_b zM5WIp2!WC8aAc3pKOGN?wpcm@(f0B&J-nVfjaqUQ#~xw3p|)So;5w%=C;PE#&K;CW zUY@;pk%h2rkU`?@Gyj|VVYJLjJLteHt+^w5Z@crp;+ez30ym57{SIoD*B0vZdPrZ! z_4!VhF3uTA&HwAenatFv5@TUU*rI6+rcnQ8kESRSYH_(+poB4p4#5`v)kb-B)z)@L zZveI0Yh(mB^Q_~->fpdf8WR-$O|PC(>)Sq50da$5po}JK~MioICT30MNy^ zZwE&<%h%6@&u2ZHMCLQ;D)j4D{iRL$0ZCu8M>whZr+i&j>tE3Jen7iTP3tF(YBV+6 z=0B}@1vY;|{)=}Zi?A{%qlc_zok79X?x=ezQO5kp*Kl^@P_o!8Iy zx0_4k^yivyR_#nW`D(Y!!;6D^EL=QGOK#7I?9OI;Pwpw*6x@Gl=)&pxWz*+>`=#0~ zt^MV~$MavznE&FMc*5$fvyH~!vf>eI6DmIB#$fFfqUb zPXYVBb+VL|i8}HnYGTXw9_1tI``)5qEBtu*jI>@!*&n(DjB%7|?weS99#2hu^6Z(m zwsu9AN8MNyt8C;a7F2g-1~1NBiN0e~Ffh8aN9<5nbS%u|c2Rw7EI=y1zE)w5Es-hL zexg+|YU!WWt=fyq^FA-M9i4NW@4!wA!**LZO`6(+^+0T0CX{^M5H?5i)R1cIHBp^G z2J=t%He8U-iB|9)&?oWK_i=6wYWTSuE-slE1larx*_ z43p3!*6Xa%_lQmYgLAdik{+^yezj23egE>Ur3EeJu!O0NUXsSlV^W4{cUFUQGtp&OW1qfpQ)c5Yid^55q z+ba3gF1@vpuSRe|p!++3K4&|zY-g<+q)owpO)x_Q7m-XeoS-L#6adu{UAxH{fZuNj}&iTn)JL-O))5Yc# zPj6VUruu3z%V>%}(uf<~nkL4YN0UaCK-Ps}{1eTl^Sia?Sos`&EiK)Uu{02myig?; z@5#T@?b)l3i64InIiE7lwC=9;v!eT^z7xhDm}-;|BnI*a^1$d{&7W~WW1$DhV-z8^ zrLeS61&DEjkG=|)7@}-guz^-Triv8P$<|}$Q2&Gj*g?aLVP0kZE*1%0qlWR6gLyNujRA1W#v+@ z6rYwU8o+3QP!5Fi+*b4BIXnaA8J%GMe(US@R5tCF79tkx%TRXX-KM2#bL8t>{=IIV zg4m6_skDN};}F<5G4cD|5lg~g)0{FNkF{KBTX$sa_uw*S3w=-38lW#H`U~k2(7eH4 z;jS)&UjuHh^F*x2>XB7W6#FC4*5)NmQs&;A_Ad7vdA8y$>OPdYEGlL#K3Cn%qe*RRt^qPHyf*{^%!}Jf`c_7;kj84 zlA-q+l=Fy9ai3YJ zEdAzyPr0l6elYb4z5O#REwVzpVq+vDijOlGCx990sXwv{3R;o6ha4_EKoTC{@zE5M zd)CdT7d-inC>~L=&fp697Pviu+*Bt;#EgnZHWd{fjgCRL@Z@+8b?L0RXz~4Bkh$b!aM~|H67u$hld#xf{|@s(KmxanitAfYJbFLAGzA zZt?~TdGq1p$KDuD2yN$DL(OS)Y6wi#J>$0Db|OOPzIeeCp|eI_^tEzw)VuDA7X;wy z1}k85Moe7%>r!lhs0BOW23<4$jyi!nybFK`E}3+ETZzTk>~h579Fd^mzPiX9@o#~F zRJT)F>2e+HK8d;{$#*SW+p>X|cQ40wxIl4#_wMp3iGKEV)d&vb3m zHrLl;Ns8Eu8eq!rc0G!7pi`)Ur|ooK^hlc(8N!^(Mk~la_hOU(c{p;QZ-dA-`E2GVD=#F57bD1xlyZ9X zez}>iS<~N7aZG3chabEf+LJ)zw1NUkXsSY_o2r0hhlpb=k~E-iC1t2@0 zjvkLN8N;NsiW9j$Gf28FNjz{{z~|=`zve%VDGEhBjdF@+z|$ApzMWm>X5Rj`BRTdK z=*=n3_guVk*Pee|Yi03L?FNb?W-G+8D8a^zYZ%dOFzLv-9u@o97zBwSkkqC}$Vhx) z+oJmY7*3j%h@QM8GGU2r@ujqZc8BaRRlB(41M4mPK;9?U+oJC<-@0E0NodhJ4?|pq z$?8Bqf=Z5%w~iXrC6I-9X*m?R*D{ICO}Q!RPoy1VuXVIS3BWO>RcQG3iPyPe8ff_& zqoBxZ7|eLFVfh}`0}uwv5EE;hL>D*7YD!U}2iMQ8lEfo|U-(35Qn#U5o~hn67inE}kBJ7j|*~ z6!)o(tL-`lA^t^vIaTpNG3?xY3y<)$?^X{TLdo1|QrrLCt&nTkYoqT2ZZO$C$M7 z!I;5{aooE`2V00ey}Wt=O(*UyqfH=FR*O!#RQBCbyCcta{*lVa$H%E3fB(EQ_U5## zs$-wH#nWz>Ul&x#nFb&93zoP0Z|$tEUL|uXM_warN2gJ>7iDqtf`ObaW?i@>KTqA7(C*P9J>>9&8TQ>PL@aO6lH? zG2I}-MU_+QM~yl@ZkBtGWV@E%&Pc5yTDrqe*97y{SH0ZgU<7Rrd{HZza*@Q*^9Oo; zb;Gv)71xMhT6nJs^yM3O?aWp~4+A`r;`icU<^(nwl!b?{JNTfAx+_gpNVh5C`|WKy3wRyO z5wTbe|AXRVL_n%*_gZPo2LJe2nFh`}Aj(rxcT=@J@^vyQ=O;e>Y*wCpjW4#@-pL6& zt}fA!5*!I*Rx?O;tj`na%=2c?E{8iM>Coooy5Bz53T_i0f8URsy~agackfacoq`jk zD4P6rRaUi_Devx8%R>pytwvA0U~$IZ{R?mdjxVPZMNmYm)tM3i99Kg_3sKnXq4!1r+Nk`kQ1(4 zNt8W67zK7f>jCtRx2A`q5v#0Rh9E*)3Vze->J*3ofUdwutruyM2q7R`Ja{zGpo9=x z{@6ycL``2uC&2XP2wn~Lry>CZzcOaw(=ee_$kXSpJl-|t)Yo_8|7bW~bqx0+-}Y0? zPH3c6p=De%D<>Jus|}IMbBrxe7XxuPy)d(8B-+Vn32fPE0-zImhcE9Ag2(xQia>ro zLanY{QnhVk^K)!owZ7CW_zowI{Ddqi?aPLr-T}B==GgqIS4{OT_s;L9mk-tB zLoqvctm#_S>OS{l@j3MkKF7~a16wBwg@`$m_Iibe56M|sk}!GYrL-I}G@NSOoHy20 z8f_rJj~W#;pv|cd)3guLHbF+T(upV%PaZYs$Mvb@FPCM4ZjssNZ;F0RePTIl-y-g_ zeqmH2C`P>VZpWujkZ^oOrDsH?H^H7QWLh22Vo@(khph~KRRcwIMRlIljQbjm{Ynd! zrja>u;z9cEDt#Xfd1EID|58msqOGolG+|^kNo2TgSN*`@*{0(~lnFUJ= z59!Nom{h_j_xG!b!TbPyE=FR6#EAIwwV%8uH5|LRqJUERiUTUjD? z3a#7bXwjD{OJ$dNnvD7RGPg18_7p!$?Cggk< z)hU$dhI0}A6DCra34H?jhlHBi#$Ed^N1pkJwX;|S4muFGO%ZC^yUxy1sO}B4BeXvi z;md4zWp$}pwRWpQOa*RnPHR%u~$eK&%f$#rkqz{afNw)CvTYOBv4TKgN@!~5a#zzUH+e~%AMa}p-! zsgq>dk$mT;i-=5AC1Rij4*3GIM?N1Rev91r2Wj~GDZc<#OP~W14r{-B!OMhi(|vUV z-vtVT;7*EBG{dn9Z^UU#Lx7I|JIH|35myiJM*q6;*!sCQUC%cxnii;iu)lfm*{&h0 z5C)w9>hONBEhr@2mHtTv0@!g@};kDPJ1k>&lEi%Gf5n+R%7E(xCZ()mCg1j9g zf2>GiPI;t*PX5M7F}(X1*e;7Gg-*Cc#0?8?-{#ZO(pu6o;tyOoj~4oJQ+41@zrE3N zXKx5%|>WP!=N2P}|1Q!Dy5$DR+*}0=3dF?kW%D@{9rj(g zj0^DRE>SJ<^YQl{`_TQSTc*mn`_eh)7mh>1I#E_eD!88zj)KGHfWsyObSR!so@mlG z^gz}RVPnb-VxPfT?e#aMzM3?wh~bxxuAQniNWZ|d%%!ge`yKF*^~7JxLo0?* zd@NR>P24>bwDRCQ(azM5`!6u_ayruL1hs|rqljSh9-7-XbSpnq7zGK68ITY6@G#XfRu3T1b zPLu#J4#ax#YeEgRC)NvwMw$^!Hj$7|3Tyi-f$T#p(?+*Q%wX|9;pOM!<3p4Xj$i_z z7n(?T_;5mO9&Fs5rSgdBhLI1N^=afa475%kL-F>sbgzpwL9bvi;iH> zhrJp41#skH1sOj~4+aAE|M}q+*oq_bbOFE54$NyC2`&cjendiDyBP8dV&4O5JmGx_ zY0F4S5fl)x!d2z2{vrSWev7PU=(*5U?$R6C4Y!?N)8V4NoF75% z<5&6|$03*|PaGC#U_e}=ieGOQ^f1Gwd+0HSGY9s3nv+w%n|7EY?-dR<)PEzDQzaro^9;}_cu+fYgJlUg?k`=eWZnxwbF;k?x$Ho#S288G@ zVTJ6#{QV8NVd3tT9hg7NVSI&!i(gZ_nEXkE3Yn`L1$n;9Qw)kbM1+ zI5XGe1=qrgTA+hC<@oFiM8Au1w@CvC$&X8B)28Gf-K#B>c95MI$>1XnI&_z9KdpUFc0E4O)!imQUC!P$a=5M5L9P!*muhq6E}Krv1vHZx-Z1yOYQ$8bLNKE28D*m zYf-+@W4m50TVScym86avYgP`;2s_b(qoYS-h2G8Gl1wJNLBugW1FJioCZmOejyYnc z<`Hz;YP7iWZWDcW_v4Y9?Y|)%5PgUgMolJ@mHsvw3(&DSP>6VF_xcUYDKtBT^(7&| z@l)9(hjD}cA7E6Y++pGoJ{m+N3gsRt~H7Ixdpfnk*lL#!Z5G8C)B)W*CR z!IdN){l>n^w-{*7Lw`ZQ$5$1*k-V06R$CauA(UG%hl~W}Sba)A)8)lrqa`$V@tbwD z-XO~7OLSnEOt4Dt6Fdb+vc(@!PtHKZOa`x7mlpnmAJ zYrjAC{NATEm;sQ*Ses__Kxi9PD?a2{r1^4VPY)5qMa}9708*)i2s>ia_Ce3@@hVQj z-P3cT$A7#-mrnz(b(L4|2`r7ITo0gwF5@M{?FpX!dmPonCW)uh&v`M%0V2lRJBYeJ z)Z4p~Q7x!g;lWi>FBk=FDUb$)^x_|um6wZ)ibfI3G^cI?!Y+lQ^bd$FHj4dJ+ zK`k}CLRqP*K&kdccy4WNEu`J?QRo+#R+hj8o50VAgm%*!vjR_@w2hH!PLwRFwg4FOGg7tNFdtZ11}!s*`w5}Ji6oEcp<{jG!_jFwg-A0)JfP7+|@68f8BZ}YOfHwW!e3>kf26IMh2hZO(@mNQ2&>(Be|uy zdE>Sd8b}d{N4^tRn2Xz4SWm%vL%CTF(HKn9NFu$-F5L$-B0mTR7#e7U1cj?j{ey!Z zke}bQ!8C|-@*NQ^iqP`CVqi0+q4%O%B^}~kyc@0ysF4zf%^sE@Yi9&EX?N*aJJ`@6 z6VukFLRE~1 zev@C5Qf;0idHwhtL`#HU9%=xbNTE$ioHfdKKK?rK*|xM6a#$>GggHM`N!?gD`0ium zSTr{?E+4n3#q%pfD(C0%@lS1S8ki6%anaGA$HpSwKYLvUP#2%tr;ezZ_Tycn!5S9c zeQUa%_f5~vDq}5WDcwmzrLR|80nA@yjpBhslI9t?O?^U&bV|!e5wyy%zJYfx_ZAp! z+bc{X@^6K{?(RDyuz>9!DeA(C*7j2+&l{JyiVlyBW0iS`Xk>!M$KtprAbCOYOAZ zA#ezru>~ zwDM8W(S&ab1Ly0AC)D%lqos=RbWt9L``{Xo4zOQU<9yThusJ!mNuSB1i_5Xqi?$d%PZ?xk{0MFpRfBq=QNnRbfAg|E3KD@?Jm zJg$%oP`0r2)buEz>C*X#u?tdGP>A${f$)(3387w*0WhAn^lR zVluG~O;3-34bkN%zBhGbrn~MJX2qEcy>bN^mC%ts$joHU&ImdamJ>+n_WOQ($ZQiN zMw3szZ)7uZ%LSShe2c8kBKm)S`6yi5#L|t|Mrnv;4FZ-?-5qKmKvyNkt?riQt6zW) zuS#bGD+x8kBdMo29g{69FRYbziQf1oB8I~-y9?`Fp2dfsjZA3l<(ILjbV2`wPlmkb ztEMa9bOcEu6b%3BEmU7c#MyK1x)~ZdhIRBPU_?*}U79PHP>8cJfv-x8+|pfkABPz* zoQNX8lm_#bW7x@rXW#Vb-aR74(R^q*eqd8B(Z;LGG_gwH+&ev{@d_!0H6@DAl{<@bH%_dl`*akKF<$05n%$ zJhIq28e@Iy6ESY>0|$`FP&-)~CD45$_d)51P1S0}5tdilraZl@8tq@o}LeP+8jDm|!SaurXfxV}TL7;i(4t@E8N%7}?IQt19pyQmSHOIe2&) zl_U6#++bCh^Vz)>)#@-9C~WDF4Wo@8Zf35(eYbOf|F5ED!(S#NxdFx!1w31LjT zfG%VY-aS||OnbCCP`AIOi0%8-Ldp`P_f^lb%&wU8MAiQ<gKP?|7 zig+b;O8Td}8$6q_;U~K`??5YR*GRHg_c?K5EuT=3NET!aP(p(OuT4$Xz)D~kIKCim zf-asdyPR3*kvUU+Juxg7qJfFnvidG%vv`hG_(sInX?lq9w8z4mLQFJy8~-#&`tR*ObHTI@&FhfERhFd5YW>g z3GGyiC6TNfyKA~^T?*;+dn!2~(H@RYPT2UYc5X%_akuWreCt5$79m!k?fwAl0629> z0vR7L{##cVwxLoU-_ZywRLnLJ-nNmxr2DPpFbZ&T!wh$^FNiOk5EnQ4+JTJ0;{?* zRG3MJ@$&Ex*DQx- zU`+y<2mZW)$;l)l+{4fjzGnyMVxZ%|sr-YieL6#_UP~g8EXqBv8VGEuCUB+KCydU+ z?uz>qzXcfNw~}z5t*A)w1F=B$Mo7qu6(X@U8%9&O!;mK#V=zqpub+DhsbOn>4gd}# z1(=;g5);zmA$>!yANZ$3@Qh}7R#&GVFBg1G=%1g8T!$KDss87uVi6GmpW@Mqf^7n3 z_y-xkkGTo^4T#thQp#omOtOEtF_H^%iYBnXu9z!GVP+#B^NEfT*{zAYZ~fsNz9bNc zFm64lAyV12c~|E1M~B!Ik+t&pac^DUBaoS8f#<&bLWy->EhxfGV19qnzu2@tCNtdG z;mFDVPLSt8IRmo5>9$a6NcESBM^|-%Re4T~=6O%%Z#}qBbtL-fkJ0;3ZTwnE*0B!) zIG#(o8_K|`W6%2CDJ5~U?G!^|WTEGoYjTC*5!SnQlzs0Ad%K8x1l{IJ+rYd1SdL&& z(5d?pPw`Lyvy4cz0>vM>2Ec7~We=^OBrv3ck`g3Ifh_n43ooXF?2kRyv-`@zf8Ef1 z8NsP-%}?HAK6h~p-)XeEx;jCJXc>LolP>GfCb6&||Jtv%HzEgzUW>`?6G_+UdSzr= zr}5lSG9#<@x$+p8YwR*IdwPDSa)$zfo-mp7sCef`MX~Tn_g>!$v-rj~UGiznXXst6 z`apexM8q32Jp737<=#{GJ7W%nMzpE^crAk6GNJeGiJJfjx94Mblo1XR*=?@rs;Ack z)r_WA)LPv2S{RN=%E|~T4>S-6O6vosiw=9&@{)YwZdO@HARv{vacz`yybZXd%oGR*RSkS1a~eR=s$kX0i=XIQe!S=rbi?;`=W27&{#0t8_4uE@T^ z4bH$%LI4G?lva3A6Ac7BSRE)f;6T@`At=l74;+lfn49!& z`wpjF%U-*sXhUO-0RqDr8NXrrago4IRJQo=xccnud8Yu?YTq&2)8GBK1=2~?@K*`A zdP|_txnWkmv8aGg`jS@YH8tTG0mFNI>IXPgvhi4KGbFpVh_cxBw}j-FhNtA7%X0bh z&f2F{%pxc5*^R9 z#j0&Y+p)5$U&fk^1qc)JXCQuKTDLAi)_Qfcv0HjD&Pp;svr)L0^ z)6mf1hh8)gC}MG-K%`gwrW9%;5Lggy3J6>bJuN=$jVvUEU~h*bw#xO)8|VaJ@MF@3%ZYSnl+FN z8ZQPuLrvTlv0;ze?mQK5J^*;u*EO(u1S|VuoJvD)6Aw}Lw5_cI5=Fqwk%Wb*8vW%W zBKr0{W;-S#>Nd#`6+FE0mpuj2O!nq7OeebHk!z?S zr$2!;(As|l5s=b`pN4Ce2XoW_!Zv?=vnQp517Sg^|Fu_ld?!kgpSfYfhN|t+mqv(X zD-_@8_a93HW4#||6IjO!z|Q3;LArib3$*^)0FbnTPS<^$71gPcZpunEP*Y6xLtv%8)m)J30m@v^(AQP*U~dmsV9tY{9hEdUnK9%^4}h!uxy)7S`f`!+@ult~ ztyl&Cz#PrZ5|ktoboSLE1uT2qWRie2#0YIZS3^ET9V zD27Q}!WKnLj~(MnG;}Z33}#xrcTqfiQGJK)EuOa@-ZSV(R(KH~))NOx^`1UR4Og^c z)UE6oFa*=i-I58LHVbEk?6)&e{)n%y@6w&ML1|PD5zr&a^`wKlB@IxO^)3d(G7JblL&*}02)_<%Qq!v{?{mGH=97`|fvpC5bR16@Gc2K0KmX*&Ds%B(Ne=bx9(y zn0EYVj7U+Xe$*U`j!sauyt{gdMAzmw)?_Lei+`ZvzI-NG%I5nfOrYUx|gu2sVO>a&~qWE%Nx+ zuP@Uw5AI7z00)lXcgV_M=gcoEvPtqXW@ArLf(rgGX|@Z|S89B%p$)^Lvat_E_fhlF zif!K>S6e|RDo(l)jv zS!;ES_!bu3z$38jTM5SdiM}kfOqc-L*3e0kW%+t?b8>nm=)9;2cR(=XS#{TxL+Q`g zeKPTW#1StL)9IPQk(~b-6Ew~ur_VLB^t*R!AY=L;9T=hTi$1gOXR_hS@BkFRFjgT_xzln zZTL}LXPtlSH-SUR?MdNJ(jy3V=IeWB^vNzc_B(GR)s$yCew`1KHeHN)t-YSB&SeaF z3mbk1USdZ_J#XPRcSy-716=#*Q%+7q_uu35H5bArykik8e{F7t3-GeR@Ya+vL@OaI zs{{;+n3x!_8{pPf{rz^414I9dDa^-j-?4`!j7Vm|!owP8RwHj24a9#F`st@1KYXC< z7jzAgB+gIm+{N=aKjr`)t==CGNoqYTspPQ;5e(_v%mC79-tJ5jQeS@)sL#*E#b1Lm z{>b}T$H@2;F?DP*($cUUQLv&7+$_*6Ttn5WD%I1?A3l_UdKRLQ_9T`?17C3Q9`C8# z#kVPVh&60ouG9{T;f$Kp+Ofux!}bacFx~)ZoKIIFlbO-$h2l4rvTjW`Ms}p4>n!_Y0=X1FjIzn zg=zaSrg|>;Z%(0a^;vk3UIu4AVEfrbukPxvCHF%OqZzVpqj0P)O4z7Y?Bo6T90KY9 zH4q!KcaOd>&#AWT0=WmT49Kynt^vkmvE2|#&RPb7y+2;z?TY1KI-VO|@Xyi5-C1$SBqk|iP_#2xCoG3m)!O=HM+kUd0D)}pd|3V#2?Mvddo z{=-M@xadRuz_Ri;l-f}VT`xks_NBaI>+B{4lroszwYAoIX6f5?j+V(+^6TH5oBd0>`+o}-b!O?%l10$+TtzPsW7&=82ORqC|?Tf-Aw1_Hw?4=tSQ}h1LYxV7> z?4OG`MScnDT11oQEBVz1|9#cVSX2;#h@K^=cJksp`&fQ__H>&k1gs(>R} zvIm4-h{!#^>lt|VCT`c-hNhLoDu+!TiVX}4uLGIYbJLRa<&-#q5JR>Lc-{t`% zlukv{22K{azCS&UP7Y@dyFI>Nt@#{e#$?CCz-LhH1=%_ux?SGq z)FnNc4+`?+$&;i;Zl|g}{owTA#%Es6*(l?!f=h$Q-VndFNeC?|6i$%WyhrK>z%!9G z-?de5;#z8YR6jcibJDNxJ<;rY6oPWWiPh9xinn!^l;5+pWCI^xlSYT>nIOF@e^keH zPYzy6V}Cjy#Cf;jt~cuZ3LQ zkmc-Jb1%VX84W(W+cBRU$Bl~>LIFL5bp_mWTh>A7Far@!M+*6{=M%w;l7{VIj_7(k zjg``N4m-g7N4UkbZM4Z0i& z{JZbf%gfBvj_>UBesNau_SJ+z{*3d{5q{AZ7w~XUMoS3-`^5=?h+pTVi$MW+lWd!B zk03?y^l4&Itr%mWA(NO&1@X+*eDnreCC6nw&n?jI4afWgOgGDbi$I8QYt_*8+!JlI3uc?i(VfS7R;DNDq57L>q1U~a_q z24aW3$AGfN`_^TqO}`1F1)XZuzn`85u=*tb-#hs=vr&y?0j*qD`$2oa$pp|EZ9`PI zLIdowR(5v8-bwLD5@wqI$&ntgRv)j*5-e3&43-W5cM~j8z?ag#nwOjECA+hUL>s1c ztm^ip=EO87Zc~8bGY)tRXpwu2{7tX!GCNoAZ^Wi@Uf%upG`A&*fObh`<7?u&vu(v~ z5%p!=^DBxC=g*%P5)!hC1^o)0`eP=Z_={tB-d4-`r``yO)ild}SS%nr=hYumKcIWu z(ZW}K-J7FEO9*FsGSj%(XMw2;f;~Bu`>8%~R?sgELrHxQfghW&(GNpCA)(kQqJtX1 zr|c=s|BeasLl<4*d%0(Yxp!+cvmzVc>z3j^Ux9EQon;Pl{#5qv z`ExN*(Lqo7n-^8PaiPRExNS=M#fB!V*>}S*m#Q1;Y7II&vwsPv9_z ztjj7O{|q5F75Bp}sz*N&0 zvQSu5G^lo}OY1Mr0`QZzWr;HTqsBSn!+#15bZ6DP-{@7ofoF3V#0_)v;*cQ#NAReIPLlQ`2d1WCguDxnR&o1bBLeTr0BM1|I^xZhNx=Yr~J?Vs~vd-RFJ5jCT?WVxLT{?42RZTG7e%b!jV=Y-+1RY1rq)BEL5pjy__qZKodzxQecwuT z)^f^NEI}F#MnVA>ZR{>L*MgiibYjvisBynk_E zFGBuT;#6SdDD>!riHf447YJJ1cNlG8>hMNJ32=CHcR(BgLn2+=&e-KTpy11FR@&7E z^C7{Tk5F%cCTQyr z*u^F2b=TmHDaG$-I6GC4)@kxs=pM|u37i#4R2M2obazhyoc6Qn6Kk6wZx`_`(o7n|{A`4A} z#eVXwbw4>C^b=VuSs)j%IpPsP zY^Nsdx7H;u`mpEA<$f6etYRh9(CeH*-rb9*KV$p$A>UmLG5Xd(nGy8-H zVqkze9FWPS#+@qw^KKP2c;eCGmJK-?R((QTDu2dxeq{xq2DZB~wFU>*!MI?xZ(;r0 zf!03R?)UF8hu?2{h2~{?otUA(y6>xB22HzELczc$re|fG`-v2#Y2l_Q#B_-w zzN>Ahs1dO2Z=VK%{{l&z@G{0k8|L4z;ddAxyRn4e= zZn*HSoV}h%;O!!-y{T(lzAk5eZ{Y4IwJw(u8GAgV6F}8=$sDlFTU*pOF)@)(?G|n5 zmaB0??~R^nG8HCcet?(W&b|~1$yC11 zN-d?>R?A%*&X*Fi>kfULCSx38A#E}+WoITpjJJGtSiTJ>;L0xAip-c*G@-R4zd*F0 zC+qwiyXtx%%yC$@F|2Vs5eJ8|MKMaas6JX#tbUNQ??7w>`C#A6(5|fdux*(MZt!cs z5LXpSvT#2lkA9p(=%=cJkKxxh`5v#`$O3 zK7_rei%frNcq_tryggJ}mm5-!{G&s^G;my+Jn3@r{87h&&_J{oRRkW0eSI5M8RTcJ z(^GwWk!J?P9TS@|_NQ!uIf7V(Ky7?YJ38XY0nSd3b>rjK{jdYgPEARmG%)pQ4mpfZ z$Lt?)Y4{z+HKA{-PJG-N8>u~SU)80%8ua(H(eR6lQ^EH1xre)IG|?vGpNk&2(6RY1 z%ns?eMJQ21FS*W%a6G+TZxzLH(ok^_eZ2rM#1-g#2TBhCtOwjxl9My0`hLl5T>^QA z>Wc(UcQSx7kL+5;c&AUebfCqaN-!Aa?k;<|`?1c@=g({`EKvyw=FmeM8C74nCyecY zkZgfZ24dE9@0kW0%O2~?6)?7a+$?LnG`@jealgpT2Exo#;L1BRpofFS^$aco7=a)|9ibmonPO&QvU zMMPQFw!EhMbk~K7gYlGj_u%YWZV}N`aqUl<(>SZW5uk=3WQ=c8xvEB8CXkO zDsD)(!)9hAa(yJx-=z2xr9mhCse9tFHTXNXiHT)YR4hR+;nF2|uMqGBVWpOkxM+&h z*;O`$-h2qZB`R}MWqhqe)i($cbrfKfoQQkEIH1Zk%7?%AQ@rXn1&Z z8xWyUe&)<5dR~GBf!8T1P^sQ(@cKPZ$h#{TVSbwhGoWo2{Z(zJM?< zM@Pq$dpU=2(*qnva1ebYo&$-MPECeE;IR%(=yk+R=tgrk;D5 zX_RW^^@`^sXT|0!Ub3NNR8(uq#9eRnEUeY8+(9hp2?#vt?!GjI<96qOBk(GE7%%bZ~yg-EK`*JVmg8O)ma*X&>CU=Lt+WLJ zQxb@+|9gCEqC#L5t$5Rxkdj-ksWxjge9<&+Gh^R%VQE9h&6bB(dvK!ua~11Q0@~Xdpn)v1wV3)}JUMT#UAt{*99J-d@H&X<65Wa`XSr z82T|k?uex|Cr{R%wmsO)2@jOzS2#cKRJ zwpO{kEwf0%VE_Kw>rb^I_ZvVumUxHDK5hBY{JmoSP({AD9>oG=O4`6U>Un0-#@C|w zzLIe=pU!`9-1X}lL^GqV*f8&1h3`p6oO#oVyuCw2m{Wi zZ1F-uHWU|V&t+YaVFt$iMqC^+c#vy#_QNB4J-#qyB&>Qry_rBD?QxpvkvTK+#Zm^B zbZyUt;51hay)|mK>OS}NPF-K#$Cef#qgPhiZA*1~z@5nCCw*X;cnFejj7DBmG*~D7 z-o4u5Drax0KZ!?s)SgLyYl|LdJ-0q`)zr~#Z;uHTmh_2gT%vz5+q`v$!ALpr48J?lXOq;Y&hh4_cE#L8ak>b z@5cwGZx~jID~?EgUY(_d#U}`Fy->WHo97JWjn*^UZvsJ)LLDL}eWBuqoEhKCD6VyM z(kl)AX}U%WLa@R!KTkL_2FJ(spN?^hh=^R-#Hf9Ek=vX@e2?hAOoeu`XU9$oPeav% zwF3FO3e(e&4Fg*|!jfKx4u#ZXZQWSMLnsYBz}P_Ss8g+K%#?vO-SXIlL8xaamj=-w zCdG65j5b`(H_BUOTZ2uk4O^{#TB?g=x~NQ!8K*wIyMWq82!V5R3GE2}NO6#rb?XuY z(#{}elIC_-Qxc)iF%7U|uG|)UwPZs5ioW06#cL2gVhQ+;t9(1<&;de+1jcP@e%=Ce zc;fCY_K$TgGaJ)6Q65(JamYC497b_Kt}5hU!>5iPH!xsS;fE$$@8>;`j(U6y#s|91|Hn7U#N7@!y!@~~!)e}1_v{j(Cq}321fwXBJ;=MDzSP8+g1@%gAoTk6 z18@ri*F?y$rsf)Z6+?bnT(#5J)&!Hym4%P|k9mOpG~?(0hP(A%VS1l{o|I8JX#)gNbWqNKdH;mD<#JihME^nHGs1nm68 zxkrr@CW4H-0;8ixhX)3NoV$e6)L``^s5dy|BPR125X>}-L{BRN)7Y3$zrNl=uV~nJ z(X;=!nsekHW1$1g3Jns#ooXkA4L5b4c!k`~?^Fkfyq`ds71zf6*Tt4Sy2pNAQ!mI( zjCxsDX9Ewzn6{gn+~U@4(>bq)F2ZyU3pQ4XNm;z$Zc2>X(+|Ah>!)%8bTA)U^XaLd z`G9hR1T0U)Q1NKh7q{@KDY>^UKOU{b46)&a{MdYcy~e(MHKyC-U!}WvUZ&(x4_fZ5 zDBOSbR#-m>9&B(xCk=NbaT8yKYT;F)avbq(q(Lo@3b*O})SidAZ{W#9f$JHWNLPg4`R!VT>`H8ny=pofNHV;X(ikFyVHuvG^Rb=vku1THdW!MBH< z(Kc64uj}UMT`!l(KB?_QF-oo{yxUJ}?~4KdT(YmSoWObORZ57BUE^P)*Y%(Fmv5c} z={D-V6!eL(qE@Q|_wOx9PZyospD18DXvhBFK=ttZyq-Tuwc-~DoB*WaJR<^=ftL27 zDlZuQq3dN2Y?ja}<0wn*+*d1Tg^;@Z-A79>&K8?$4v1IM)BUQ#f5uUvt~-U1%G`-Pv{Mz#5)} z{C#1|I@&B9Xx=av?t<+EZP)9aB8m&!Yf_ZJF?kJ*M6AlooyDbGyD>gtr)R%MU!+9@!%U&wq`BfFp ztk@~R2Cuj1!|yv*a*AVPW9)hVNdBG@XG?tReczKtif{dY21Q1$4+_D`uaoe^;|90g zc&Ou~@40hDeBBP}OgYRpcOZ~={Z+aAeHpD7a7-y$4o6tQ0GZ+1F2;^6t{;J)u0==V zIVD0u7`Mgz`6n9|Oq~z0lXp8IT;A2x%wP4qS!!o00$a|#vJSCtWoTw*U5RT^Bmin^ za~{HiTk~6Sje^bZ%&$t*==ZRh6bGsx@U#oKG6$w~j-s#TPJKjLi8@?7*d`q+h_+^htl2qZ6SU6L!5 z*RY_gI2#&~_u2d@byk)H{v<*J$ggRPvU_d;Br^0iM=dQ6z)Cev#1vq;OEtGYp`zXE z7Pv{6t;XCd5vKEhG}JDwhYbx0qbxL(k!OFD4V`t|5Sf(J!hh_A)bR}!FUdEkI$?g? zJBZB!GzBj7-_znXGCNC!gCBW@k1Bm%108o_Vq%I);K^<5JINi8iY8|Uc3PgFu0y&E z7+f&`%iOXjG4^$`xY|y>K}&2GuMDtLb7Fhbv8nU z5jWw|Ba@n6n0qSQ=Esiu+-uAY8A;x_KMqQOXkY22Iet0>?jwtI2bPp(p zwdd*jjkSyzLga~{nS19$LCL9L<&DdVP(!fo44NlGg$=*+pHvtI#M=$Ol&QqHmOOz_ zrNGbof#kZnPU9=Nr=PI48dq<0iOL_NZu0A1QuDdWdRcrW5Iz>r9DFSPN<{rtGk-(x z)e`IQj_-(N{5JDaX0ofaFfC04Y6fJuGw?!y0XQ0!3Cfj?NYKY3F)={M+z8x&%P9dc zka8x+XeNz2t3pE>lR*NYH`siBptWG+RTu+UlFar)KT~H2IH1;-|I&{z(u4zD$#rsa%JDvCs>(j*Xr!|szIrT*DSlpFx^QM!uc0Bjx2isY&ZB5xTG;H74-F+rw>t(HEt~|8ndMN0*XI1%8 zL-sXmG+0<(3nWf2mp9vbp0~cdDq!c}&vWO!o6q;i)7YwfeAg zw)=e%R()mbL_NP&DrEE9nQvxIN3!!jOy56Gx<$q%AKNfE1N+w7)#qjPlTP%OOL56m z*~~pWI5s)C%Xi-C*6J6@I?tIvm-IZcXVYKRTPwjHhP>K*KiJ>Z&8Rl|O!o)l=7bSp z%SFbY$Km0$Z_8xiMShbbLLW>@QI7@_*52QDA>E@qDFW$v%NG)}RU{9o-?2ESmo7DH zRv|RkgT-&JGLn5VYxXJo|E?HKnTgp^^rO-~zJS%79!>VcH`?7(qbL1$N4^&M{1mfV z!F3Ks|2N?%GvM)XVZFU{#omGPt7UoV0F*oWJ-O2Lev?>V7+dSa!O72`EA{Z6qJ68z z&K#T5nyI>9GrosUzS(Z-Vb%`5$aA3R)Uro*_QZi4qX$oqPhS{(l0S2?48}I(rf8_X zDUcFBxRA+uNDWWJ&dzS`Y_a<@+d#Nk=3x&?5B8e;h~T9+x#o(K7VE7y;PmUqVvTIi;HjA)s7Y^J9=mrEg5NPT_Roje#2|@{Jo>7PTLnv#z*Hs;Wm1kydE?u(X;NXQLP@4Uu zJr65F9_0;gf=@%CY`rujz69?X7gEcj2_K(d(`;Fb8z-^*y*PIZlv|%h8Alh74c0i z`ctrns((&RTvYR1Nu_ofKW(Nw&WoCsnU#}M z@NLV|h=@;9m*oksqpdYK`|vT#m%ZzzUuf*nrEie^-E<=_-Lu^cz7+-1!17`b9qMA! zbC_7Ht6^m1S#ftKjprqg=2F;!Q*UMl-RXzlQ=Ho*@zR9#(MmDrtayiyuf`RJr%sp> z8Kv*BYK@@4E?FDmmZ8z|-uTawz^I78&@FEtc0D}p@i>fzjAn84_PR^VA!m5b#^0Akqh91-R{ayK|t+H+rche@I(x~U3mdD1p%S5{2 zATvQcuXv4Po#VG7PablyqYba!X{rpKz2&@aaWE5FdRZ;2Seqkkp4HSl#VN$bMRnqq zLy8Y)&cTWWFbSKbtD9OvXBXcGXt3;#f=ze)_{G)>7B#$z+V)XH>|f;SShVcjF^j)v zkZz1Uq`?;{nwD?X8Pi}b);YMc%#hBHu5eh2hP`aS@W`-i7=vHT##~z*_qOf!3`}@z zKAFu33lHaW{r4g#b@E^3)6VwH0sq_|lix$x2R2lX>q= zMUus+Od`t;EM#voH=L$*id_0+iQg9!rm|VaY;GxvjA68Z76z!QK>9Ot6CqYwYn1ik zMQPDZWqrQ7!;P^edP!?EeelW7b~DeF7N^WWiRP_!rCxwB|OklW?zQwjUr_Gp|v z#(g=8YjsVjYP?9kh9&^xA}7t+azjF|nyqzqez9;gvl9S4@{~Sf$6CH~O8pF@6ZgZn zrv5!JoU*Fw^-(GaX6d1p1Gqy{lni9H&xnzzHCJCHSo^*dYXxaUR~MEnpSA30^YSZE zb?wu{Kaok1Sd?I!+Pf>`t~`iK7dj`QDF;BxZ1!wGN?Xp~Qad|}kbnsTVssbe>qWSE zQVfz2mlZf!4rO-?N7l)^zQ;Y!wuxoAb!?Zt8Fr)8r@4uUQnJ{zsR=hltf6Y(f9)W& zj&NjpGILG+eLj{P2;H+ss!TRSMid!9hY#qB_|G(iDlH-aTP9A#RL&IJ<;a?KZ2qH97vbd{~%+xgWR= z!^KW~@Vb@S=f}HV#sidvfvdhQAlhIpdnzCxe3VC6TB%Q)JXYuIIddBF=l6SKTGJPv z7e4VJb=|Fgj?%79i*o#RBUimZSXGoBxAHA&P=NO|Bp7mtBf+l(ViZ|C-t^6%sWSB$(#vve2q2i#% z04q5rBSXwGb$Aiy=!C=}D}i||wE_g0fMUlZyOg~(2DtQGd&@DzEaJk^qjMNB5=)7y zwj*jHV_wm8ArslHb8t9(r(U!x8GMzsaBaV~+K4VjM@BNclIrqCTKf>&jCz@kFH*Nu ziH|;ZtfPGQ|M{!90*=+y&G4P|eKZqw{M8AY4=8q=R;s+(7`b23qK9`Set8K^leUE2 zp1QkW(iZi|c{?6N?`K~F7FUAF!{6Tm{s221{P(wY?} z1>U}*RTOX5No426pJqM4@Xj4smy(TYk-=NBWz|1=S>rblDFn7#Q&c#O!A^6HNr*7D zz0|hUmwaK2O|S2;?-h%=n~c9Gj~F$|{=q0gNc8%9=N*HB>?d;BwPVb-oDOm`vN>W`7vZMZA43`TI`|OcAATV4UUK)b5RC zX%w*SZ4R;`H8%h@W_|gpp)$fdNshjI_wMwgiqIt-(A&7v#NWL6DsFXWZ7+*%%os5d z5-}vA-j`SYohfUCYv5{YI%M}g9%S!!*X)4mZvB4$lch&6SHng>)bH`4U%_`R>zR^v z*dz2}EcHm;@#QTa2?On7V8bOh?X#eyA)OM}y->aVbpOQf6kAG%dsOV3$jTLeb|n28 zeDH^6noQ7aZv(pT1U($81(kMN+<(%V>@`*baHfSsuD*t(;qx^rT%aX**Y*L73 ztr-I;(}wVMb+tcRplY-&W7evLifUtFWFD0xPf?6W;(M82+?z)hSKht2bVG@&=*ZbB z>f^C;WEUlUh2|!})hg)xd2_j797D$RKKw5wL410j?20q&7G+YG+Xljc zTqgQMqP;~@Ng}IJtTFIX9<8m-M`_^8)Xw@9T-mpy`&4wYR#(0coUmzM>YVHb6EC;k z{rbIs`_`04=q1FnapN+-l;7Q}F?IsRr*F8dQ4r{Je0pzY|G$Q_An^IxMPgde6b;ks#vCV}8_BlwE`z3|yH-?nWv1w{^*q;Ii|L9zYAyxopC zpI|n5KM`>sr(60FNRWEYAFhFl> zM1gY-WjedOX63kD{vmt-_Sjev)ETU(7}PvE)uY%ltXitD*M8HL4i0D8dMhUqt?Jve zXB8X5EUOHOb4MCHNT%J+-4f}un!|`q5%5z0%DGBio?yd69+_b||F%bKA~t=LXMlNO zvev0_Y;mUN=Dup3vuEw{Pl#(a4z+O~5kP}1Ykg&0;K}{_Z@2>42g!T}2&as1-Mk6R z$c1EeO8BU_-%m~s9rfbLlNFclFrpqA5j@Z$Uw1zjcsDfyN-?U&HO6$pO9z2(Xy0H``fGu&n>N$f7q3B#jUokE^44S=c6tI$c;Nevs!fy9XYbe)pc;1 zsaZ}=UQLe`q52!k7q`7WdgxHgi^BEfXq69blPn_j6CJbzb6{y4q`tie{-sXh0K}({ z$*w5j%}Q3R_{XKnBwyKb{`bt1r)wn=y7SqOy?R8vn(5j8JB2Q=hw<<9g`thdFB{a2 z?2@?X@ij`{lI2l3r&L|j4|J^|fTs_0k7*C9B-t$;jZ zC0Hu4mtwhD7v-`Sysw?#YI|uPGKXF}_hyZ5DXu?eixw9tySIMQ^>NleZ?<;BhSaSe z7Y5y1pNbSYT6N-t3FNtG)tmD7h$oS%QII`htY6ArnJ=5KD+7;9*1S5qK0)Frv5u%Q z=p7Vg6J!q~Jiq&u@bHrd1t2frfz7hwKfho6i9HzQcO)Y1U+eI|(40Z(o0>kAmX$@T zzEt=xZWOZ)iayocGB+BYB6e#jT%cr62QK3+(g#H&2r}B26-163@f8uXQVk9~%lnmH zv6B1D9MyhPL}DWYb5MzdJ9han9Y-%){l}S^6k`4{YMIAW%0N<@XSOrAB40UtbtAf@ zXk0rsUAXRPG{9tXifeVJvWx${%xAASler2lBC+nV1r^$-35rDsA4=yoaf>8U`)a^v zRD|04QCNKZ7JQ@N4G>i4ecN#1ra{DlHU9Pf^Ne=>ZW$x4Ojt&<72?){Q2h7*2Ues< zIW1l?8&UD*&8=XO#%?nM{1=`$t+}iH=X-j+s4)TZgNq(yZ~5&zamb4WlkGd;Cs;RO zXXL=#rrV4_KZ!!JGPL#5?cIMoYu;-)?3{4>x0I9DUHAGntI5|i^|H3?(-?5$zx^|x zKmPJu)swYG{#nVJ{g%T~67$PS{`u#hT0D#sQpaqq^(|sek=2jwtN?WFMb7$KI3NwuOc#YwNDbkK00GG*QNsJG=VlSx< zNQ@=oTLb}Sg;F9k&i;D9omrgvEcAIrg(u_-;%{13;Salgo#COUZP`+P>xCmF@E3!+ zLxywcGba}|-9AIm*!lHc%AGs6ij$e!Su;x`ygDcE+h=abHz~31J(za%kHMO?W#Gr$ z1;9Rs5Git+>UftG@DbGb3_V(2jG8l3y1T^)1>l!ftXfK%jg{A$+1rQR8?6=TdHam2 zPSTTQliWL(w0>@W{nMVoY=LkmUaIs6RvooCcEy*skMv|jJPwX%N9?>5&W(fzV;d-^z8vjwcx;KeLG-_;;#vRHV3Qc#o_E`Wk53W)DB4f92 zhdgo#aqp|Df=6Z9-=xl@!KPY`m*km#fA~MZ1v7Wi+hm!#4+*!mprKPDqRJsmQRdLvm)N+(u6gcHz)^SyKBg$0Vt zJ+Kq&M)$M7Y7`NM<}3u~ug@*6QLMbT+o-f``=VFORcMYFv19iruiM2@)+1BxniND{ zLrcaTPDs!Pz?Qph!udkB6tHMfXD`RNR0~?1?pJ?%R)OCSRCIfpIBGb`90NR=(hfj}O-NEy4x`HiaZO6ud)Tko_B%qI&06 zoLhe5{M@Ny$HY}wGM%Y1$h9v^Rov14Cz?o}YdG_YASl%p7N{-A_7^6U64`MnC`UGp>{cGgpr< zwEV9Vky>$dA|>ti={5riU5b*^i_7%DdNS?8t1PK_ z#d28w{{ezSWy8_8j;(LUu)kR_xUfpbVYi&zM1=F)p}d$~q&vixUvF#!0_YWFpCJ&N zaD4d+$t&r35GikUKj}q@!_O030jLK!pJ?p3M>|2@)_v`??oT7&$T#=psgP0GCEbi! zw{9bfwB(kd>j@*-OHlZ2KpPp6K8SXtd)bi z^|8ECxqpAE|8rv5)G<>1!2!uc4%x#HF+@c_I9In2>tMUl##`$Y_^}soQxNn^5hBr$ zSf+UR()kKIWW>79i8R8Nclta>N|5ThI;EaHP2|+tFj64>tNae)Evfqnai*ZZ=weI$L&@ZkrK zb^QIX-2fCeXIvMDJ#&~LN3ko(mZzuipQR3Z6C&d^G2`8677GPQi@IDe4T-&V>kf-} zv_&_d&n9rNOBQv`6`)xN1tTM)c#*fFs%izw$spM$ByQBwMO554z}Q5~VM(@)jW9F* z2K%n~zxvR=Vr!@^TIYn-(yLd$PyxmVM?j2fX*qtY&EtU5i{&c!IRhSx@eC;-SNicN z(l4J?uf~V_%a+--Yu4mRo2XDtO&O{vdI-C~a`?j$=?0w7qoyGY?`EtW>7`LWRu?x3 z%JQn#QE?v%uNIIp9JIPX<_e2{58HzsiRX#VQQs?7t}MH&TA$HT)H%y6&dkX-4PJ79`5X4@))fr* z@`_FPd1CNXNwyg-Fa6o)B%ZJqPgg#9M%ndFqYoWF-pF7Z0N1{eOyHBHKARiOdi&v98+|OZl7^{@$wCU0Rh5d ziSOGtEsH^BfMwe{46-cR2D=O(k`*8{N$fS(vg!XTg#xS#OqUO@?Ib_eHT*3|x-DUlB0TR-!Wp>wG|LIyE&ga#>e;Pkq z7yV;Q?P{UxbI4u^z=l_B8ixjL9tg3aqlxr#(33FEy-;^u zrnlNsSIF(0#}hn(mJBj*etutWs#Gm}?`12kzj&-ym)Pl-Q&N6%awU5XG7V3(x|^En zD&uE^mC$+&a{&rU@BJV^0>c7X1o`=0zWa8_Ia|+`H=Ua`*iW_r?I~Uf=vG*;$(QaTV_S^S zqn!ORisO%OIE{@{{;6m|#kOY6{<797uLVy=<{5>sHOgtLPQdJ)+x2=#0O1b`66M9Q zUJ;@pr!QT?gS@|z(rh_3C3Xzqa-mmqoF66VE3~gLB8>vT=0FgmS*(De3$?{c2GdOz z30M`svC>a3oU{_%?Q6ysYwXepe@~x1o35e2tKqwOO;s&FsFC|%O(${ zmW?;>T$f9-u`;OSm_{*fdF$EyAZcf~hrt~>b)zr1tm(Cwrj|?N@i(emzW=eqW{M0KGr_}cK^SSD^A&{QH3zZ8lsnqII7_yNZAuR% zy+aX(L36}A)$;!@Nb#!}8O|F!vN3yY-uQ#}}erY!o1Y6!RM0;R(J1VI70#w#WJ zz*$FQyS7b78I2d%z0NvGEejit97T)Ac$zbf=> z8ad*`i4#52%--{r7^ce`-p3F26SPM z+aaMUR-o1*_vpzOP4)K~i3J05z7Dc#3*{6?)sh-rUB_s9Nn-zaP$sZP#iojtJ@Hh= z+?w9&Iu%EQhl8p_sxCBIkPsI3Z}g3O3$&+>FB)u__I8MSf5LZ7&2&2Lss83Aj1^@U zRF54WO%Bk|SWC2_4nY%YeAgi2Im&-p!Q{CS|F9JONP_~m>40L(gUU){j#0$(?tQD$bA-LEY-4Jw=5p1n)O*-}Y8ffr=SRf?P!rzRi^;h4hGa z&@xe-RTL32k%QMbKLvNgaSWk6nK(LalFr;qMz68e4-WQH^m4{p=7WHiE-R(UVnRUt zQw=a$!$0o0HYI5}&2Cw|19}HI8CV`TnbDS`{vle+lqIabb4*XB=Ot8AIWuq@ZaDF)!(Qo^er$E0`H4Pl_2kp zENa2{=}Nut)=%X-DRM}XAw!Gw_nVF(`q9_i+xhjtOD|)v*w=*MixocCm!(BRZi&pM zO|e=i2pxY!{(Qz;*S#J0Lu{38<0L<8#xc%mnBk#GB=WTQX^ltH3! z@^S7T_w?!uTSeB zW-kYw4H=L7DBrbfh^9+#I-5YSetY|UH<}&yHDsJO-d%xKe8UqS!~xSX`CMhoD;6V4 zoMC0|G|f&-|4-v1D7&;ji+AlZJlDe^dl11AWig+cd#uE7FQi2y+NN)Inum*)MQd@d zbm;95eag<(=Y>8-Z^}D*iSnIacx&b9;CY?iena&Htwx_>BSJ^}Wl7vB8Sw-S{21sr zb>ai{`US=6aeIE&W7;!)w;dKdGA>O%$iO>3KpBKG$1tTTs zhQ$#V!LZ-Jfj+N0P66?`v=Ea0cJ;BkA#+Bp{JDFEbkBoKkO1nE3G<~7&6_f14^~`g z2ORc{E$KZ;PtR(|NmOQUM3)ade(6`VtgCNZXJH&7q8zQw-M_6IOOYl0r{kDU&?e>nh`SR7IlvmyM<5-;<&f*$InNVK#_miGm?`!+q%UzEDmaV zhWdc@HT%98OaHoTSSlUZr?Y7A${amxp*Ym}(w|{Oe-#p4vI7HFcg->DqUMClJJM(Z zT9{1FaLRpQy*(WYzn=2s6`cei%*)^@*iO=q_(e1Vo%g_OT{B5op$73oe2Etg+^cbR z)+kBLc3560z-hJKYJ+IIS7*$b!RmITy$9^9wp3;+c_6i-R@aNU-|31SC zr+J$m9_mFiGJavNQ$$`0HO-(+CT?!O*fq$32RV?)E6tuho#Ju|{j>I6XHL{E!?9cc z;~5`joTw%zA0}bUQTG$35xiSQxUYttqEl(e4S4{EI>GbUQlMQ(Ew;ccU$DS*efk*G zMr?z?9Z+5S^I5ym{nEwqfD#)vY;c{Y+0yf9We6Oy^Y{AFuT&m-GE=CmFS%$85ZwY- zCDZRZDm9x@nw4npU&RcdNMLgmG&LaQft}WA!gAx<+F1p&F}kp6C|x@+%whzU2&aK6 zdKjntF+b|(*6j~&KRP{&%R+tVpJmSnT+Xd|ETr56ypy4w*{4a3?-v`s3IYrD(4M5&F-t4#TEP>3Q?vF&m4Q$nG%th_x0Pihua67PoVZjC??mK zA$Tsne>c6C)VNV}f6<->v?Zwt`Gc?`Eh=FtA-QVRn; zmdqRXwDR}Y=sH9{(rgVzm{sl_~&Ss!MdvwGL6_}#}BB;dX zaA@ez-D$ZDwI88b74I3#SqY%95fmu0Crv|80Ax_nBfu^_;*3wGVT12g%Kg=UJOsW$ zIaoh8iIj7A+K?w2gt%(Z2{X$I_KGJZCeFCj!Eqe}4!50dcM!RhgP=ldoe2m4ic%n0 zF5$%ePuP!lux$3151ra+$jS5PM^V1IMRhLhlh~&M*Os8VsmqpKYVKAOw!J?$?0ZyJmPE=#fd#F93~hXpL_ zWDC35x1g6|P4nZqidSO)bf*v^#UkDi;CcL%^lF4@`E4{Wb7^2hAE z%umK@hEOr>lFuXxQbvC6zbuYd>jH1vuVTO({>T8<*;)L_32rys)sidpz;ry-EFKE| zy&qA*WY*{v;-UY&>V5|sQP1l%{TzzT4fiaAw`aTW{rDquFn8_w^XCXFGRzFig+I5F z?ZfeY{CGo-37PZ+0>(0frLR)k&LdEm4{&DsGe!jhRFd4gn&O z&G6PZYeejZ8yV-2Z;$9RzLS~-z+_I&oE@#cpWl=$`$2M*ys`juXNt~Y&6@BZmZc^~ zxx3uB(-8r5NG!oQ60u9bRv3R2)Z-!#YK?tD$$}R^P*XPF7ibN+VTr1zg~f?kZmX&I zk4`o;s zX^bBI{NY0;G{dlfK%^+F>Fg#NY?JH~0=LaJ4{fhxgz$00c#Az8LfU}>FvDkl8JB`n z@&&psW~BGgZ1O3XEv$V**AdioA}J|}c2GQ}lG_=AS)#jp>tDF_E(J#PkIZj3FV-9* z5pVr_p80tH;MzPsJ1fAaQ%zoEB9wJcZeQQY zYkS2KTLxu!UqaPWX4fBe6alKVyRu4G6Bk1B%Ju6}XUvgPb3;r>e;De}cGSlwJ~0uC z6t|{-yT(X8K6UyuA8cCS*yxgaJ?QM9rd%Xav{ME7`4XvbvSMA=i7r*5*{*9g z($(W*(cLbbtx}}+4J3(LfBjhA(JXa8F!BQLDs&Fq z_GAcB#eG8z4a>8$wI)qErzp~kjf-13X}M4W+-0fHS@Q!Oo<9_cZyt!K7!3WssSj9! zTkEIT?M%g#&sN%ZB!)%cA7}_AQdO^2x`S?lnoXDczPD|Nv9a;tX-!CiR$a{*3M#=x z=25_nJ8q8bS2iLkA%SXdl(ttp5WfxiZnLZFn-%M7h`*53aV&Uq??cIgcU;8k^j0zo z*;vqI$XPh!>OkX}NL^~va|C8|^j|@;vn(tu__r{2E0GG;a#Ft7urrU)_!V@P3nY;> z-KVi+?+_K;0}Wb{b63?CMfcm-p^JCe-K<+T2X(V3h%843PWnbCZN-icWZfuJ4|%9=&>OjFpxE2WfX%M0YB)TQPZ)j`Lrj!@`VhAaEj{ z#TP{){jG#K8mI2dW=PlV&D1F6U{j;?@3Peqs=2NQrh0N2@Vcwu=&-3!;cS}<5C>s0|=QJ*rxn0imoh04ZWfj_~B*TQTI+u`$PSUM2Z69$)m@QlUVdB+}OIArluKS z{+tS@4I3z9KUJj8L73wYxO9E#q-Ko*>B5urIdwbc%pbh6ENMuJR2i)}1(VF~R|9t? z3lK_4>>g6F-J^vrw7|f?2+*vz`a8YJ*ze@LXWQ`LDZx`0#!#ALgpiw}m@MdnV2H<# z^;Z;mE?Tzid2=)zm?UKMc_WWkFJEq>a-qn$tY3AGlK8l?!LbM>#FjPG%z8B`q=D>y z8#Kzpm+rw7N-R4?0%cv{)f+=HPCPC6`2eg%_mefwMIpRV3?>eqga0fFG2 zxP`s3u!~e|9RGFAu)u2b1{y*HQb@xn&*}{a#$Prh@&E0lv+v;^b zwu#$oG7D@ooK;sDFKV!zl=b3qNWxSOLdp1V4!IWSK5k<3k^`lL)6}f z52t;vDB+%b=pSs12s`Q4E!zagzS<_@SoyJ6t+d`v>T_$r;nWU#eQz_k%yvC$$b9Ma zNwC%DSxpF&HeOIS5Zd)*LI64G649tc?GJc#KH=*FXVv^~FLqB24oE1%L+@$k6@RZ^ z2;|qrERM@4D&ooM9keKFW-{m?UVlh?U+KNm<{D*)nQd;F_U+Yc469j!98X-hpk03> zT+qlO_n%}`hfE;&)cNyIPmik~Y9^dgNx|n?S{s^Gwtv3 z23ZYtb-E5~U@rYN*$P2*qehRuP7%o2rA&^{?>Ue&@E0NNkf^X}>(=nAvCN>-RUFvA zKTle(i(ZyV4d0VEj{YwGb2DVpcG4>%TH2Ya>z(fU^MY2dzU5jy!$uTCFa|EZ+B)F0 z^t<|cVGViEkq)+ReR-L-HeSWId}Y@rG`j{L2V>a zcvay~>mkt^PyeBN_FDFhcOmz<>~+Yu5w%=%+d=V*3LH1r#EZfT_3o-~6L`h0yo;F5Y^TCEyv^Nce^Q%^NBFk!%-A7pdiz>ss7yxHGhQb*d@d8;th@+ zuRd5KnZMwR*Co*4l0TZy%Zp^nt|R7Kq3jT^wh(L%`Gb!5kv$a`zX-uYgiO!4t(e!kbqxq8y7H7A@o^X<;lE(Hj`pCa}P&_yjV1Kri=LBF_ zWIy8Mtk0=_w+R)<@ef7}u|x&#Qk4!#Q6PQj3|rM;uRzp;ozvQXeI(#I$X?#VLrh3? z{k`T#Q}$~em2>v@-52t!PtUd5o6WFpSLMZVtEQviY5#oCc*G!FfO8P5pk{2_{ipE{ z$JWs8TW#vPBORdET)8TY9|Q%>mJFnk+UqH z2_tUYjv!ep*x-ZRRa}gpbUL7MRx)h(@LOUFigysrM7ITLU-GFY>1HCvjqW0zJ^P5= z=*lGLLE6X`_#^e3_XPBc0|g~s6j<#NaJtifgpnJmhuKV7p_AYS<&H!&zkahe%rY;I^v|lDrk|VNJNkUA6mxF~@gr4nXJAydg-VE9jAPAq zz}oh&di>9Qo`pL z#(ug~Iyo&j`XEeCLavUW+IgCh@fJ)ZUUV7T@^P57al>khQ1$>y%(Cpsm|1&6S!U&@ z6vg=|t>KP-SECJ;+&7Z3K1|bZnYDS&wM`kFaJ4J?3Q!*{Jm_Zz#1Gbm>;RwG&P{i9 zbuATUiA54?8ylMj{i{eigf)mDI)cbd=}#kD14%L@mJ zl80hgS6)8wM@!EMe6D8|S#PF(;y8ZHf4N_{e;c&@{;jFGYLHbpr^slLI@pU&b35xC z6XN4f0hf;D>XB6QzBSk;PT=dTTD6Kb2X^(kd$PH7>zeE;a6z$A>h09qqQv?e@PWMSXZv|E%x=3e!%XL}d;7Jz7WSicBxC;d3~? z3p3zePiS!eN)SaNH(iuH<&*m4s<$S^8CBo)R9(V>TkT&zyfP1c*_O*nRvXv1RS){J zqvdl*-QY4iK-fuOc(uqMX-ED}pjli1CnZ2zUEB$_j)bB_P(7)u59^sirCQGGkKsY~ub9WwVRD)BfAHWvilQx; zIGSx-aq;9yp%4}D>tpiP3>4nR$5@;|_0#vgZXK)y?}BR=A``hD8G#AwD_mS+@%gqJ z4biUA+LAlQaXq;T;a|9BmplS__yWzc&S5gH$v_H+4`-2mB~pd4`-M(J{74*PfpF_h znC(fVcqiEODm>Z!%F0TC!cjAl__f*6XpWp|o2I{6Uq7qMch|KsXFnEZ9v1Hru8~$k zLP^DR(2%s>O*z7RRd%)`NKlmPlP3X}Y$x>U!1wb`{fgB_@)Cu@SI|WC5T_mxkNF=Q z#Rv=NiZwy4L#2NY8t4BTb({X=mnHY_Pl@0Dz6mQjJa@$**HM@(ck*|K@IYpOWixQfxEUlQeCNTal1(3XXjux(Cve6#DzuV#Iptdo zq|O3$jXS&?z_?lkWL|q7NrC^&6U<>E3Y;?X7IPI6S!uLR`iG|pG6*?uR!p$6d%9($ zJ^0V~d$F_Wlc{KSY<_626>wzRQ5E9Cs8LRztM2Q`%s2I~pB$>;b|*RE^*I->^YbD; z3{t+ew7A*-%H01d?j;XwZ`zxIRJasNMJ60NbcmF}`q6Y;e6q4MM~^;sOzQJJI&Z4&gS9q@qF_-wtoPlJZisz z&S%HQg9B7lHn7<7opsD78VbN0t)PrasK1f)z5Q7w8UxIOdI^v+SSoJmUK(x2fZ(&y z2M7(&){mt{k762{h`%$`+cS$+PY=SLvec@Od~<`OEpV*Z2MGR8ofv-$lHY@Vm9RDp*NynP;q3GWA5fpjKkgpC0m|?=i}2a{;!B) zNGub*yP`XYer?4%7QJ(j_u>$<88z?T9Sf?`zIDR-p7GgV#tZcmPPCeTnR3uvCw4;7 z2?3kg_=lp=LCVBHcK<5+d8XKvZ~zyynFCDrLIx}+Aw<{&1P1Q@`piJD?^!|)!peSq z`{qd(_Cgw?sjV$8Qf}obtKYumSfVw+Hu@mP?7nJv%-EAHXkuH7Z)#NerlqwQu{ z=Bj~n4CwMp-_5FtZeSbt+_YYogL+%|pB(YdPciwD75O=GaER zF+To#&5I`AD@pq*4Ig^mTzPu&t0koqUd>eX4V$<3fH$x*uKLbJQGFLGTdW7ON7gm= z+);GEbJvT4cmjhBufE~;5GoT>(t^Ar^lA&iMg~Gxp$<*XdB2Mn?;)xLj+_;qPqeH{ zGKkptr@f6j8xk8;V!$kQ>3jL_I3T6#5YL}dG}+7B_>Ocu;`2R*fT8^Vl1WqC|Etsq zUP`k~O}){kT;J3Ykja4>fA_yT!hVG1HE*c*4bOyd>)ZMvH*@9hf4dH!cfsEJS4WA7 z#82M`B>~{tbIM^N@#m(RIfDib0$`_{>oGvLW6Z+HlPo@+C-(y$Qj^iq#f36l4Vn!N!fFwM8PmE|^hrx^oq#Av&!Z z1~i@t2nzf~kuXP`tR7-e`u(MX?I^=2ibtw0X_{|e_?MV-Nl9?Sg3bpMHYXAuA|~1u~H)S z|9*HnbOAQaf7OO2(e!*QbW03vg#}&H8wiG#6h9tIM|LVxV5x0{!&X|BV*I3njx1p4 zCMHJ6sS+up{z3NgIzxx9=1hVAumtohV~?cclyXKkM<@tDEQpE>fJ=SA>~IcduuKIi zTGdagR&xJUDw#4`Tkki2DOK&*y{=zh2%E%Bj24=-VI(%%>XQl6{cVVSA_wR^AA zb_-+cyp4EK@RGG_-x2Gt#l#Rvqb?HCb`d7)x|$?(>ek<-zGMu?WANbnMMVJ-tG4oe z05$*ybES9n=bH)p+g=h>VG$F>l++Ibq1l?pu=zGa|KYS+Cckyhn)ffuuMb7neIWXB z(J?@E_y%!^mebi671x38Y(#rr-CB1X1u{pyZppT7i5PGRIuoyC@PxY%J*jSOzYx?J~>3~;WuGK@DS_e5CVUmU45ad(F`IQDQVlMjrwW$e(luyh(ZOT(Yl-+_uPdpA@)yEvJ!I5u&~ zCYCJ+d9QVPVLCc+QHrgU^n;f^{44HH-XjVg%!V7>qhep%(-jt!sE4lmBy#B*WdD5~ zC5ZqEY%!`GGHh5l5-FaN<~uZ@61rXRi{uwm>@eh5VAUB!BH2NWRAr2T0f}Nh?!JWR7#f_YkO5C-@YwONyBb@KhUsUm|xP9fqB>=%S)lA@csC1?`RrdY=?+F zH#feXs9m`h(`TmbC@cGMysc-=n$_@p-DI$UCntwGXl?{caMj8U`wAPTkap}ywi`Xj z*mD;?b1|un+>+!e3Wd^TE3d!qdPvx1ZLo2mc)E_3R`&h-Qk8l|ef6@89zt|ehhbdq zh)+Ph?_vAr1OHi(&S{g#UJfbWHrvcBMCSUcK~K>WfNDl{amd14_2~V;GaKNeVy0lhG~E0Qo_=BvpnUErGRqUz_rr>Vw#rPsH;sDXUJg?H`r>2!GLXa_ zfUdV`oi!+@2EOiQ9mO2v?XA&PT4Yc+PfsK5_1j=H|IX_LPXrvoohJP1#)UVQO?Iv@ zq{Jk)sOmc^h(q4-a$j-?SneWB4bUN?$CVd7-R5q1aiyrhQ}um(-;dJ<`*@8SkexeGq}}DMV$zV8du$Sh6eW7w^i8@v zzL%@$NL7Z^#wm~Xyfu|MB|l`u&=cQ&v#X+;zaa9o&U>x}U-7{t82>Czqq@Q4kUJv2wW=;h0N=_k@+ z-OI?fH`uuE*sYw&RPpVo(`IqCg_^ zmo62{sp;%7$t5lX1u5Bxmc;(-a&AdgWS)%!6NMxo1PNZIhzDV7E<}h{@fp@n+q)rV zhTiylp33HSZBezR^XKOgocC3}M#KT_qwp@+HCNs8(Cyn3ot+#15-6`TtG|FKt&m9A zeLAXgX9(*xK0WbxXqfAY!hQU<`u^f@gqoKHw{eAs-IVmO7V?QuL;t-`Kq;*X9z2-3 zeEDwFZ?(Q=4(Opu-t=wX<=$JYtb7hNu#LnBq7@2X7?zTe1W}zC?QTCGp%w}Sj4r0n z%xgtP{6*}UF`V#vD=bLC(D2HcJ(-%}W*QZDY|=bE=vYw@R#S=ZJ9yBbA)J?fa1vRIEHy{hT^x}$4?n(qrH22`ty9Lov)++Y5j@MUsas|27 zrc2j`{qs-H%IO=*!5qd9oJ&Wj?Nzwtu36?XL%>HiTPieX;;nLcvbw#WzyInLD=e_t zkPCJnx8W7@ebE||9v2oBkvpqx6|!^bm;Vl}+8e+JBP@QqVu{T=g6-dCL1b}Wkek`1 zpt5W9OEF{PZm7OenH(|M(+%pMAFS2hYO?zv~=gpP+(j zLq|P`KJj);ZUxnvP_)%XvD6N@m{}=bZB+;HHgqda8B;C2erS#ySx{WuuS>^xwF~$Z zePWSd#OLee(7&2ynX4bx`d^EUbs-{VRGdM}fFdDR*&SBoCTG}V^O^FbYr~?VqG0uvm6X7$4F$BrI(xi zdcV?=k}i|%P3yH(zMPKKL^To(XgdF5KtR!ow5VS4$DHrBTBp6L!l_D6iO1qjKZ{Tl zlro};niS&s`I{)JWDQ5oSiD#;t!5l%Klz|tD2)QJd@lguk;r~$@ndXXsqTx_2Q39t zb6*W>1jJz(Vb17kf49{~$Q9oNzjuW#MHD>l#11<C!Y z$}$LF5+OvzWtrT?XvMiBRKAj%;E~`G@o%Om-PF%&|M_Bfd(!mg`;=hB>w2zIAz|Cb%VwY9=btIhf# zzQN5kqr_5SP;tVvc+hM?04Io|_8&l3^f!vB$!Kr%L*@t`Xud*8I>TX~ba$P`S1b8RYAqm-0~Q#<+Z8@8MQg*Cn8Q zSyL=3I8QF|QJHi)B3BPE7AhIn;dqgfgva_zP>4(lKJPFb9Y|(fY9o>y;@%0~QePhR z%jiDf|6M!=W1SGjQ*_d@x0WIb_|>L0*i5KZm~Zb%sBTldlS^cwjh|&1Q>i`@W$KeB zC~uQ|edW*Xft!)toI76PmoO(AuRHLIAaUB+V(5Kg#u zQmUX%XnqweQ?mm}$~h&$F+tHJgvhD8B3*>b2;hG)EWX)TC5v#T%oFwT@$!l|?Bg?# zRRUru^%F5>nFyL)9@*AQ$FxnqTb%$TNWk93YEAlXmKFvUcJn_q7G?uunkMa$Bcr+F zvzGLA_Ur0_Nk6ym6K&g_;ZC0hD|J<38f8N4W6ZNA0zoR8tfQWN{Q5P^Eba+iyZ)oI zdJes96$%tgWYN+l#>ItP9mR0gP9}GPygO2^+Ko0~cZp)~{Gk7~XwLb7eb$GMAB92U z!GlAhqKzMwjNvshvGAE^A-v7$7GP zk2W)Z4HR$HmXFphBECSwGje1jdYzeA0IxyGQ-8_L>|3pB<(-;t**D zqU6i$ml7w>S3p^R2eU<9cR7JC1sS{aEMylFb$)mPatIz#_4rJ=;D!<(SJD(*HB*8+ z!hbstTYh(ku%ky$=EU4tvz`F#I%vra8gvQtE6CekdQUJ-E^6R-k)Ojt|9T!9*<(gU z32oW&ffKwd&e$wT^K1iRb&#Fdh}>h_=FRJ~zZ?_>ZZgMp|BL#Z3dNFY{Fifnu>(GF zym7OWNa-Q=&f#bXV~jWGx!z3crUo-ltDfH1DI9WJNl zW)kJdb3wFv=T55tQiWudG{z?+_#QugMaDu2@r6L1D=7R6Vkf|WJ$g{^45VN2VXZcm zlpQrosoRvRGv`Y;;BF^T-zBS)3Zlm)|7*Qz5>47Lzc8@~Y(yw$Pi0k9s8&jKOhIAF z=;*14o5GI1?vq9z1i50s558s3L<8nhv)Md^c4)-K(&4m!CyN{-^UU` z7~tOjBa4KLG58cq3&z%xgiz>BmG-YoO1VH|NlOm#X>@E8eP{?befjonTBV9TGBx4* zhdq4%^&n7>eO7N9QNyGERh(Y?JkMI`2aBP`O?s5H)x^lDw1UP_`I658~YU_9ztsz5K{=DOx%DVPl=BCJR0&mfS$H-??M>;U) z*$bgcoefvBB3C&qk)OrHpFt>JGG5F$I zXTLc4!Oi9*40FnVI3WJ1^>w(7(O_by$H!uG)r>AR@=tZOC7sOrn=5j5(sD2WX(Ot! zZ3lttN@QNMZrzW+1>^V3#Q($CnTGY)w(b6}M2e8)Rv~E;8PbHLkR(x*Av2*;3LzS0 zsE`a1WoRH}Y?3Kch=fLzp~;j)MWv)#zpMLw-uGSGwmvK$pY49Q{?~P$$FLv!z8|g# z)~4`w`}4}P-@QBW_(L9~J{HBab~Oo!wrE@;AtAA7#BaWCs>mLl%W7a73PO04&Y4!* zsb2JxZ0b4sIpK9dXGBfVeXXzwz0xwG>h3{&j=x3r!+j@GuM2+jz;8;|$(M=QC* z?6Uq?5GB0IOy>AstE#F(=>hTkY1$u0D`#9im?DdWZo*pd;Q(v~kW;$b-iBS2SD(nI zQQJ^}fuT`j9C$=p>=1W|hVt0AZ(?7A9!UX8Z=@XK?s6I@(gwY< z9R>}$!;SesL}1`!u9}lpED>h$UEh;&%2%nfM+%bs+7W%ZdfC~SVvnp2Nm{U!^?Tb^ z+6U)+8EI;=O+#dBJ7rG)q~c8g6jr9rue44KuuFF4WM(M z#!+P-{U+kR3|y##RGJTYInSRzUw=kS8y@`Hv@ozPoB`&zx@v11^l3bw{p{J9q5d8e z`&3?zTDRaVA&cJ|r@WT{Uw+IjWRCOUi!m`3TzzV+LKa9jG=%KmzdGA{oMdX4iL$VN zym&%%ZZ+Kc}4$cI(Auc!bud|$617ekt-t6hmn>m|L@0LjeMNrT!$hi0)EJ1+U# z%L}iCQJhqA*{^1r_)LSXiqZQz-YR?Lmb+C$&aDjj8b`>7s4v-B%wE(a9r}1>_8xpb zUJ{$^Cl~D{T%S)*=h1diXwgPn&aqCWey3h!8elKFii`RZ{j9Sb0*}=%P6X%di5}V! z&m@-aX!5>u?%bCS4pM3c|2|PxU`R6)TKn7;P+0J$8R}n%bob(cIm><8TYncFzW9~& zlKE*vowkvMT#>35{~4rTHYRc1P!S34sYcbg%xY-KshYZnK30UX1DmDft;R4-%~8nM zA6S_W0A$ff)v~K$s-Yy)Rpa7INv53~(uq_%XKL}9>MCa%zW_TZt2 zrm~T#QI-nLzs=5O4$@?%k&3E=imTk#;Z5M}&;3A_U0l-Fz8=xw?Tb8^K85*W!gG`t zDk=fI@q%+X9n`Yql{u0xJ`T1hh;-mt@#K?l%%rXhF8L=2O2!6vcTcxp;vS2vte!Ke zB3vjG>&QuVc6lb0Bb_34T)L#ae@4l0H2bcE9>!qyBE2C-nli%Ek3xq~>emillbqYY zg!03q7Tub;CEdPV+i0Ka7|k26s=CRb8>p|nWNP>3lQD1i=o$_mJGL3Y&XS}RyF4{d z^D-_kNQoo7V{th4)&qu23Pzrz-nyUp5ElG*OT{#c{As#bRpT*t4$syeKKzVCE~Sw& zLyCtF9Z-Z+Kex=TDrXQ!ZH83gjD#owrl5hI-jTTEm3K9$SmTs2YFq(9ufwoOm*@*B zXXECAk`^NT<0TTg%EJEdpHV~a-C{wfkGD6T3t9=Y(r`FNXyyM znP;%{wqrtiGV!<@Ql+9ySz&T`J}L@IV^jzC*&+I%Rr1tnRSDDb%RDVkYRSI3d#~gG z(S(Trgy_ZSHru!D+VCq>HB^+sT2v}KKomM$7fTM=b*z9`Hd3Ec4-RNAvV#4eS#pWA>+HKdlFoc8hX)6XStgTQ%B+`N7vk~d&yqc({GE4=)FKY=GocxU1&{NdfWH(J}b+y zN-qF+r^!5D1u>aFx)OG@->Xy3vjx;%v?(7=VOn(z z-Q7z2Oe7Zk_|X^LT6RC+&>>ly1mn8QEGSjQR#l~HL-ha&e_JkUb#7f=-8O#gShDNE zzjn9pH@FQ`eOf$m)v{&o49m8B+aSA6A>VeN^Qq}yPn%kV_Ka|-D7sh#Cz0V2jp*Ln z>j@lLpEl@K?EOfOQQWbN=ZqkXgZCKm^wG2}bPhqw(ND$*1IxGrk&)THRbRgb zEBvD*qk+pA6IBh3HKa=@p|YP{{4ZVNrfS{F0)oHWR3QztkoS@zSTgXW;rf8qE{%s< zccWZJ(}$o=%Cd)S>wB_R-K|1qzZMhv9{)M@=F*DgtQs~nEw63-Jbxg zcnp#%?2qp2vDz}Q%V|@#wFpj2x_x6K&dvU_#ybB4mI2qH>nGh9{bF(SfTCYdKIZ_J zV(1-_d|^9l>N#)VLRYDqQ-+SV`h>q{+<^!B0lC!uT%}w4(K>OQ2Q)yp=5K~<$iz!NyB@KobDL?Rpj}&q4B*)94Vg# z{}{3O5ZtYu$Q43KFxN`P>dfktCtJYQ<;+@A0)79;ucqA-(_x`;gjFPfq?_20b zE|H=H|EH+X#h<^^zs~cUbO-c|wW86=4Qmc|LKDO8SXYr(Wnk zz5AzX*z(9Pv7yGYpQ|@4Sa7^jz2S<~NMT9RpxX@?b$>@aC)eJihwS@A4#lA#hr$$; zMoOK%sBbvqkNWW@yWU5csx+3iw-Fd`IaxjHdPrF5;!%HL9@~<--C9`@?3iAAp4*z& z^q+S$Nli@+;%fC-$Q(|TL+B^bU(E{{Kkq)&6>b@w$zCcg#8wC5Hei)kE&%%vSz&Nk z-s70(;fu-1KdG2GfUjS_9;D+i4S~vA6In%R<%wwvUVPe6ymp_wNMW{(O?g$-xV5i_ zHzwck_$sSCdEl*GRpY9uKDe**yCXj=iuu9`LJ_NgtDyqFZJY1;IN(NfhamRK0-cmfjRqL!@7qtlj ziLklxdKGpHP|#3ZPc5(Wr0gH1tJ_^h=GDWV8D##1^&u0i4IltRnb$pH2?+=m_4GN8 z5tIu&*|E5B)f3yBLn(M!{6@LcSXPay^7d%!E{zJL=>Z^9%Tmt3o?dvJw9PXPu(A z?-F58(Z!Q}LGCyv!Ql!TH_N?xzWTj{ln1mcJBx00$`{$S=~`b)9T3EFGsrble$H#? z^7`(j1Db0h*RjdvJJ#f$S2LP~1j2rW<-MQ_Adu*+ zc~f4(IK@e9VCcCkS8|SerizsH8npy78lYP3C!g47cU`dNigqg6lbM&-yY$9tFnmxZ z)?~FOnw@4Tt%Jiy5P+}BqDRXMd?_>pLot!#6I#w~w;L#R@)dNif%W3LUmzEe#pPpY zrt2XU_lN@wJyBps9>9l;K8iYW2``9R5sUUxN}7F_t`*JNyrA&jvdDh8c)LDNc~?)vU}9fWwGRO%b-X?qQ_2wL}KY8s>OKx-Xsu`pCmD;2lf(d^MZ8!-vV8RyS_|>#D2?zn>D(EVX8^*?(I!dfdN+BuZ7KD6Tr!+}8nq+RqHq?R zQ`pH#)E1V2!|(3hWKo}gu~hs*Z&%*if$c1ngDuzdw!kzacUcC1ShG8GJs2$TYj)T-X!%~26QQyJsnrLD@(x9b>|fk$pAu=w2WC>^ zX~g2SCmHB_x3@e>F5L_m5)lw^k%k;un(j+^+E#$lhF`y!U;b`v+#eKlz(Z0Ur1cR_ zz{>#~gu#}`O#ouvy-RN!ft8V&0Dku9p*B{xA3iina`x{NZ#)CRf)ziD!!1y_Ag+Te zjTRp5mtKUJZU{pX3Vf6?oS#XcS6PSDTqd8`Z>^bkXG%q-a<0X=x2kY9M+ZK*A+Ut} z6>yRx8R(B1HUHYWlloV~pvMLFP@GEL&R8i#yVz0diU36-7nGCRsePZpY$~euuU;!v z&@xt`VM7}g1b7#E(UAd+)8oDHLhj#U^W2_2uR2I-?K7zFi0$?To{KH(5xN9+6AGtKm-trUxb`n|J2Jq3vc2I{!rjY$G@Wp!jKB95dXW0 z&3A6y8gJDp0!C==w424M3lo>!@4(%wsz3xdS|n5Y*f_mQ%UoSUPAWtP!;|Oy&Wh0A ze)_b4Z&{f}gSuS0(Y5A$LV{q@3;lEF?Ig|$)TTVs8bEE`y@FXjp$;I!gjY?sNG zE6ik+551;N``%|(!3_||-D0A2oGD0MC=@ke`?k*lSy*V;fh=s<_Wz44bp5$jv0LQj zgG()^hwQsJ>vezqe6#rx=TCD|={6+2R$~4L(F6CoGdmm*KI?`HJA;mm-Vqmd*wbhU3e*ly zuR{>$=Py{0q{jH;JSGO)I?&NgbXM?fwy%7v9~%>+A`^(cq5rj82Z7!O+&Z}ZkfZzI zi@lRePIq~1S!H`Lu}vav_Fc$#k-ivN-n@RjV}7}Qc4OefU%Xhqmhac9zxZYUbgs?O z`$y|YSkFm2pbBKp73b(gc)1LFfAG{P&;h{(d$Hphn8V~Iuy-9$mRf-d^rkbN?)}Gp zTGopfGnYFl$g8TN`FUiiXZ&LFD3QEQNGahATR=7As8Byne zcI}|T*kE{qf-cqd?Pkic3bpcGv?`Pccc}&iP{^y?7%X2;Pbj72-Q=MC3o=D!Q!B%1 z9M&?n3-$ZEqcsB|GI=1#x137w4i=!G;MN|KC#9duNLyLt)`SNh!kb(h~) zjWb$*gkS&j^3Q{!yC41~-TY5Q*&fDbCL&tNrR;?J6E&pp@gXMN`BQZv$GGJg${VWY zn9&=ImgY~i>QOSxLU`i?7ZS6D8Do?_k?J#Rq3IaCeyW-8{swAtS;g*$7sk zF4?{N!?E*hKk$;FY}8EXy%=oKZrH#$XcSPaeB;-GHDDz%r70lyEUZkHMwqwkL-8zq zW5~b7#ak&_GDcVTOAc=%S(#lsKdasx5;`Gbi0@<7s|y{Tnnj1b>=LM^uAba}Fz!DM z4F5LJBj+S_cz;EgWy^BQel<$WH5E2Ym?H{pn?JSsjKwbF(h3Jdeino7` z=lLbBu9vv#`@Lgl#wLAnq!nk6TdGK_{Kt<|fX8!0xvqfUMIqh8rl+a1)iJEHicCi5 zXQpN|;wMG2+nwg!i=q1F!eKgja_{>p%H8S!ys}w(=jaVKLSWq$UOY10qsxzI{;hsgZeCd};~`%WfZUhO^Tx5E`cJyO{kx=Yj znb|U&)2;l^P9#@diIbz3XY9Op&m`ihCQWztr>TOMxMu&r2M68ynOG+=Ct-++poGzx zA_jqirID0qTz3MW)RR}j5_kE<&$(bP&mZB0DjTWg;M}HGUGH6=XP)uy*)tdVkLnDN zLCleMmJ1uf@)ioac#Z$oPOht~-awO5o2(i$@#&8)H&*IgUAV-;F(+u!P)J#a?^vk& z+U@*A!Q1-#ZaY(eb@qLJHQrxWwC1$WRCCLwjWO=9KdKY@Q#D3zN3mt1Yrt{wxMr6e?5-d19IimMtrzzY<>9@NwfNcgw#w!m!4-0lG%#^Na30-o#b+14JW~S)n5I$VV-pYDK3DBjO^+}N+DgNrUq7~81E~!)A zGp!N)6yE5fs73ZW!pihG!JMPyymqaVX>U)X5?%|Zu7uRlNxTc~?AiYR_3Bo-^`KFO z$RLmsV2kWN&ICdtI)xgz7d9<^T6RNKjNo<~H;8LV) zb=CLqMU+ekl1TkXG)WlvN@XlM5!O1m?l0fB)aN(xHfa9hnIY}wd|^ziJpp*Ui~v`i zs9T<`+IFXE;_w=uh^qEOQysS)29kgA{`~;+f2@ZwHXyuLfA`;!pSbIu;B_XYd`oBq zkS3=(F$UPt=X1g-I`9<}uYIcXglveKki~=ohDMK47;T`A_k8>gHuaL4BLW%){THkU z39(Mvx(oQ4k&jSUFu?4_kuOT4E4WRXq^1-&D)J^{8OUd+X$!YIfronW;s=kADvEN1 z!-zxX({gue-#Tz1PVvli()W_9F5k+zxLy$GiZ`cwzWqQkEo?CdYMatcST92}{Po;v zo+3Xh>npWd#-a${;HG0#HsC{_h5e2QijjQ?@B26`zW*D)@HCiK$@aG8?Y?yDMH8Un z_6dcMa2zvES&Az?3a z4LfuwV|B4$Iu@nulDWpkW#7uD38MW&&P9U}6{%bk7GaolLze}d2p4KxuvM)ebRSxd z?Jib23R!|J`i`zW;}R~*ZYp#%)yS%IbX&7O@n(Rm>zXXk9R-)NC~v~#DPx~;R%eG)hde(qwt)P2eaO{qjY7vHuGf3O zfafJTx)PUY$JXKLSuKP1F9OIET+gYKoYdok1&b)4xTox?gwRVl;I?9gP<>3uc>kpK zg;sZXj2M?M`i#FuFQa|o4bFubty}uH0139^KqlXfB;wulq6bfFg=^Rlp zh(mKrOF?QD)8nzw8zKUJvLf@%vuD+q*rzHlpdVn}HW9d3Jtom0Q$F3}fIXaw!p=1D z!#+`lVVKj8Nb&N-x?eQ>LrSS$H5cMd_Dx}J zuxD_A7(L+1FXPV=8tk0irUIJZ1Pe*5kr4!WR`|CgIDnx4hX=-{FX%GrqQEr2hn<&@O z<&7gJOW^*BtYvVp5mikd7U=*@i0WM{Qhf3bM$r%EK7get)VN*z+Oh^7r#l1B6&U-} zIfhn|!}auDvJ^EgN(;smbW{VYQrU(~L#6!}E_?&DJ3Y~FW>+Uk5DOp4&H>m3Ex)qG z^+<<@d)TQ{Go_z;G*sC&n%5lmnc)Z(gekrf8A-O9V2Ri5=d-Aa#7(^+hTcP2RrbclQhA4uc?ea-yFvTfvoN&H-mKRd|w>)%(`2sltJH%E!h#r{osP!IaYe zR+rvw=NCDdHp9z&sIE@zjh1lKX-us!+z^3tMNWRcPKSnZJ$9If4<8mno;Vd?70OBl zi|hyomxzz$5zPK z$AyjPe3Y-s&nXV$BRSF(2tz+acFkSH2B8;o*MMB4MonU3!42!cFB}z^-wN*Ua=Ib4 z_s(b;E1dr^A0FJl#ox#vseVlNPGwF0P&o)2OzDw87GGes#X??vcp5&4K6^a~s z$ZGz5PAEpZkF&DQ>Taud^tKrwR{Vt7n4&KJ?XcquvXR%IR4dINS&=rt&15N)l6)o-7^r%;b(hial?%}R+~ zMJVfJmWxG2QfVQ;-P~beWUb>A=`bkW@>t=ZAvcBXX>@*{h56@_5LlZ9X%s$xh3tqA z8JXbE3+>XEPnt5@D-k)cHHeY%{c?BeIqZ8_q1SaYdaFjJKrzdCj8)?EOQ`2zC;t7t zg)yT*bLt8mqV4{4n8cbe(BDu0JMqw=CZDyn6J?E$snoptN8zlVRWe-Azk>6>#3~jj zGhjk-Hut1I!S{8uy4AOfN;_9d(@&(S{sn!29p zd)bB?^pL8rWdMPkYDaU%tuk;)$5yR4QOOx)LD`xD-M! zT=`*664Y9jp3Nsh$*{qO*%y=epBA9gc2`X8?j{VGUWDm)Ag6lI<<0C+zw{C7Lr|ca z@86#<`O$uR!Gw@~Zhu258fdzb7qV=jb??YK%TlB}=nX-;8E2}gswSQg=3N57k@zsJ zZLE?^3loUbM~`Adm+oFZDeuz5_8~ECw`FDTy5+Z2X*}R$HpM3hbj(Cpr|KS zEZWXcq_V>*B6lyy)!k_pv|0JoV&t- zn4Y{Gd8cWT?r@Yzp2w;GLounUP5u{dhz~n>Fz=3!SNW=+gL*gA968YBbGU7yn3E&4 z&g<8YWq%^&9HAtFA@D$cE!+~cahE)t`kxtveLA&agxHhIH*X4juBToa0Oe&Ldr7JG zl7y{ngn>CcWRMRytokL2eU@Js%NVp{=fY>fweDz#nP>D+M@e|J&`0s+vbXLDRmsKd z-e)9AI=Exs0}>~A?0fuy(Us7)riy-2WSzpN->Q?zTR|)#_-cr>qA#j1pv(7cgKV2; zw_L^dh@*pdtKyz;P`8I0H`sXqVJ{5B9Pg_0%jup1`|@w)CCCVFgM6C?D`t(@yH2fb zkcGwH(CO`iw(F)wS$zKXExW`~S3(W3cp?HxdVJ_UPCd?0_n%E)>}PWB%(-*JHs!x1 zj0hYuIS0m2>u)Tg$IHzX#B%wjH7U#>K;ZIhf04qRzroMskUDQVV|<|G-`o)YVtg#R zj-X3&sXyQLJP%NL=EyMf0rPBZ2LBz=TibsT#F64EIOI_;ciuXmqmDBf4)vaemf+tIerJv|Q>sN{uR~O5KFRx$4#TeY+{Z(6S;x3W7-MF;rbH5_Om&+lvyA`o9*I z9Y!^SK@e66VCK@wI}->B->4Q2nAI8uZit*?CrH~{^1<&byeDX89;sJ z|N50%1s;5${ie9M+eqkX7wxZ3x0RjOKEh&hzE7W7PhzEJ=U1w8$a4&pWHx(P zpJ!kJ9P|ivf5-5CQ)+jeJt2ZNM;BHGQ^c6URo9u;V*z4S?t{ntOv#iC3^=5CYCDrB90;F0 zS5!^V5MsezX_AK|Sk+YzpWgo6KD#6&|-| z3>I}~+kCY*Qe@MSq&@T4yct)6vpUB0c))I$Hciq>k53i#ncNt6Bl79Zp~}k3;DGAd ztptRi?7W~9SiWmw>5Hxn_4T!S#;!+D9WvVf_Db7c)E_4u%dGh9f7WWpqwgzZ=3*&5T=A1!Cgj>{2dzwg1qzl`32vBHNq#(OreN z0iCrd@&^YdRpk5?nmp+Sa0cGi@4*xl^j`@UM8Heu9KJ_e-Bs zYgHPHguNWd^W;Si%<5r7UML)+g9G03$L1D1G6qCUWPemvUI)58a?*A+4@L`mCLvQO z(S<2|@lKjiUovme=WhZl)U*$-1_jvi3#Q0rJ||9|th0&Q%tESgNQ50L8wzpaLg_u0 zq^-Xq#q8{K%ml*O#ra7U=R88 zG|aIT#f+si)7>e9R7YJui!V!?GXugC6TIu}ME}}Vrsc8`EV&N)p#LIveH#gNiWc|# zpM@p)wi?~MR#L3fE{w#EtCZdIS>~$6QvCP7b2^P zoy8V@-^^8DNF#5Oj2*g?p*zaRO>r;{=(SJy%{0c@n~4U;;Zh2QUs#&v$(ekLNU`Xm zY!I^c(BkXv_hANLt0Hf*r277ej;f4x>#8s6;%p&)634mC(hIi*VxMjsU8Ez37j<8% z(IK>a3SWvPM^#zby@9nt&g%XA;p^91gqHaw9!%S(B7D~!HOl1MB-i)I01-h_R-{I4 z$Haj?H}bTglW%!0st zt&t;Hi(}NWa$yk~DoO%;ExtUDe7vN4YImteUw%QJU$10o9fnR@m<-rP16m%rof5I_ zCX)x&BQsLvxD?sX_Tk>qOS?#&kr-4QYtbd50*-VGXqKata}+CtOG=FC;N*2*k->`n z_*B!Ra}U4+PqEXtGF22TVo@I|{2s7$Q!lXi2ggN@w|Vpq@t5ei>59YTr+tlTqC$ZS z2zXa{VQvUuRFBivn$t03;55H~|9*Y)+Ly~hQ<8I(CB}j7d?KOg+Qp;QL_QeddzhoG;*UONz4}u!xZM zBiHs@qTjFAOa1Vzq`!jMgsnlB0ntZEfTTe*s*|J@*!-NU?9x06-yG;P<5*2!@!I$D5$R^B^BN~ACvzmV#AJ*r~M)r(KN z&70;|nGT7WOPqW|P21#Gar)LVH1${JZL(Xm=vmtCj)duO|I05_KTZw~Fv{bP4AP7r zLxt@9y|)~OYPXQU>)NGKcfBbp%C%hxME#4SLG_z_^{UWN6YDD_ufinwQc)3;r;%jN z{S=nF7RlQRgKLv9u;>i1`K&1J6lt2W%iGCmsJ8zxoNyX7^W;+B5Ev;aS}dfM@6#Lq zOO45uhtLKN)wH5Ve9vGjos)Kp7i-@H@nAQpUy_Qy3+#?oI-LSJhl3FjZ#oP@dRi6F znv@NEjfVFCWf$GpKB+qfi|5XLB`mjbzRp)mx*8mBuo=1YxJj}2wK4iV85beWdmqm8 zGPHnpI2JWb+Q7e)-{#DGICs9wx09FlJ9TP11O({pJbg~?X37m*x$a++KLg@fA$Syv za^)!zmZrHNsEvT*UK4-$GL6J3!}S$#4*Qfi9PT z$mOv_TpK0JvF2qGc)>Ko>F+wUTYECU{Nk}M<&D=0h6RCP5jke1qN@GI5t$^>l;_&d z-{%0u<__mPG8w%e9#UwE?+YE^=8Mq+T}#;2bC+e6@@1DGVgU-KJ%Y;n9diIKBxNH8 zZ_6*R`9_0`GuIQZIkp(2^)a~=Wid7BS|wKjW$xoo8_3N_H!aOY)5^KkF2Ps6E^Txh zK|gH45x(zsGc_5>2Av{Bg8r+Bl4IA= zZcs}W*iPo!DhMXUR8G-D{4U;$4#3zgDv>hSN6WN-I*SEZENrCCPxG%=%VW;bJUP=zI-^jII&01#<-NNmJ6BU zxTNfHU6JH>Twa$&+EMF^lpcfG5}i^-=QBb5P9AG9Qg-fJ zk*)*NYD_>3{dWtW>Thc+^j(}09eYHGN2Vu9C{t%Ynww1Q(zO%AFi^0(uw9&F5#Z@I zV{*&X4MF?%d2HG8`l0yE7(!!^>q!~G;XFzUDa7wzzhV<)nh5VjC;kX%LewvyL87(! zoZKGX#(so6fVGDjT|&z0@K@MI&U|jb>IFt2`EpS=5cSB^t<*-3`uzA9bK6OQ-Otnm z2g(G-29F=D8%w6fMz8U@_JZWjDUN#gKf8cTk6d~Owa|^k*Roz_dA4J5Vxqhn8gc$O z13w}ZAQ=^GirHzIALWC4Ndh2xq4=`5_9A_%SJZi;yMcI`Dth{Ou0{3z*U!ZdCz|<~ znD_?MeGjf{)?>Gj6Z58oaL;FYs^riK*6l!J>b|jN>|Hu05TE zGK{pt&jiS5ZELB4GZr(%Ba2X0^!aG!NgQl+a;@pUD5B_dS?Q{)rzfnG(5~#h-9!lR zxE?Z-wl7%Dq7wq@7%S88u<-C(*RO-n<%|85IQK(5*VtvD2Wg6_pEx#-tdgsTkTU|W z-z0fcbvl-xwY9kqcbMz#MMXxrGk^Ykwt?j;=o+MzR|rBw(N=m>1VN#?cKc~JHnk)R zW%-obNdkMDve;wi
{}
', + content + ) + + content_preview_formatted.short_description = "Content Preview" -# Register your models here. + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'sender', + 'receiver', + 'lobby' + ).prefetch_related( + 'sender__sent_messages', + 'receiver__received_messages' + ) + + def get_readonly_fields(self, request, obj=None): + """Dynamically determine readonly fields based on user permissions. + + Args: + request: The HTTP request object. + obj (Message, optional): The message object being edited. + + Returns: + tuple: Fields that should be readonly for this user/object. + """ + readonly = list(self.readonly_fields) + + # Non-superusers cannot edit core message data + if not request.user.is_superuser: + readonly.extend(['sender', 'receiver', 'lobby', 'content']) + + return readonly + + def has_delete_permission(self, request, obj=None): + """Control delete permissions for message objects. + + Args: + request: The HTTP request object. + obj (Message, optional): The message object being considered for deletion. + + Returns: + bool: True if the user can delete messages, False otherwise. + """ + # Only superusers can delete messages + return request.user.is_superuser + + def mark_as_reviewed(self, request, queryset): + """Custom admin action to mark messages as reviewed. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + count = queryset.count() + self.message_user( + request, + f"Marked {count} message(s) as reviewed. " + f"This action is logged for audit purposes." + ) + + mark_as_reviewed.short_description = "Mark selected messages as reviewed" + + def export_conversation(self, request, queryset): + """Custom admin action to export conversation data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} message(s). " + f"Download link will be provided when processing is complete." + ) + + export_conversation.short_description = "Export selected messages" + + def delete_selected_messages(self, request, queryset): + """Custom admin action for safe message deletion. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected messages. + """ + if not request.user.is_superuser: + self.message_user( + request, + "Only superusers can delete messages.", + level='ERROR' + ) + return + + count = queryset.count() + queryset.delete() + self.message_user( + request, + f"Successfully deleted {count} message(s). " + f"This action has been logged for audit purposes." + ) + + delete_selected_messages.short_description = "Delete selected messages (Superuser only)" + + def save_model(self, request, obj, form, change): + """Custom save logic for message objects. + + Args: + request: The HTTP request object. + obj (Message): The message object being saved. + form: The admin form instance. + change (bool): True if this is an update, False if creating new. + """ + if not change: + # Log message creation for audit purposes + pass + + # Validate message before saving + try: + obj.clean() + except Exception as e: + self.message_user( + request, + f"Validation error: {e}", + level='ERROR' + ) + return + + super().save_model(request, obj, form, change) diff --git a/chat/migrations/0002_alter_message_options.py b/chat/migrations/0002_alter_message_options.py new file mode 100644 index 0000000..49db07d --- /dev/null +++ b/chat/migrations/0002_alter_message_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='message', + options={'ordering': ['-sent_at'], 'verbose_name': 'Message', 'verbose_name_plural': 'Messages'}, + ), + ] diff --git a/chat/migrations/0003_alter_message_lobby_and_more.py b/chat/migrations/0003_alter_message_lobby_and_more.py new file mode 100644 index 0000000..3a2b86b --- /dev/null +++ b/chat/migrations/0003_alter_message_lobby_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:37 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_alter_message_options'), + ('game', '0003_turn_move'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.AlterField( + model_name='message', + name='lobby', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='game.lobby'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['lobby', '-sent_at'], name='chat_messag_lobby_i_96d6b6_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['sender', 'receiver', '-sent_at'], name='chat_messag_sender__ba5b4a_idx'), + ), + migrations.AddIndex( + model_name='message', + index=models.Index(fields=['-sent_at'], name='chat_messag_sent_at_67fe48_idx'), + ), + ] diff --git a/game/admin.py b/game/admin.py index 8c38f3f..9176f49 100644 --- a/game/admin.py +++ b/game/admin.py @@ -1,3 +1,2109 @@ +"""Admin configuration for the game system of the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the game app, providing comprehensive management tools for administrators +to monitor and manage game functionality, lobbies, players, and card mechanics. +""" + +import json from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from django.db import models +from django.core.exceptions import ValidationError +from .models import ( + CardSuit, CardRank, Lobby, LobbySettings, LobbyPlayer, Game, GamePlayer, + Card, SpecialCard, SpecialRuleSet, SpecialRuleSetCard, GameDeck, + PlayerHand, TableCard, DiscardPile, Turn, Move +) + + +@admin.register(CardSuit) +class CardSuitAdmin(admin.ModelAdmin): + """Admin interface for the CardSuit model. + + Provides management capabilities for playing card suits with visual indicators + and efficient display for quick suit identification and management. + + Features: + - Color-coded suit display with emojis + - Filtering by color (red/black) + - Search by suit name + - Readonly ID field + - Optimized for small dataset + + Attributes: + list_display: Fields shown in the suit list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the suit list + """ + + list_display = ('suit_display', 'color_display', 'id') + list_filter = ('color',) + search_fields = ('name',) + readonly_fields = ('id',) + ordering = ('name',) + + def suit_display(self, obj): + """Display suit name with appropriate emoji indicator. + + Args: + obj (CardSuit): The card suit instance. + + Returns: + str: HTML formatted suit name with emoji. + """ + emoji_map = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + emoji = emoji_map.get(obj.name, '🃏') + return format_html('{} {}', emoji, obj.name) + + suit_display.short_description = "Suit" + suit_display.admin_order_field = 'name' + + def color_display(self, obj): + """Display suit color with visual indicator. + + Args: + obj (CardSuit): The card suit instance. + + Returns: + str: HTML formatted color with styling. + """ + if obj.color == 'red': + return format_html('🔴 Red') + else: + return format_html('⚫ Black') + + color_display.short_description = "Color" + color_display.admin_order_field = 'color' + + +@admin.register(CardRank) +class CardRankAdmin(admin.ModelAdmin): + """Admin interface for the CardRank model. + + Provides management capabilities for playing card ranks with value-based + ordering and face card identification for game logic management. + + Features: + - Value-based display with face card indicators + - Ordering by numeric value + - Quick identification of face cards + - Search by rank name + - Readonly fields for system data + + Attributes: + list_display: Fields shown in the rank list view + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by rank value + """ + + list_display = ('rank_display', 'value', 'card_type_display', 'id') + search_fields = ('name',) + readonly_fields = ('id',) + ordering = ('value',) + + def rank_display(self, obj): + """Display rank name with value information. + + Args: + obj (CardRank): The card rank instance. + + Returns: + str: Formatted rank name with value. + """ + return f"{obj.name} ({obj.value})" + + rank_display.short_description = "Rank" + rank_display.admin_order_field = 'value' + + def card_type_display(self, obj): + """Display whether this is a face card or number card. + + Args: + obj (CardRank): The card rank instance. + + Returns: + str: HTML formatted card type indicator. + """ + if obj.is_face_card(): + return format_html('👑 Face Card') + else: + return format_html('🔢 Number Card') + + card_type_display.short_description = "Type" + + +@admin.register(Lobby) +class LobbyAdmin(admin.ModelAdmin): + """Admin interface for the Lobby model. + + Provides comprehensive management capabilities for game lobbies including + player management, game status tracking, and lobby configuration. + + Features: + - Visual status indicators with privacy settings + - Player count tracking and capacity management + - Advanced filtering by status, privacy, and creation date + - Direct access to lobby settings and players + - Custom actions for lobby management + - Enhanced search across owners and lobby names + + Attributes: + list_display: Fields shown in the lobby list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the lobby list + fieldsets: Organization of fields in the detail view + inlines: Inline editing of related objects + actions: Custom bulk actions available + """ + + list_display = ( + 'lobby_info_display', + 'owner', + 'status_display', + 'privacy_display', + 'player_count_display', + 'created_at_formatted', + 'can_start_display' + ) + + list_display_links = ('lobby_info_display',) + + list_filter = ( + 'status', + 'is_private', + 'created_at', + ('owner', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'name', + 'owner__username', + 'owner__email', + ) + + readonly_fields = ( + 'id', + 'created_at', + 'password_hash', + 'player_count_display', + 'can_start_display', + 'lobby_statistics' + ) + + date_hierarchy = 'created_at' + ordering = ('-created_at',) + + fieldsets = ( + ('Lobby Information', { + 'fields': ('id', 'name', 'owner'), + 'description': 'Basic lobby identification and ownership.' + }), + ('Privacy & Access', { + 'fields': ('is_private', 'password_hash'), + 'description': 'Privacy settings and access control.', + 'classes': ('collapse',) + }), + ('Game Status', { + 'fields': ('status', 'player_count_display', 'can_start_display'), + 'description': 'Current lobby state and game readiness.' + }), + ('Statistics', { + 'fields': ('lobby_statistics',), + 'description': 'Detailed lobby activity statistics.', + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('created_at',), + 'description': 'System-generated timing information.', + 'classes': ('collapse',) + }), + ) + + actions = ['close_lobbies', 'reset_lobby_status', 'export_lobby_data'] + + def lobby_info_display(self, obj): + """Display lobby name with ID for identification. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Formatted lobby name with truncated ID. + """ + short_id = str(obj.id)[:8] + return f"{obj.name} ({short_id}...)" + + lobby_info_display.short_description = "Lobby" + lobby_info_display.admin_order_field = 'name' + + def status_display(self, obj): + """Display lobby status with visual indicators. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted status with color coding. + """ + status_colors = { + 'waiting': '#28a745', + 'playing': '#007bff', + 'closed': '#6c757d' + } + status_icons = { + 'waiting': '⏳', + 'playing': '🎮', + 'closed': '🔒' + } + + color = status_colors.get(obj.status, '#6c757d') + icon = status_icons.get(obj.status, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_status_display() + ) + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def privacy_display(self, obj): + """Display privacy setting with visual indicator. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted privacy status. + """ + if obj.is_private: + return format_html('🔐 Private') + else: + return format_html('🌐 Public') + + privacy_display.short_description = "Privacy" + privacy_display.admin_order_field = 'is_private' + + def player_count_display(self, obj): + """Display current player count with capacity information. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Player count with capacity and status indicators. + """ + try: + current_count = obj.get_active_players().count() + max_count = obj.settings.max_players + ready_count = obj.players.filter(status='ready').count() + + if current_count >= max_count: + color = '#dc3545' # Red for full + status = '🔴 Full' + elif current_count == 0: + color = '#6c757d' # Gray for empty + status = '⚪ Empty' + else: + color = '#28a745' # Green for available + status = '🟢 Available' + + return format_html( + '{} {}/{}
' + '({} ready)', + color, status, current_count, max_count, ready_count + ) + except: + return format_html('❌ Error') + + player_count_display.short_description = "Players" + + def created_at_formatted(self, obj): + """Display formatted creation timestamp with relative time. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Formatted datetime with relative time indicator. + """ + now = timezone.now() + time_diff = now - obj.created_at + + if time_diff < timedelta(hours=1): + minutes = int(time_diff.total_seconds() / 60) + relative = f"{minutes}m ago" + elif time_diff < timedelta(days=1): + hours = int(time_diff.total_seconds() / 3600) + relative = f"{hours}h ago" + else: + days = time_diff.days + relative = f"{days}d ago" + + return format_html( + '{}
({})}', + obj.created_at.strftime('%Y-%m-%d %H:%M'), + relative + ) + + created_at_formatted.short_description = "Created" + created_at_formatted.admin_order_field = 'created_at' + + def can_start_display(self, obj): + """Display whether the lobby can start a game. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: Visual indicator for game start readiness. + """ + if obj.can_start_game(): + return format_html('✅ Ready') + else: + return format_html('❌ Not Ready') + + can_start_display.short_description = "Can Start" + + def lobby_statistics(self, obj): + """Display comprehensive lobby statistics. + + Args: + obj (Lobby): The lobby instance. + + Returns: + str: HTML formatted statistics summary. + """ + try: + total_players = obj.players.count() + active_players = obj.get_active_players().count() + games_played = obj.game_set.count() + messages_sent = obj.messages.count() + + return format_html( + '
' + 'Statistics:
' + 'Total Players Joined: {}
' + 'Currently Active: {}
' + 'Games Played: {}
' + 'Messages Sent: {}
' + '
', + total_players, active_players, games_played, messages_sent + ) + except: + return format_html('Statistics unavailable') + + lobby_statistics.short_description = "Statistics" + + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'owner', + 'settings' + ).prefetch_related( + 'players', + 'game_set', + 'messages' + ) + + def close_lobbies(self, request, queryset): + """Custom admin action to close selected lobbies. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + updated = queryset.filter(status__in=['waiting', 'playing']).update(status='closed') + self.message_user( + request, + f"Closed {updated} lobby(ies). Active games may continue." + ) + + close_lobbies.short_description = "Close selected lobbies" + + def reset_lobby_status(self, request, queryset): + """Custom admin action to reset lobby status to waiting. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can reset lobby status.", level='ERROR') + return + + updated = queryset.update(status='waiting') + self.message_user(request, f"Reset {updated} lobby(ies) to waiting status.") + + reset_lobby_status.short_description = "Reset to waiting status (Superuser only)" + + def export_lobby_data(self, request, queryset): + """Custom admin action to export lobby data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobbies. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} lobby(ies). Download link will be provided when ready." + ) + + export_lobby_data.short_description = "Export lobby data" + + +class LobbyPlayerInline(admin.TabularInline): + """Inline admin interface for LobbyPlayer model within Lobby admin. + + Provides a compact view of players within a lobby directly from + the lobby admin page, allowing quick player management. + """ + model = LobbyPlayer + extra = 0 + readonly_fields = ('id', 'status') + fields = ('user', 'status', 'id') + + +@admin.register(LobbySettings) +class LobbySettingsAdmin(admin.ModelAdmin): + """Admin interface for the LobbySettings model. + + Provides management capabilities for lobby game configuration settings + with validation and rule compatibility checking. + + Features: + - Configuration overview with rule compatibility + - Settings validation and beginner-friendly indicators + - Direct lobby access links + - Custom validation for settings combinations + - Readonly fields for computed properties + + Attributes: + list_display: Fields shown in the settings list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'settings_summary', + 'lobby_link', + 'configuration_display', + 'compatibility_display', + 'beginner_friendly_display' + ) + + list_display_links = ('settings_summary',) + + list_filter = ( + 'max_players', + 'card_count', + 'is_transferable', + 'neighbor_throw_only', + 'allow_jokers', + ('special_rule_set', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'lobby__name', + 'lobby__owner__username', + ) + + readonly_fields = ( + 'id', + 'beginner_friendly_display', + 'has_time_limit', + 'compatibility_display' + ) + + fieldsets = ( + ('Basic Settings', { + 'fields': ('id', 'lobby', 'max_players', 'card_count'), + 'description': 'Core game configuration parameters.' + }), + ('Game Rules', { + 'fields': ('is_transferable', 'neighbor_throw_only', 'allow_jokers'), + 'description': 'Gameplay rule modifications.' + }), + ('Advanced Configuration', { + 'fields': ('turn_time_limit', 'special_rule_set'), + 'description': 'Advanced settings and special rules.', + 'classes': ('collapse',) + }), + ('Compatibility Analysis', { + 'fields': ('beginner_friendly_display', 'compatibility_display'), + 'description': 'Automated analysis of settings compatibility.', + 'classes': ('collapse',) + }), + ) + + def settings_summary(self, obj): + """Display a summary of key settings. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: Formatted summary of main settings. + """ + return f"{obj.lobby.name} ({obj.card_count} cards, {obj.max_players} players)" + + settings_summary.short_description = "Settings" + settings_summary.admin_order_field = 'lobby__name' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + + def configuration_display(self, obj): + """Display configuration details with visual indicators. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted configuration summary. + """ + features = [] + + if obj.is_transferable: + features.append('🔄 Transferable') + if obj.neighbor_throw_only: + features.append('👥 Neighbor Only') + if obj.allow_jokers: + features.append('🃏 Jokers') + if obj.has_time_limit(): + features.append(f'⏱️ {obj.turn_time_limit}s') + + if not features: + return format_html('📋 Standard Rules') + + return format_html('
'.join(features)) + + configuration_display.short_description = "Configuration" + + def compatibility_display(self, obj): + """Display rule set compatibility information. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: HTML formatted compatibility status. + """ + if obj.special_rule_set: + if obj.special_rule_set.is_compatible_with_player_count(obj.max_players): + return format_html( + '✅ Compatible
' + '{}', + obj.special_rule_set.name + ) + else: + return format_html( + '❌ Incompatible
' + 'Requires {}+ players', + obj.special_rule_set.min_players + ) + else: + return format_html('📋 No Special Rules') + + compatibility_display.short_description = "Rule Compatibility" + + def beginner_friendly_display(self, obj): + """Display whether settings are beginner-friendly. + + Args: + obj (LobbySettings): The lobby settings instance. + + Returns: + str: Visual indicator for beginner-friendliness. + """ + if obj.is_beginner_friendly(): + return format_html('✅ Beginner Friendly') + else: + return format_html('⚠️ Advanced') + + beginner_friendly_display.short_description = "Difficulty" + + +# Добавим LobbyPlayerInline к LobbyAdmin +LobbyAdmin.inlines = [LobbyPlayerInline] + + +@admin.register(LobbyPlayer) +class LobbyPlayerAdmin(admin.ModelAdmin): + """Admin interface for the LobbyPlayer model. + + Provides management capabilities for player-lobby relationships + with status tracking and lobby navigation. + + Features: + - Player status management with visual indicators + - Direct links to user and lobby admin pages + - Status-based filtering and searching + - Bulk status update actions + - Activity tracking and management + + Attributes: + list_display: Fields shown in the lobby player list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the player list + actions: Custom bulk actions available + """ + + list_display = ( + 'player_info_display', + 'lobby_link', + 'status_display', + 'activity_display' + ) + + list_display_links = ('player_info_display',) + + list_filter = ( + 'status', + ('lobby', admin.RelatedOnlyFieldListFilter), + ('user', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'user__username', + 'user__email', + 'lobby__name', + ) + + readonly_fields = ('id',) + ordering = ('lobby__name', 'user__username') + + actions = ['mark_as_ready', 'mark_as_waiting', 'remove_from_lobby'] + + def player_info_display(self, obj): + """Display player information with user link. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted player info with admin link. + """ + user_url = reverse('admin:accounts_user_change', args=[obj.user.pk]) + return format_html('👤 {}', user_url, obj.user.username) + + player_info_display.short_description = "Player" + player_info_display.admin_order_field = 'user__username' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + lobby_link.admin_order_field = 'lobby__name' + + def status_display(self, obj): + """Display player status with visual indicators. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: HTML formatted status with color coding. + """ + status_colors = { + 'waiting': '#ffc107', + 'ready': '#28a745', + 'playing': '#007bff', + 'left': '#6c757d' + } + status_icons = { + 'waiting': '⏳', + 'ready': '✅', + 'playing': '🎮', + 'left': '👋' + } + + color = status_colors.get(obj.status, '#6c757d') + icon = status_icons.get(obj.status, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_status_display() + ) + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def activity_display(self, obj): + """Display player activity status. + + Args: + obj (LobbyPlayer): The lobby player instance. + + Returns: + str: Visual indicator for player activity. + """ + if obj.is_active(): + return format_html('🟢 Active') + else: + return format_html('⚪ Inactive') + + activity_display.short_description = "Activity" + + def mark_as_ready(self, request, queryset): + """Custom admin action to mark players as ready. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + updated = queryset.filter(status__in=['waiting']).update(status='ready') + self.message_user(request, f"Marked {updated} player(s) as ready.") + + mark_as_ready.short_description = "Mark selected players as ready" + + def mark_as_waiting(self, request, queryset): + """Custom admin action to mark players as waiting. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + updated = queryset.filter(status__in=['ready']).update(status='waiting') + self.message_user(request, f"Marked {updated} player(s) as waiting.") + + mark_as_waiting.short_description = "Mark selected players as waiting" + + def remove_from_lobby(self, request, queryset): + """Custom admin action to remove players from lobbies. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected lobby players. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can remove players.", level='ERROR') + return + + updated = queryset.update(status='left') + self.message_user(request, f"Removed {updated} player(s) from their lobbies.") + + remove_from_lobby.short_description = "Remove from lobby (Superuser only)" + + +@admin.register(Game) +class GameAdmin(admin.ModelAdmin): + """Admin interface for the Game model. + + Provides comprehensive management capabilities for game sessions + with detailed tracking of game state, players, and statistics. + + Features: + - Game status tracking with visual indicators + - Player management and winner/loser identification + - Game duration and statistics display + - Trump card information with suit indicators + - Advanced filtering by lobby, status, and duration + - Custom actions for game management + + Attributes: + list_display: Fields shown in the game list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the game list + fieldsets: Organization of fields in the detail view + actions: Custom bulk actions available + """ + + list_display = ( + 'game_info_display', + 'lobby_link', + 'status_display', + 'trump_card_display', + 'player_count_display', + 'duration_display', + 'winner_display' + ) + + list_display_links = ('game_info_display',) + + list_filter = ( + 'status', + 'started_at', + 'finished_at', + ('lobby', admin.RelatedOnlyFieldListFilter), + ('loser', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'lobby__name', + 'lobby__owner__username', + 'loser__username', + ) + + readonly_fields = ( + 'id', + 'started_at', + 'finished_at', + 'player_count_display', + 'duration_display', + 'winner_display', + 'game_statistics' + ) + + date_hierarchy = 'started_at' + ordering = ('-started_at',) + + fieldsets = ( + ('Game Information', { + 'fields': ('id', 'lobby', 'trump_card'), + 'description': 'Basic game identification and trump suit.' + }), + ('Game Status', { + 'fields': ('status', 'player_count_display', 'winner_display'), + 'description': 'Current game state and outcome information.' + }), + ('Timing Information', { + 'fields': ('started_at', 'finished_at', 'duration_display'), + 'description': 'Game duration and timing details.', + 'classes': ('collapse',) + }), + ('Game Results', { + 'fields': ('loser',), + 'description': 'Game outcome and losing player identification.' + }), + ('Statistics', { + 'fields': ('game_statistics',), + 'description': 'Detailed game statistics and analytics.', + 'classes': ('collapse',) + }), + ) + + actions = ['finish_games', 'export_game_data'] + + def game_info_display(self, obj): + """Display game information with ID. + + Args: + obj (Game): The game instance. + + Returns: + str: Formatted game info with truncated ID. + """ + short_id = str(obj.id)[:8] + return f"Game {short_id}..." + + game_info_display.short_description = "Game" + game_info_display.admin_order_field = 'started_at' + + def lobby_link(self, obj): + """Display link to the associated lobby. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted link to lobby admin page. + """ + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html('🎯 {}', lobby_url, obj.lobby.name) + + lobby_link.short_description = "Lobby" + lobby_link.admin_order_field = 'lobby__name' + + def status_display(self, obj): + """Display game status with visual indicators. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted status with color coding. + """ + if obj.status == 'in_progress': + return format_html('🎮 In Progress') + elif obj.status == 'finished': + return format_html('🏁 Finished') + else: + return format_html('❓ Unknown') + + status_display.short_description = "Status" + status_display.admin_order_field = 'status' + + def trump_card_display(self, obj): + """Display trump card information with suit indicator. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted trump card with suit emoji. + """ + suit_emojis = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + + suit_colors = { + 'Hearts': '#dc3545', + 'Diamonds': '#dc3545', + 'Clubs': '#212529', + 'Spades': '#212529' + } + + emoji = suit_emojis.get(obj.trump_card.suit.name, '🃏') + color = suit_colors.get(obj.trump_card.suit.name, '#6c757d') + + return format_html( + '{} {}
' + 'Trump: {} of {}', + color, emoji, obj.trump_card.suit.name, + obj.trump_card.rank.name, obj.trump_card.suit.name + ) + + trump_card_display.short_description = "Trump" + + def player_count_display(self, obj): + """Display number of players in the game. + + Args: + obj (Game): The game instance. + + Returns: + str: Player count with visual indicator. + """ + count = obj.get_player_count() + return format_html('👥 {} players', count) + + player_count_display.short_description = "Players" + + def duration_display(self, obj): + """Display game duration information. + + Args: + obj (Game): The game instance. + + Returns: + str: Formatted duration or current runtime. + """ + if obj.finished_at: + duration = obj.finished_at - obj.started_at + total_seconds = int(duration.total_seconds()) + hours, remainder = divmod(total_seconds, 3600) + minutes, seconds = divmod(remainder, 60) + + if hours > 0: + return format_html('⏱️ {}h {}m {}s', hours, minutes, seconds) + elif minutes > 0: + return format_html('⏱️ {}m {}s', minutes, seconds) + else: + return format_html('⏱️ {}s', seconds) + else: + now = timezone.now() + runtime = now - obj.started_at + minutes = int(runtime.total_seconds() / 60) + return format_html('⏳ {}m (ongoing)', minutes) + + duration_display.short_description = "Duration" + + def winner_display(self, obj): + """Display game winner information. + + Args: + obj (Game): The game instance. + + Returns: + str: Winner information or game status. + """ + if obj.status == 'finished' and obj.loser: + winners = obj.get_winner() + if winners and winners.count() == 1: + winner = winners.first() + return format_html('🏆 {}', winner.user.username) + elif winners and winners.count() > 1: + return format_html('🏆 {} winners', winners.count()) + else: + return format_html('❓ Unknown') + elif obj.status == 'in_progress': + return format_html('🎮 In Progress') + else: + return format_html('⏳ Pending') + + winner_display.short_description = "Winner" + + def game_statistics(self, obj): + """Display comprehensive game statistics. + + Args: + obj (Game): The game instance. + + Returns: + str: HTML formatted statistics summary. + """ + try: + turns_count = obj.turns.count() + moves_count = Move.objects.filter(turn__game=obj).count() + cards_on_table = obj.tablecard_set.count() + cards_discarded = obj.discardpile_set.count() + + return format_html( + '
' + 'Game Statistics:
' + 'Total Turns: {}
' + 'Total Moves: {}
' + 'Cards on Table: {}
' + 'Cards Discarded: {}
' + '
', + turns_count, moves_count, cards_on_table, cards_discarded + ) + except: + return format_html('Statistics unavailable') + + game_statistics.short_description = "Statistics" + + def get_queryset(self, request): + """Optimize queryset for the admin interface. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related( + 'lobby', + 'lobby__owner', + 'trump_card', + 'trump_card__suit', + 'trump_card__rank', + 'loser' + ).prefetch_related( + 'players', + 'turns', + 'tablecard_set' + ) + + def finish_games(self, request, queryset): + """Custom admin action to finish selected games. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected games. + """ + if not request.user.is_superuser: + self.message_user(request, "Only superusers can finish games.", level='ERROR') + return + + updated = queryset.filter(status='in_progress').update( + status='finished', + finished_at=timezone.now() + ) + self.message_user(request, f"Finished {updated} game(s).") + + finish_games.short_description = "Finish selected games (Superuser only)" + + def export_game_data(self, request, queryset): + """Custom admin action to export game data. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected games. + """ + count = queryset.count() + self.message_user( + request, + f"Export initiated for {count} game(s). Download link will be provided when ready." + ) + + export_game_data.short_description = "Export game data" + + +@admin.register(GamePlayer) +class GamePlayerAdmin(admin.ModelAdmin): + """Admin interface for the GamePlayer model. + + Provides management capabilities for game player relationships + with card tracking and position management. + + Features: + - Player position and card count tracking + - Direct links to game and user admin pages + - Elimination status monitoring + - Seat position management + - Card count validation + + Attributes: + list_display: Fields shown in the game player list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by seat position + """ + + list_display = ( + 'player_info_display', + 'game_link', + 'seat_position', + 'cards_display', + 'status_display' + ) + + list_display_links = ('player_info_display',) + + list_filter = ( + 'seat_position', + 'cards_remaining', + ('game', admin.RelatedOnlyFieldListFilter), + ('user', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'user__username', + 'game__lobby__name', + ) + + readonly_fields = ('id',) + ordering = ('game', 'seat_position') + + def player_info_display(self, obj): + """Display player information with user link. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted player info with admin link. + """ + user_url = reverse('admin:accounts_user_change', args=[obj.user.pk]) + return format_html('👤 {}', user_url, obj.user.username) + + player_info_display.short_description = "Player" + player_info_display.admin_order_field = 'user__username' + + def game_link(self, obj): + """Display link to the associated game. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted link to game admin page. + """ + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('🎮 Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def cards_display(self, obj): + """Display card count with visual indicator. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: HTML formatted card count. + """ + if obj.cards_remaining == 0: + return format_html('🃏 0 cards (OUT)') + elif obj.cards_remaining <= 3: + return format_html('🃏 {} cards (LOW)', + obj.cards_remaining) + else: + return format_html('🃏 {} cards', obj.cards_remaining) + + cards_display.short_description = "Cards" + cards_display.admin_order_field = 'cards_remaining' + + def status_display(self, obj): + """Display player game status. + + Args: + obj (GamePlayer): The game player instance. + + Returns: + str: Visual indicator for player status. + """ + if obj.is_eliminated(): + return format_html('✅ Eliminated') + else: + return format_html('🎮 Playing') + + status_display.short_description = "Status" + + +@admin.register(Card) +class CardAdmin(admin.ModelAdmin): + """Admin interface for the Card model. + + Provides management capabilities for playing cards with suit and rank + organization, special card identification, and game usage tracking. + + Features: + - Visual card representation with suit colors + - Special card effect indicators + - Suit and rank filtering + - Card usage statistics + - Trump card identification + - Search by card properties + + Attributes: + list_display: Fields shown in the card list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by suit and rank + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'card_display', + 'suit_display', + 'rank_display', + 'special_display', + 'usage_stats' + ) + + list_display_links = ('card_display',) + + list_filter = ( + ('suit', admin.RelatedOnlyFieldListFilter), + ('rank', admin.RelatedOnlyFieldListFilter), + ('special_card', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'suit__name', + 'rank__name', + 'special_card__name', + ) + + readonly_fields = ('id', 'usage_stats', 'is_special') + ordering = ('suit__name', 'rank__value') + + fieldsets = ( + ('Card Properties', { + 'fields': ('id', 'suit', 'rank'), + 'description': 'Basic card identification.' + }), + ('Special Effects', { + 'fields': ('special_card', 'is_special'), + 'description': 'Special card abilities and effects.', + 'classes': ('collapse',) + }), + ('Usage Statistics', { + 'fields': ('usage_stats',), + 'description': 'Card usage in games and trump selection.', + 'classes': ('collapse',) + }), + ) + + def card_display(self, obj): + """Display card with appropriate styling. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted card representation. + """ + suit_emojis = { + 'Hearts': '♥️', + 'Diamonds': '♦️', + 'Clubs': '♣️', + 'Spades': '♠️' + } + + emoji = suit_emojis.get(obj.suit.name, '🃏') + + if obj.is_special(): + return format_html( + '' + '{} {} of {}', + emoji, obj.rank.name, obj.suit.name + ) + else: + return format_html('{} {} of {}', emoji, obj.rank.name, obj.suit.name) + + card_display.short_description = "Card" + card_display.admin_order_field = 'rank__value' + + def suit_display(self, obj): + """Display suit with color styling. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted suit display. + """ + if obj.suit.is_red(): + return format_html('{}', obj.suit.name) + else: + return format_html('{}', obj.suit.name) + + suit_display.short_description = "Suit" + suit_display.admin_order_field = 'suit__name' + + def rank_display(self, obj): + """Display rank with value information. + + Args: + obj (Card): The card instance. + + Returns: + str: Formatted rank with value. + """ + if obj.rank.is_face_card(): + return format_html( + '{} ({})', + obj.rank.name, obj.rank.value + ) + else: + return f"{obj.rank.name} ({obj.rank.value})" + + rank_display.short_description = "Rank" + rank_display.admin_order_field = 'rank__value' + + def special_display(self, obj): + """Display special card information. + + Args: + obj (Card): The card instance. + + Returns: + str: Special card indicator or standard card label. + """ + if obj.is_special(): + return format_html( + '⭐ {}', + obj.special_card.name + ) + else: + return format_html('📋 Standard') + + special_display.short_description = "Special" + + def usage_stats(self, obj): + """Display card usage statistics. + + Args: + obj (Card): The card instance. + + Returns: + str: HTML formatted usage statistics. + """ + try: + trump_count = obj.as_trump.count() + attack_count = obj.attack_card.count() + defense_count = obj.defense_card.count() + + return format_html( + '
' + 'Usage:
' + 'Trump: {} times
' + 'Attack: {} times
' + 'Defense: {} times
' + '
', + trump_count, attack_count, defense_count + ) + except: + return format_html('Stats unavailable') + + usage_stats.short_description = "Usage" + + +@admin.register(SpecialCard) +class SpecialCardAdmin(admin.ModelAdmin): + """Admin interface for the SpecialCard model. + + Provides management capabilities for special card effects with + detailed effect configuration and rule set associations. + + Features: + - Effect type categorization and visualization + - JSON effect value display and editing + - Rule set compatibility tracking + - Effect description formatting + - Targetability and counter information + + Attributes: + list_display: Fields shown in the special card list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by name + fieldsets: Organization of fields in the detail view + """ + + list_display = ( + 'name', + 'effect_type_display', + 'effect_summary', + 'targetable_display', + 'counterable_display' + ) + + list_display_links = ('name',) + + list_filter = ( + 'effect_type', + ) + + search_fields = ( + 'name', + 'description', + ) + + readonly_fields = ( + 'id', + 'targetable_display', + 'counterable_display', + 'effect_summary' + ) + + ordering = ('name',) + + fieldsets = ( + ('Special Card Information', { + 'fields': ('id', 'name', 'description'), + 'description': 'Basic special card identification and description.' + }), + ('Effect Configuration', { + 'fields': ('effect_type', 'effect_value', 'effect_summary'), + 'description': 'Special effect type and parameters.' + }), + ('Effect Properties', { + 'fields': ('targetable_display', 'counterable_display'), + 'description': 'Effect behavior and interaction properties.', + 'classes': ('collapse',) + }), + ) + + def effect_type_display(self, obj): + """Display effect type with visual indicator. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: HTML formatted effect type. + """ + type_colors = { + 'skip': '#ffc107', + 'reverse': '#6f42c1', + 'draw': '#dc3545', + 'custom': '#17a2b8' + } + + type_icons = { + 'skip': '⏭️', + 'reverse': '🔄', + 'draw': '📥', + 'custom': '⚙️' + } + + color = type_colors.get(obj.effect_type, '#6c757d') + icon = type_icons.get(obj.effect_type, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_effect_type_display() + ) + + effect_type_display.short_description = "Effect Type" + effect_type_display.admin_order_field = 'effect_type' + + def effect_summary(self, obj): + """Display effect summary with parameters. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: HTML formatted effect summary. + """ + if obj.effect_value: + try: + params = json.dumps(obj.effect_value, indent=2) if obj.effect_value else '{}' + return format_html( + '
' + '{}

' + 'Parameters:
' + '
{}
' + '
', + obj.get_effect_description() or obj.description or 'No description', + params + ) + except: + return format_html('Invalid effect data') + else: + return obj.get_effect_description() or obj.description or 'No description' + + effect_summary.short_description = "Effect Summary" + + def targetable_display(self, obj): + """Display whether effect is targetable. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: Visual indicator for targetability. + """ + if obj.is_targetable(): + return format_html('🎯 Targetable') + else: + return format_html('🔄 Self-Affecting') + + targetable_display.short_description = "Targeting" + + def counterable_display(self, obj): + """Display whether effect can be countered. + + Args: + obj (SpecialCard): The special card instance. + + Returns: + str: Visual indicator for counterability. + """ + if obj.can_be_countered(): + return format_html('🛡️ Counterable') + else: + return format_html('⚡ Absolute') + + counterable_display.short_description = "Countering" + + +@admin.register(SpecialRuleSet) +class SpecialRuleSetAdmin(admin.ModelAdmin): + """Admin interface for the SpecialRuleSet model. + + Provides management capabilities for special rule configurations + with compatibility checking and card association management. + + Features: + - Rule set compatibility analysis + - Special card count tracking + - Player requirement validation + - Lobby compatibility checking + - Inline card management + + Attributes: + list_display: Fields shown in the rule set list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by name + fieldsets: Organization of fields in the detail view + inlines: Inline editing of related objects + """ + + list_display = ( + 'name', + 'min_players', + 'card_count_display', + 'compatibility_summary' + ) + + list_display_links = ('name',) + + list_filter = ( + 'min_players', + ) + + search_fields = ( + 'name', + 'description', + ) + + readonly_fields = ( + 'id', + 'card_count_display', + 'compatibility_summary' + ) + + ordering = ('name',) + + fieldsets = ( + ('Rule Set Information', { + 'fields': ('id', 'name', 'description'), + 'description': 'Basic rule set identification and description.' + }), + ('Configuration', { + 'fields': ('min_players',), + 'description': 'Rule set requirements and limitations.' + }), + ('Statistics', { + 'fields': ('card_count_display', 'compatibility_summary'), + 'description': 'Rule set statistics and compatibility analysis.', + 'classes': ('collapse',) + }), + ) + + def card_count_display(self, obj): + """Display count of associated special cards. + + Args: + obj (SpecialRuleSet): The rule set instance. + + Returns: + str: HTML formatted card count. + """ + total_cards = obj.get_special_card_count() + enabled_cards = obj.get_enabled_special_cards().count() + + return format_html( + '🃏 {} cards
' + '({} enabled)', + total_cards, enabled_cards + ) + + card_count_display.short_description = "Special Cards" + + def compatibility_summary(self, obj): + """Display compatibility summary information. + + Args: + obj (SpecialRuleSet): The rule set instance. + + Returns: + str: HTML formatted compatibility information. + """ + try: + # Get lobbies using this rule set + using_lobbies = LobbySettings.objects.filter(special_rule_set=obj).count() + + return format_html( + '
' + 'Compatibility:
' + 'Min Players: {}
' + 'Used by {} lobbies
' + 'Special Cards: {} total, {} enabled
' + '
', + obj.min_players, using_lobbies, + obj.get_special_card_count(), + obj.get_enabled_special_cards().count() + ) + except: + return format_html('Compatibility data unavailable') + + compatibility_summary.short_description = "Compatibility" + + +class SpecialRuleSetCardInline(admin.TabularInline): + """Inline admin interface for SpecialRuleSetCard model within SpecialRuleSet admin. + + Provides a compact view of special cards within a rule set directly from + the rule set admin page, allowing quick card management and status toggling. + """ + model = SpecialRuleSetCard + extra = 0 + readonly_fields = ('id',) + fields = ('card', 'is_enabled', 'id') + + +# Добавим inline к SpecialRuleSetAdmin +SpecialRuleSetAdmin.inlines = [SpecialRuleSetCardInline] + + +@admin.register(SpecialRuleSetCard) +class SpecialRuleSetCardAdmin(admin.ModelAdmin): + """Admin interface for the SpecialRuleSetCard model. + + Provides management capabilities for special card associations within + rule sets with enable/disable functionality and game compatibility. + + Features: + - Rule set and card association management + - Enable/disable status tracking + - Game compatibility checking + - Bulk enable/disable actions + - Association filtering and search + + Attributes: + list_display: Fields shown in the association list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering by rule set and card + actions: Custom bulk actions available + """ + + list_display = ( + 'association_display', + 'rule_set_link', + 'card_link', + 'status_display', + 'compatibility_display' + ) + + list_display_links = ('association_display',) + + list_filter = ( + 'is_enabled', + ('rule_set', admin.RelatedOnlyFieldListFilter), + ('card', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'rule_set__name', + 'card__name', + ) + + readonly_fields = ('id', 'compatibility_display') + ordering = ('rule_set__name', 'card__name') + + actions = ['enable_cards', 'disable_cards', 'toggle_status'] + + def association_display(self, obj): + """Display association summary. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: Formatted association summary. + """ + status = "✅" if obj.is_enabled else "❌" + return f"{status} {obj.card.name} in {obj.rule_set.name}" + + association_display.short_description = "Association" + + def rule_set_link(self, obj): + """Display link to the rule set. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted link to rule set admin page. + """ + rule_set_url = reverse('admin:game_specialruleset_change', args=[obj.rule_set.pk]) + return format_html('📋 {}', rule_set_url, obj.rule_set.name) + + rule_set_link.short_description = "Rule Set" + + def card_link(self, obj): + """Display link to the special card. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted link to special card admin page. + """ + card_url = reverse('admin:game_specialcard_change', args=[obj.card.pk]) + return format_html('⭐ {}', card_url, obj.card.name) + + card_link.short_description = "Special Card" + + def status_display(self, obj): + """Display enable/disable status. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: HTML formatted status indicator. + """ + if obj.is_enabled: + return format_html('✅ Enabled') + else: + return format_html('❌ Disabled') + + status_display.short_description = "Status" + status_display.admin_order_field = 'is_enabled' + + def compatibility_display(self, obj): + """Display game compatibility information. + + Args: + obj (SpecialRuleSetCard): The association instance. + + Returns: + str: Compatibility status indicator. + """ + if obj.is_enabled: + return format_html('🎮 Available in Games') + else: + return format_html('🚫 Not Available') + + compatibility_display.short_description = "Game Compatibility" + + def enable_cards(self, request, queryset): + """Custom admin action to enable selected card associations. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + updated = queryset.update(is_enabled=True) + self.message_user(request, f"Enabled {updated} special card(s) in rule set(s).") + + enable_cards.short_description = "Enable selected special cards" + + def disable_cards(self, request, queryset): + """Custom admin action to disable selected card associations. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + updated = queryset.update(is_enabled=False) + self.message_user(request, f"Disabled {updated} special card(s) in rule set(s).") + + disable_cards.short_description = "Disable selected special cards" + + def toggle_status(self, request, queryset): + """Custom admin action to toggle enable/disable status. + + Args: + request: The HTTP request object. + queryset: QuerySet of selected associations. + """ + for obj in queryset: + obj.toggle_enabled() + + count = queryset.count() + self.message_user(request, f"Toggled status for {count} special card association(s).") + + toggle_status.short_description = "Toggle enable/disable status" + + +# Регистрируем остальные модели с базовой конфигурацией +@admin.register(GameDeck) +class GameDeckAdmin(admin.ModelAdmin): + """Admin interface for the GameDeck model. + + Basic management interface for game deck cards with position tracking. + """ + list_display = ('card', 'game_link', 'position', 'is_last_card') + list_filter = (('game', admin.RelatedOnlyFieldListFilter),) + search_fields = ('card__rank__name', 'card__suit__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'position') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(PlayerHand) +class PlayerHandAdmin(admin.ModelAdmin): + """Admin interface for the PlayerHand model. + + Basic management interface for player hand cards with ordering. + """ + list_display = ('player', 'card', 'game_link', 'order_in_hand') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + ('player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('player__username', 'card__rank__name', 'card__suit__name') + readonly_fields = ('id',) + ordering = ('game', 'player', 'order_in_hand') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(TableCard) +class TableCardAdmin(admin.ModelAdmin): + """Admin interface for the TableCard model. + + Management interface for attack-defense card pairs on the game table. + """ + list_display = ('attack_card', 'defense_card', 'game_link', 'defended_status') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + 'defense_card', + ) + search_fields = ('attack_card__rank__name', 'defense_card__rank__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'id') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def defended_status(self, obj): + if obj.is_defended(): + return format_html('🛡️ Defended') + else: + return format_html('⚔️ Undefended') + + defended_status.short_description = "Status" + + +@admin.register(DiscardPile) +class DiscardPileAdmin(admin.ModelAdmin): + """Admin interface for the DiscardPile model. + + Basic management interface for discarded cards. + """ + list_display = ('card', 'game_link', 'position') + list_filter = (('game', admin.RelatedOnlyFieldListFilter),) + search_fields = ('card__rank__name', 'card__suit__name', 'game__lobby__name') + readonly_fields = ('id',) + ordering = ('game', 'position') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + +@admin.register(Turn) +class TurnAdmin(admin.ModelAdmin): + """Admin interface for the Turn model. + + Management interface for game turns with move tracking. + """ + list_display = ('turn_number', 'player', 'game_link', 'move_count', 'completion_status') + list_filter = ( + ('game', admin.RelatedOnlyFieldListFilter), + ('player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('player__username', 'game__lobby__name') + readonly_fields = ('id', 'move_count') + ordering = ('game', 'turn_number') + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.game.pk]) + short_id = str(obj.game.id)[:8] + return format_html('Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def move_count(self, obj): + return obj.moves.count() + + move_count.short_description = "Moves" + + def completion_status(self, obj): + if obj.is_complete(): + return format_html('✅ Complete') + else: + return format_html('⏳ Pending') + + completion_status.short_description = "Status" + + +@admin.register(Move) +class MoveAdmin(admin.ModelAdmin): + """Admin interface for the Move model. + + Management interface for individual player moves with action tracking. + """ + list_display = ('action_display', 'player_link', 'game_link', 'turn_number', 'created_at') + list_filter = ( + 'action_type', + 'created_at', + ('turn__game', admin.RelatedOnlyFieldListFilter), + ('turn__player', admin.RelatedOnlyFieldListFilter), + ) + search_fields = ('turn__player__username', 'turn__game__lobby__name') + readonly_fields = ('id', 'created_at', 'player_link', 'game_link', 'turn_number') + ordering = ('-created_at',) + date_hierarchy = 'created_at' + + def action_display(self, obj): + action_colors = { + 'attack': '#dc3545', + 'defend': '#28a745', + 'pickup': '#ffc107' + } + action_icons = { + 'attack': '⚔️', + 'defend': '🛡️', + 'pickup': '📥' + } + + color = action_colors.get(obj.action_type, '#6c757d') + icon = action_icons.get(obj.action_type, '❓') + + return format_html( + '{} {}', + color, icon, obj.get_action_type_display() + ) + + action_display.short_description = "Action" + action_display.admin_order_field = 'action_type' + + def player_link(self, obj): + player_url = reverse('admin:accounts_user_change', args=[obj.turn.player.pk]) + return format_html('👤 {}', player_url, obj.turn.player.username) + + player_link.short_description = "Player" + + def game_link(self, obj): + game_url = reverse('admin:game_game_change', args=[obj.turn.game.pk]) + short_id = str(obj.turn.game.id)[:8] + return format_html('🎮 Game {}', game_url, short_id) + + game_link.short_description = "Game" + + def turn_number(self, obj): + return obj.turn.turn_number -# Register your models here. + turn_number.short_description = "Turn #" + turn_number.admin_order_field = 'turn__turn_number' \ No newline at end of file diff --git a/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py b/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py new file mode 100644 index 0000000..098c561 --- /dev/null +++ b/game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py @@ -0,0 +1,139 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.RemoveField( + model_name='turn', + name='game', + ), + migrations.RemoveField( + model_name='turn', + name='player', + ), + migrations.AlterModelOptions( + name='card', + options={'verbose_name': 'Card', 'verbose_name_plural': 'Cards'}, + ), + migrations.AlterModelOptions( + name='cardrank', + options={'ordering': ['value'], 'verbose_name': 'Card Rank', 'verbose_name_plural': 'Card Ranks'}, + ), + migrations.AlterModelOptions( + name='cardsuit', + options={'ordering': ['name'], 'verbose_name': 'Card Suit', 'verbose_name_plural': 'Card Suits'}, + ), + migrations.AlterModelOptions( + name='game', + options={'ordering': ['-started_at'], 'verbose_name': 'Game', 'verbose_name_plural': 'Games'}, + ), + migrations.AlterModelOptions( + name='gamedeck', + options={'ordering': ['position'], 'verbose_name': 'Game Deck Card', 'verbose_name_plural': 'Game Deck Cards'}, + ), + migrations.AlterModelOptions( + name='gameplayer', + options={'ordering': ['seat_position'], 'verbose_name': 'Game Player', 'verbose_name_plural': 'Game Players'}, + ), + migrations.AlterModelOptions( + name='lobby', + options={'ordering': ['-created_at'], 'verbose_name': 'Lobby', 'verbose_name_plural': 'Lobbies'}, + ), + migrations.AlterModelOptions( + name='lobbyplayer', + options={'ordering': ['lobby', 'user__username'], 'verbose_name': 'Lobby Player', 'verbose_name_plural': 'Lobby Players'}, + ), + migrations.AlterModelOptions( + name='lobbysettings', + options={'verbose_name': 'Lobby Settings', 'verbose_name_plural': 'Lobby Settings'}, + ), + migrations.AlterModelOptions( + name='playerhand', + options={'ordering': ['order_in_hand'], 'verbose_name': 'Player Hand Card', 'verbose_name_plural': 'Player Hand Cards'}, + ), + migrations.AlterModelOptions( + name='specialcard', + options={'ordering': ['name'], 'verbose_name': 'Special Card', 'verbose_name_plural': 'Special Cards'}, + ), + migrations.AlterModelOptions( + name='specialruleset', + options={'ordering': ['name'], 'verbose_name': 'Special Rule Set', 'verbose_name_plural': 'Special Rule Sets'}, + ), + migrations.AlterModelOptions( + name='specialrulesetcard', + options={'ordering': ['rule_set__name', 'card__name'], 'verbose_name': 'Special Rule Set Card', 'verbose_name_plural': 'Special Rule Set Cards'}, + ), + migrations.AlterModelOptions( + name='tablecard', + options={'ordering': ['id'], 'verbose_name': 'Table Card', 'verbose_name_plural': 'Table Cards'}, + ), + migrations.AddField( + model_name='specialrulesetcard', + name='is_enabled', + field=models.BooleanField(default=True), + ), + migrations.AlterField( + model_name='specialcard', + name='effect_type', + field=models.CharField(choices=[('skip', 'Skip Turn'), ('reverse', 'Reverse Order'), ('draw', 'Draw Cards'), ('custom', 'Custom Effect')], max_length=20), + ), + migrations.AlterField( + model_name='specialcard', + name='effect_value', + field=models.JSONField(blank=True, default=dict), + ), + migrations.AlterField( + model_name='specialruleset', + name='description', + field=models.TextField(), + ), + migrations.AlterField( + model_name='specialruleset', + name='min_players', + field=models.IntegerField(default=2), + ), + migrations.AlterField( + model_name='specialruleset', + name='name', + field=models.CharField(max_length=100), + ), + migrations.AlterUniqueTogether( + name='card', + unique_together={('suit', 'rank', 'special_card')}, + ), + migrations.AlterUniqueTogether( + name='gamedeck', + unique_together={('game', 'position')}, + ), + migrations.AlterUniqueTogether( + name='gameplayer', + unique_together={('game', 'user')}, + ), + migrations.AlterUniqueTogether( + name='lobbyplayer', + unique_together={('lobby', 'user')}, + ), + migrations.AlterUniqueTogether( + name='playerhand', + unique_together={('game', 'player', 'card')}, + ), + migrations.AlterUniqueTogether( + name='specialrulesetcard', + unique_together={('rule_set', 'card')}, + ), + migrations.DeleteModel( + name='Move', + ), + migrations.DeleteModel( + name='Turn', + ), + ] diff --git a/game/migrations/0003_turn_move.py b/game/migrations/0003_turn_move.py new file mode 100644 index 0000000..1636bbf --- /dev/null +++ b/game/migrations/0003_turn_move.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:37 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('game', '0002_remove_turn_game_remove_turn_player_and_more'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Turn', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('turn_number', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='turns', to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + options={ + 'verbose_name': 'Turn', + 'verbose_name_plural': 'Turns', + 'ordering': ['turn_number'], + 'unique_together': {('game', 'turn_number')}, + }, + ), + migrations.CreateModel( + name='Move', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action_type', models.CharField(choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('table_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.tablecard')), + ('turn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='moves', to='game.turn')), + ], + options={ + 'verbose_name': 'Move', + 'verbose_name_plural': 'Moves', + 'ordering': ['created_at'], + }, + ), + ] From 8019b064dc2a9c31791b035dad02df98058b6b33 Mon Sep 17 00:00:00 2001 From: uxabix Date: Sun, 19 Oct 2025 20:02:49 +0200 Subject: [PATCH 07/38] Tests for models in all applications --- accounts/tests.py | 535 ++++++++++++- chat/tests.py | 573 +++++++++++++- game/tests.py | 1858 ++++++++++++++++++++++++++++++++++++++++++++- 3 files changed, 2963 insertions(+), 3 deletions(-) diff --git a/accounts/tests.py b/accounts/tests.py index 7ce503c..d5372a3 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -1,3 +1,536 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from game.models import Lobby, LobbyPlayer, LobbySettings, Game, GamePlayer, Card, CardSuit, CardRank -# Create your tests here. +User = get_user_model() + + +class UserModelTest(TestCase): + """Test suite for User model.""" + + def setUp(self): + """Set up test data for User tests.""" + self.user = User.objects.create_user( + username="testplayer", + email="test@example.com", + password="testpass123" + ) + + self.user_with_avatar = User.objects.create_user( + username="avataruser", + email="avatar@example.com", + password="testpass123", + avatar_url="https://example.com/avatar.jpg" + ) + + def test_user_creation(self): + """Test that User instances are created correctly.""" + self.assertEqual(self.user.username, "testplayer") + self.assertEqual(self.user.email, "test@example.com") + self.assertTrue(self.user.check_password("testpass123")) + + def test_user_uuid_generation(self): + """Test that UUID is automatically generated for users.""" + self.assertIsNotNone(self.user.id) + # UUID should be a valid UUID4 + self.assertEqual(len(str(self.user.id)), 36) + + def test_user_str_representation(self): + """Test string representation of User.""" + self.assertEqual(str(self.user), "testplayer") + + def test_user_created_at_auto_generation(self): + """Test that created_at timestamp is automatically set.""" + self.assertIsNotNone(self.user.created_at) + + def test_get_full_display_name_with_full_name(self): + """Test get_full_display_name() returns full name when available.""" + self.user.first_name = "John" + self.user.last_name = "Doe" + self.user.save() + + self.assertEqual(self.user.get_full_display_name(), "John Doe") + + def test_get_full_display_name_without_full_name(self): + """Test get_full_display_name() falls back to username.""" + self.assertEqual(self.user.get_full_display_name(), "testplayer") + + def test_get_full_display_name_with_partial_name(self): + """Test get_full_display_name() with only first name.""" + self.user.first_name = "John" + self.user.save() + + self.assertEqual(self.user.get_full_display_name(), "John") + + def test_has_avatar_true(self): + """Test has_avatar() returns True when avatar is set.""" + self.assertTrue(self.user_with_avatar.has_avatar()) + + def test_has_avatar_false(self): + """Test has_avatar() returns False when avatar is not set.""" + self.assertFalse(self.user.has_avatar()) + + def test_has_avatar_empty_string(self): + """Test has_avatar() returns False for empty string.""" + self.user.avatar_url = "" + self.user.save() + + self.assertFalse(self.user.has_avatar()) + + def test_get_active_lobby_no_lobby(self): + """Test get_active_lobby() returns None when user is not in a lobby.""" + self.assertIsNone(self.user.get_active_lobby()) + + def test_get_active_lobby_waiting_status(self): + """Test get_active_lobby() returns lobby when user is waiting.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='waiting' + ) + + self.assertEqual(self.user.get_active_lobby(), lobby) + + def test_get_active_lobby_ready_status(self): + """Test get_active_lobby() returns lobby when user is ready.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='ready' + ) + + self.assertEqual(self.user.get_active_lobby(), lobby) + + def test_get_active_lobby_playing_status(self): + """Test get_active_lobby() returns lobby when user is playing.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='playing' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='playing' + ) + + self.assertEqual(self.user.get_active_lobby(), lobby) + + def test_get_active_lobby_left_status(self): + """Test get_active_lobby() returns None when user has left.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='left' + ) + + self.assertIsNone(self.user.get_active_lobby()) + + def test_get_current_game_no_game(self): + """Test get_current_game() returns None when user is not in a game.""" + self.assertIsNone(self.user.get_current_game()) + + def test_get_current_game_active_game(self): + """Test get_current_game() returns game when user is playing.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Game Lobby", + status='playing' + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + GamePlayer.objects.create( + game=game, + user=self.user, + seat_position=1, + cards_remaining=6 + ) + + self.assertEqual(self.user.get_current_game(), game) + + def test_get_current_game_finished_game(self): + """Test get_current_game() returns None for finished games.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Game Lobby", + status='playing' + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='finished' + ) + + GamePlayer.objects.create( + game=game, + user=self.user, + seat_position=1, + cards_remaining=0 + ) + + self.assertIsNone(self.user.get_current_game()) + + def test_can_join_lobby_success(self): + """Test can_join_lobby() returns True for valid join.""" + lobby = Lobby.objects.create( + owner=self.user_with_avatar, + name="Open Lobby", + status='waiting' + ) + + LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36 + ) + + self.assertTrue(self.user.can_join_lobby(lobby)) + + def test_can_join_lobby_already_in_lobby(self): + """Test can_join_lobby() returns False when user is already in a lobby.""" + lobby1 = Lobby.objects.create( + owner=self.user, + name="Current Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby1, + user=self.user, + status='waiting' + ) + + lobby2 = Lobby.objects.create( + owner=self.user_with_avatar, + name="Other Lobby", + status='waiting' + ) + + self.assertFalse(self.user.can_join_lobby(lobby2)) + + def test_can_join_lobby_full(self): + """Test can_join_lobby() returns False when lobby is full.""" + lobby = Lobby.objects.create( + owner=self.user_with_avatar, + name="Full Lobby", + status='waiting' + ) + + LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=36 + ) + + # Fill the lobby + user2 = User.objects.create_user(username="player2", password="test") + user3 = User.objects.create_user(username="player3", password="test") + + LobbyPlayer.objects.create(lobby=lobby, user=user2, status='waiting') + LobbyPlayer.objects.create(lobby=lobby, user=user3, status='waiting') + + self.assertFalse(self.user.can_join_lobby(lobby)) + + def test_can_join_lobby_closed(self): + """Test can_join_lobby() returns False for closed lobbies.""" + lobby = Lobby.objects.create( + owner=self.user_with_avatar, + name="Closed Lobby", + status='closed' + ) + + # Create settings for the lobby + LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36 + ) + + self.assertFalse(self.user.can_join_lobby(lobby)) + + def test_leave_current_lobby_success(self): + """Test leave_current_lobby() successfully removes user from lobby.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='waiting' + ) + + result = self.user.leave_current_lobby() + + self.assertTrue(result) + self.assertIsNone(self.user.get_active_lobby()) + + def test_leave_current_lobby_not_in_lobby(self): + """Test leave_current_lobby() returns False when not in a lobby.""" + result = self.user.leave_current_lobby() + + self.assertFalse(result) + + def test_leave_current_lobby_already_left(self): + """Test leave_current_lobby() returns False when already left.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + LobbyPlayer.objects.create( + lobby=lobby, + user=self.user, + status='left' + ) + + result = self.user.leave_current_lobby() + + self.assertFalse(result) + + def test_get_game_statistics_no_games(self): + """Test get_game_statistics() with no games played.""" + stats = self.user.get_game_statistics() + + self.assertEqual(stats['total_games'], 0) + self.assertEqual(stats['games_won'], 0) + self.assertEqual(stats['games_lost'], 0) + self.assertEqual(stats['win_rate'], 0.0) + + def test_get_game_statistics_with_wins_and_losses(self): + """Test get_game_statistics() with mixed results.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Stats Lobby", + status='playing' + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + # Create 3 finished games: 2 wins, 1 loss + for i in range(3): + game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='finished', + loser=self.user if i == 0 else self.user_with_avatar + ) + + GamePlayer.objects.create( + game=game, + user=self.user, + seat_position=1, + cards_remaining=0 + ) + + GamePlayer.objects.create( + game=game, + user=self.user_with_avatar, + seat_position=2, + cards_remaining=0 + ) + + stats = self.user.get_game_statistics() + + self.assertEqual(stats['total_games'], 3) + self.assertEqual(stats['games_won'], 2) + self.assertEqual(stats['games_lost'], 1) + self.assertEqual(stats['win_rate'], 66.7) + + def test_get_game_statistics_all_wins(self): + """Test get_game_statistics() with all wins.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Stats Lobby", + status='playing' + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + # Create 2 games where user always wins + for i in range(2): + game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='finished', + loser=self.user_with_avatar + ) + + GamePlayer.objects.create( + game=game, + user=self.user, + seat_position=1, + cards_remaining=0 + ) + + GamePlayer.objects.create( + game=game, + user=self.user_with_avatar, + seat_position=2, + cards_remaining=0 + ) + + stats = self.user.get_game_statistics() + + self.assertEqual(stats['total_games'], 2) + self.assertEqual(stats['games_won'], 2) + self.assertEqual(stats['games_lost'], 0) + self.assertEqual(stats['win_rate'], 100.0) + + def test_get_game_statistics_ignores_active_games(self): + """Test get_game_statistics() only counts finished games.""" + lobby = Lobby.objects.create( + owner=self.user, + name="Stats Lobby", + status='playing' + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + # Create an active game + active_game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + GamePlayer.objects.create( + game=active_game, + user=self.user, + seat_position=1, + cards_remaining=6 + ) + + # Create a finished game + finished_game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='finished', + loser=self.user_with_avatar + ) + + GamePlayer.objects.create( + game=finished_game, + user=self.user, + seat_position=1, + cards_remaining=0 + ) + + GamePlayer.objects.create( + game=finished_game, + user=self.user_with_avatar, + seat_position=2, + cards_remaining=0 + ) + + stats = self.user.get_game_statistics() + + # Should only count the finished game + self.assertEqual(stats['total_games'], 1) + self.assertEqual(stats['games_won'], 1) + + def test_user_ordering(self): + """Test that users are ordered by username.""" + user_z = User.objects.create_user(username="zzz", password="test") + user_a = User.objects.create_user(username="aaa", password="test") + + users = list(User.objects.all()) + + # First user should be alphabetically first + self.assertEqual(users[0].username, "aaa") + # Last user should be alphabetically last + self.assertEqual(users[-1].username, "zzz") + + def test_user_password_hashing(self): + """Test that passwords are properly hashed.""" + # Password should not be stored in plain text + self.assertNotEqual(self.user.password, "testpass123") + + # But check_password should work + self.assertTrue(self.user.check_password("testpass123")) + self.assertFalse(self.user.check_password("wrongpassword")) + + def test_user_authentication_fields(self): + """Test that inherited authentication fields work correctly.""" + self.assertTrue(self.user.is_active) + self.assertFalse(self.user.is_staff) + self.assertFalse(self.user.is_superuser) + + def test_create_superuser(self): + """Test creating a superuser.""" + admin = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="admin123" + ) + + self.assertTrue(admin.is_staff) + self.assertTrue(admin.is_superuser) + self.assertTrue(admin.is_active) + + def test_user_email_optional(self): + """Test that email is optional for users.""" + user_no_email = User.objects.create_user( + username="noemail", + password="test123" + ) + + self.assertEqual(user_no_email.email, "") + + def test_avatar_url_validation(self): + """Test that avatar_url accepts valid URLs.""" + self.user.avatar_url = "https://cdn.example.com/avatars/user123.png" + self.user.save() + + self.assertEqual(self.user.avatar_url, "https://cdn.example.com/avatars/user123.png") + + def test_user_related_objects_accessible(self): + """Test that related objects are accessible through reverse relations.""" + # These should not raise AttributeError + self.assertIsNotNone(self.user.sent_messages) + self.assertIsNotNone(self.user.received_messages) + self.assertIsNotNone(self.user.lobby_set) + self.assertIsNotNone(self.user.lobbyplayer_set) \ No newline at end of file diff --git a/chat/tests.py b/chat/tests.py index 7ce503c..2769c13 100644 --- a/chat/tests.py +++ b/chat/tests.py @@ -1,3 +1,574 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from chat.models import Message +from game.models import Lobby -# Create your tests here. +User = get_user_model() + + +class MessageModelTest(TestCase): + """Test suite for Message model.""" + + def setUp(self): + """Set up test data for Message tests.""" + self.user1 = User.objects.create_user( + username="sender", + password="test123" + ) + + self.user2 = User.objects.create_user( + username="receiver", + password="test123" + ) + + self.lobby = Lobby.objects.create( + owner=self.user1, + name="Test Lobby", + status='waiting' + ) + + def test_private_message_creation(self): + """Test that private Message instances are created correctly.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Hello, this is a private message!" + ) + + self.assertEqual(message.sender, self.user1) + self.assertEqual(message.receiver, self.user2) + self.assertIsNone(message.lobby) + self.assertEqual(message.content, "Hello, this is a private message!") + + def test_lobby_message_creation(self): + """Test that lobby Message instances are created correctly.""" + message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Hello everyone in the lobby!" + ) + + self.assertEqual(message.sender, self.user1) + self.assertEqual(message.lobby, self.lobby) + self.assertIsNone(message.receiver) + self.assertEqual(message.content, "Hello everyone in the lobby!") + + def test_message_uuid_generation(self): + """Test that UUID is automatically generated for messages.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + self.assertIsNotNone(message.id) + # UUID should be a valid UUID4 + self.assertEqual(len(str(message.id)), 36) + + def test_message_sent_at_auto_generation(self): + """Test that sent_at timestamp is automatically set.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + self.assertIsNotNone(message.sent_at) + + def test_message_str_representation_short(self): + """Test string representation of Message with short content.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Short message" + ) + + self.assertEqual(str(message), "sender: Short message") + + def test_message_str_representation_long(self): + """Test string representation of Message with long content.""" + long_content = "This is a very long message " * 10 + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content=long_content + ) + + str_repr = str(message) + self.assertIn("sender:", str_repr) + self.assertIn("...", str_repr) + self.assertEqual(len(str_repr.split(": ", 1)[1]), 53) # 50 chars + "..." + + def test_is_private_method_private_message(self): + """Test is_private() returns True for private messages.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Private" + ) + + self.assertTrue(message.is_private()) + + def test_is_private_method_lobby_message(self): + """Test is_private() returns False for lobby messages.""" + message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Public" + ) + + self.assertFalse(message.is_private()) + + def test_is_lobby_message_method_lobby_message(self): + """Test is_lobby_message() returns True for lobby messages.""" + message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Lobby message" + ) + + self.assertTrue(message.is_lobby_message()) + + def test_is_lobby_message_method_private_message(self): + """Test is_lobby_message() returns False for private messages.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Private" + ) + + self.assertFalse(message.is_lobby_message()) + + def test_get_chat_context_lobby_message(self): + """Test get_chat_context() for lobby messages.""" + message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Test" + ) + + context = message.get_chat_context() + + self.assertEqual(context['type'], 'lobby') + self.assertEqual(context['context'], self.lobby) + self.assertEqual(context['context_name'], 'Test Lobby') + + def test_get_chat_context_private_message(self): + """Test get_chat_context() for private messages.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + context = message.get_chat_context() + + self.assertEqual(context['type'], 'private') + self.assertEqual(context['context'], self.user2) + self.assertEqual(context['context_name'], 'Private chat with receiver') + + def test_get_chat_context_invalid_message(self): + """Test get_chat_context() for message without lobby or receiver. + + Note: This should not happen in practice due to validation, + but we test the fallback behavior. + """ + # Create message without validation + message = Message( + sender=self.user1, + content="Invalid" + ) + + context = message.get_chat_context() + + self.assertEqual(context['type'], 'unknown') + self.assertIsNone(context['context']) + self.assertEqual(context['context_name'], 'Unknown') + + def test_get_lobby_messages_method(self): + """Test get_lobby_messages() retrieves lobby messages.""" + # Create multiple messages + message1 = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="First message" + ) + + message2 = Message.objects.create( + sender=self.user2, + lobby=self.lobby, + content="Second message" + ) + + # Create a private message that should not be included + Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Private" + ) + + messages = list(Message.get_lobby_messages(self.lobby)) + + self.assertEqual(len(messages), 2) + # Should be ordered by sent_at descending (newest first) + self.assertEqual(messages[0], message2) + self.assertEqual(messages[1], message1) + + def test_get_lobby_messages_limit(self): + """Test get_lobby_messages() respects limit parameter.""" + # Create 10 messages + for i in range(10): + Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content=f"Message {i}" + ) + + messages = list(Message.get_lobby_messages(self.lobby, limit=5)) + + self.assertEqual(len(messages), 5) + + def test_get_lobby_messages_empty_lobby(self): + """Test get_lobby_messages() returns empty queryset for lobby with no messages.""" + empty_lobby = Lobby.objects.create( + owner=self.user2, + name="Empty Lobby", + status='waiting' + ) + + messages = list(Message.get_lobby_messages(empty_lobby)) + + self.assertEqual(len(messages), 0) + + def test_get_private_conversation_method(self): + """Test get_private_conversation() retrieves conversation between two users.""" + # Create messages in both directions + message1 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Hello from user1" + ) + + message2 = Message.objects.create( + sender=self.user2, + receiver=self.user1, + content="Reply from user2" + ) + + message3 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Another message from user1" + ) + + # Create a lobby message that should not be included + Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Lobby message" + ) + + # Create a message to another user that should not be included + user3 = User.objects.create_user(username="user3", password="test") + Message.objects.create( + sender=self.user1, + receiver=user3, + content="Message to user3" + ) + + messages = list(Message.get_private_conversation(self.user1, self.user2)) + + self.assertEqual(len(messages), 3) + + def test_get_private_conversation_order(self): + """Test get_private_conversation() returns messages in descending order.""" + message1 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="First" + ) + + message2 = Message.objects.create( + sender=self.user2, + receiver=self.user1, + content="Second" + ) + + messages = list(Message.get_private_conversation(self.user1, self.user2)) + + # Should be ordered by sent_at descending (newest first) + self.assertEqual(messages[0], message2) + self.assertEqual(messages[1], message1) + + def test_get_private_conversation_limit(self): + """Test get_private_conversation() respects limit parameter.""" + # Create 10 messages + for i in range(10): + Message.objects.create( + sender=self.user1, + receiver=self.user2, + content=f"Message {i}" + ) + + messages = list(Message.get_private_conversation(self.user1, self.user2, limit=5)) + + self.assertEqual(len(messages), 5) + + def test_get_private_conversation_no_messages(self): + """Test get_private_conversation() returns empty queryset when no conversation exists.""" + messages = list(Message.get_private_conversation(self.user1, self.user2)) + + self.assertEqual(len(messages), 0) + + def test_get_private_conversation_symmetry(self): + """Test get_private_conversation() works regardless of parameter order.""" + Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + messages1 = list(Message.get_private_conversation(self.user1, self.user2)) + messages2 = list(Message.get_private_conversation(self.user2, self.user1)) + + self.assertEqual(len(messages1), len(messages2)) + self.assertEqual(messages1, messages2) + + def test_clean_validation_both_lobby_and_receiver(self): + """Test clean() raises ValidationError when both lobby and receiver are set.""" + message = Message( + sender=self.user1, + receiver=self.user2, + lobby=self.lobby, + content="Invalid message" + ) + + with self.assertRaises(ValidationError) as context: + message.clean() + + self.assertIn("both lobby and receiver", str(context.exception)) + + def test_clean_validation_neither_lobby_nor_receiver(self): + """Test clean() raises ValidationError when neither lobby nor receiver is set.""" + message = Message( + sender=self.user1, + content="Invalid message" + ) + + with self.assertRaises(ValidationError) as context: + message.clean() + + self.assertIn("either lobby or receiver", str(context.exception)) + + def test_clean_validation_passes_with_receiver(self): + """Test clean() passes validation with receiver set.""" + message = Message( + sender=self.user1, + receiver=self.user2, + content="Valid private message" + ) + + # Should not raise ValidationError + message.clean() + + def test_clean_validation_passes_with_lobby(self): + """Test clean() passes validation with lobby set.""" + message = Message( + sender=self.user1, + lobby=self.lobby, + content="Valid lobby message" + ) + + # Should not raise ValidationError + message.clean() + + def test_save_calls_clean(self): + """Test that save() method calls clean() for validation.""" + message = Message( + sender=self.user1, + receiver=self.user2, + lobby=self.lobby, + content="Invalid" + ) + + with self.assertRaises(ValidationError): + message.save() + + def test_save_valid_message(self): + """Test that save() works for valid messages.""" + message = Message( + sender=self.user1, + receiver=self.user2, + content="Valid message" + ) + + # Should not raise any exception + message.save() + + self.assertIsNotNone(message.id) + + def test_message_ordering(self): + """Test that messages are ordered by sent_at descending.""" + message1 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="First" + ) + + message2 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Second" + ) + + message3 = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Third" + ) + + messages = list(Message.objects.all()) + + # Should be ordered newest first + self.assertEqual(messages[0], message3) + self.assertEqual(messages[1], message2) + self.assertEqual(messages[2], message1) + + def test_message_indexes_exist(self): + """Test that database indexes are properly configured. + + This test ensures the model has the expected index definitions. + Actual index creation is verified by migrations. + """ + # Check that indexes are defined in Meta + self.assertEqual(len(Message._meta.indexes), 3) + + def test_message_content_can_be_long(self): + """Test that message content can store long text.""" + long_content = "A" * 10000 # 10,000 characters + + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content=long_content + ) + + message.refresh_from_db() + self.assertEqual(len(message.content), 10000) + + def test_message_related_names(self): + """Test that related names work correctly.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + # Test sender's sent_messages + self.assertIn(message, self.user1.sent_messages.all()) + + # Test receiver's received_messages + self.assertIn(message, self.user2.received_messages.all()) + + # Test lobby's messages (for lobby message) + lobby_message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Lobby test" + ) + + self.assertIn(lobby_message, self.lobby.messages.all()) + + def test_message_cascade_delete_sender(self): + """Test that messages are deleted when sender is deleted.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + message_id = message.id + self.user1.delete() + + # Message should be deleted + self.assertFalse(Message.objects.filter(id=message_id).exists()) + + def test_message_cascade_delete_receiver(self): + """Test that private messages are deleted when receiver is deleted.""" + message = Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="Test" + ) + + message_id = message.id + self.user2.delete() + + # Message should be deleted + self.assertFalse(Message.objects.filter(id=message_id).exists()) + + def test_message_cascade_delete_lobby(self): + """Test that lobby messages are deleted when lobby is deleted.""" + message = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="Test" + ) + + message_id = message.id + self.lobby.delete() + + # Message should be deleted + self.assertFalse(Message.objects.filter(id=message_id).exists()) + + def test_multiple_users_can_message_same_lobby(self): + """Test that multiple users can send messages to the same lobby.""" + user3 = User.objects.create_user(username="user3", password="test") + + message1 = Message.objects.create( + sender=self.user1, + lobby=self.lobby, + content="From user1" + ) + + message2 = Message.objects.create( + sender=self.user2, + lobby=self.lobby, + content="From user2" + ) + + message3 = Message.objects.create( + sender=user3, + lobby=self.lobby, + content="From user3" + ) + + lobby_messages = list(Message.get_lobby_messages(self.lobby)) + self.assertEqual(len(lobby_messages), 3) + + def test_user_can_have_conversations_with_multiple_users(self): + """Test that a user can have separate conversations with multiple users.""" + user3 = User.objects.create_user(username="user3", password="test") + + # Conversation with user2 + Message.objects.create( + sender=self.user1, + receiver=self.user2, + content="To user2" + ) + + # Conversation with user3 + Message.objects.create( + sender=self.user1, + receiver=user3, + content="To user3" + ) + + conv_user2 = list(Message.get_private_conversation(self.user1, self.user2)) + conv_user3 = list(Message.get_private_conversation(self.user1, user3)) + + self.assertEqual(len(conv_user2), 1) + self.assertEqual(len(conv_user3), 1) + self.assertEqual(conv_user2[0].receiver, self.user2) + self.assertEqual(conv_user3[0].receiver, user3) \ No newline at end of file diff --git a/game/tests.py b/game/tests.py index 7ce503c..6b9601e 100644 --- a/game/tests.py +++ b/game/tests.py @@ -1,3 +1,1859 @@ from django.test import TestCase +from django.contrib.auth import get_user_model +from django.core.exceptions import ValidationError +from django.db import IntegrityError +from django.utils import timezone +from game.models import ( + CardSuit, CardRank, Card, Lobby, LobbySettings, LobbyPlayer, + Game, GamePlayer, GameDeck, PlayerHand, TableCard, DiscardPile, + Turn, Move, SpecialCard, SpecialRuleSet, SpecialRuleSetCard +) -# Create your tests here. +User = get_user_model() + + +class CardSuitModelTest(TestCase): + """Test suite for CardSuit model.""" + + def setUp(self): + """Set up test data for CardSuit tests.""" + self.hearts = CardSuit.objects.create(name="Hearts", color="red") + self.spades = CardSuit.objects.create(name="Spades", color="black") + + def test_card_suit_creation(self): + """Test that CardSuit instances are created correctly.""" + self.assertEqual(self.hearts.name, "Hearts") + self.assertEqual(self.hearts.color, "red") + self.assertEqual(self.spades.color, "black") + + def test_card_suit_str_representation(self): + """Test string representation of CardSuit.""" + self.assertEqual(str(self.hearts), "Hearts") + self.assertEqual(str(self.spades), "Spades") + + def test_is_red_method(self): + """Test is_red() method returns correct boolean.""" + self.assertTrue(self.hearts.is_red()) + self.assertFalse(self.spades.is_red()) + + def test_card_suit_ordering(self): + """Test that CardSuit instances are ordered by name.""" + diamonds = CardSuit.objects.create(name="Diamonds", color="red") + clubs = CardSuit.objects.create(name="Clubs", color="black") + + suits = list(CardSuit.objects.all()) + self.assertEqual(suits[0].name, "Clubs") + self.assertEqual(suits[1].name, "Diamonds") + self.assertEqual(suits[2].name, "Hearts") + self.assertEqual(suits[3].name, "Spades") + + def test_card_suit_color_choices(self): + """Test that only valid color choices are accepted.""" + # Valid colors should work + valid_suit = CardSuit.objects.create(name="Test", color="red") + self.assertEqual(valid_suit.color, "red") + + +class CardRankModelTest(TestCase): + """Test suite for CardRank model.""" + + def setUp(self): + """Set up test data for CardRank tests.""" + self.ace = CardRank.objects.create(name="Ace", value=14) + self.king = CardRank.objects.create(name="King", value=13) + self.jack = CardRank.objects.create(name="Jack", value=11) + self.six = CardRank.objects.create(name="Six", value=6) + + def test_card_rank_creation(self): + """Test that CardRank instances are created correctly.""" + self.assertEqual(self.ace.name, "Ace") + self.assertEqual(self.ace.value, 14) + + def test_card_rank_str_representation(self): + """Test string representation of CardRank.""" + self.assertEqual(str(self.ace), "Ace") + self.assertEqual(str(self.king), "King") + + def test_is_face_card_method(self): + """Test is_face_card() method for various ranks.""" + self.assertTrue(self.king.is_face_card()) + self.assertTrue(self.jack.is_face_card()) + self.assertFalse(self.ace.is_face_card()) + self.assertFalse(self.six.is_face_card()) + + queen = CardRank.objects.create(name="Queen", value=12) + self.assertTrue(queen.is_face_card()) + + def test_card_rank_ordering(self): + """Test that CardRank instances are ordered by value.""" + ranks = list(CardRank.objects.all()) + self.assertEqual(ranks[0].value, 6) + self.assertEqual(ranks[1].value, 11) + self.assertEqual(ranks[2].value, 13) + self.assertEqual(ranks[3].value, 14) + + +class CardModelTest(TestCase): + """Test suite for Card model.""" + + def setUp(self): + """Set up test data for Card tests.""" + self.hearts = CardSuit.objects.create(name="Hearts", color="red") + self.spades = CardSuit.objects.create(name="Spades", color="black") + self.diamonds = CardSuit.objects.create(name="Diamonds", color="red") + + self.ace = CardRank.objects.create(name="Ace", value=14) + self.king = CardRank.objects.create(name="King", value=13) + self.seven = CardRank.objects.create(name="Seven", value=7) + self.six = CardRank.objects.create(name="Six", value=6) + + self.ace_of_hearts = Card.objects.create(suit=self.hearts, rank=self.ace) + self.king_of_spades = Card.objects.create(suit=self.spades, rank=self.king) + self.seven_of_hearts = Card.objects.create(suit=self.hearts, rank=self.seven) + self.six_of_diamonds = Card.objects.create(suit=self.diamonds, rank=self.six) + + def test_card_creation(self): + """Test that Card instances are created correctly.""" + self.assertEqual(self.ace_of_hearts.suit, self.hearts) + self.assertEqual(self.ace_of_hearts.rank, self.ace) + + def test_card_str_representation(self): + """Test string representation of Card.""" + self.assertEqual(str(self.ace_of_hearts), "Ace of Hearts") + self.assertEqual(str(self.king_of_spades), "King of Spades") + + def test_card_str_with_special_card(self): + """Test string representation includes special card name.""" + special = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + description="Skip next player's turn" + ) + special_card = Card.objects.create( + suit=self.spades, + rank=self.ace, + special_card=special + ) + self.assertEqual(str(special_card), "Ace of Spades (Skip Turn)") + + def test_is_trump_method(self): + """Test is_trump() method with various trump suits.""" + self.assertTrue(self.ace_of_hearts.is_trump(self.hearts)) + self.assertFalse(self.ace_of_hearts.is_trump(self.spades)) + self.assertTrue(self.king_of_spades.is_trump(self.spades)) + + def test_is_special_method(self): + """Test is_special() method.""" + self.assertFalse(self.ace_of_hearts.is_special()) + + special = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + description="Draw 2 cards" + ) + special_card = Card.objects.create( + suit=self.hearts, + rank=self.seven, + special_card=special + ) + self.assertTrue(special_card.is_special()) + + def test_can_beat_trump_vs_non_trump(self): + """Test that trump cards beat non-trump cards.""" + # Hearts is trump + trump_suit = self.hearts + + # Low trump beats high non-trump + self.assertTrue(self.seven_of_hearts.can_beat(self.king_of_spades, trump_suit)) + + # Non-trump cannot beat trump + self.assertFalse(self.king_of_spades.can_beat(self.seven_of_hearts, trump_suit)) + + def test_can_beat_same_suit(self): + """Test card comparison for same suit.""" + trump_suit = self.spades + + # Higher rank beats lower rank in same suit + self.assertTrue(self.ace_of_hearts.can_beat(self.seven_of_hearts, trump_suit)) + + # Lower rank cannot beat higher rank + self.assertFalse(self.seven_of_hearts.can_beat(self.ace_of_hearts, trump_suit)) + + def test_can_beat_different_non_trump_suits(self): + """Test that different non-trump suits cannot beat each other.""" + trump_suit = self.spades + + # Hearts and Diamonds are both non-trump + self.assertFalse(self.ace_of_hearts.can_beat(self.six_of_diamonds, trump_suit)) + self.assertFalse(self.six_of_diamonds.can_beat(self.ace_of_hearts, trump_suit)) + + def test_can_beat_trump_vs_trump(self): + """Test trump card comparison.""" + trump_suit = self.hearts + + # Higher trump beats lower trump + self.assertTrue(self.ace_of_hearts.can_beat(self.seven_of_hearts, trump_suit)) + self.assertFalse(self.seven_of_hearts.can_beat(self.ace_of_hearts, trump_suit)) + + def test_card_unique_together_constraint(self): + """Test that cards with same suit, rank, and special_card are unique.""" + # Create a special card first + special = SpecialCard.objects.create( + name="Test Special", + effect_type="skip", + description="Test" + ) + + # Create first card with special_card + Card.objects.create( + suit=self.hearts, + rank=self.ace, + special_card=special + ) + + # Creating duplicate with same suit, rank, and special_card should fail + with self.assertRaises(IntegrityError): + Card.objects.create( + suit=self.hearts, + rank=self.ace, + special_card=special + ) + + +class LobbyModelTest(TestCase): + """Test suite for Lobby model.""" + + def setUp(self): + """Set up test data for Lobby tests.""" + self.user1 = User.objects.create_user(username="player1", password="test123") + self.user2 = User.objects.create_user(username="player2", password="test123") + + self.lobby = Lobby.objects.create( + owner=self.user1, + name="Test Lobby", + is_private=False, + status='waiting' + ) + + # Create lobby settings + self.settings = LobbySettings.objects.create( + lobby=self.lobby, + max_players=4, + card_count=36, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False + ) + + def test_lobby_creation(self): + """Test that Lobby instances are created correctly.""" + self.assertEqual(self.lobby.name, "Test Lobby") + self.assertEqual(self.lobby.owner, self.user1) + self.assertFalse(self.lobby.is_private) + self.assertEqual(self.lobby.status, 'waiting') + + def test_lobby_str_representation(self): + """Test string representation of Lobby.""" + self.assertEqual(str(self.lobby), "Test Lobby") + + def test_lobby_uuid_generation(self): + """Test that UUID is automatically generated.""" + self.assertIsNotNone(self.lobby.id) + + def test_is_full_method_empty_lobby(self): + """Test is_full() returns False for empty lobby.""" + self.assertFalse(self.lobby.is_full()) + + def test_is_full_method_with_players(self): + """Test is_full() with various player counts.""" + # Add players up to max + for i in range(4): + user = User.objects.create_user(username=f"player{i + 10}", password="test") + LobbyPlayer.objects.create( + lobby=self.lobby, + user=user, + status='waiting' + ) + + self.assertTrue(self.lobby.is_full()) + + def test_is_full_excludes_left_players(self): + """Test that is_full() doesn't count players who left.""" + # Add 3 active players + for i in range(3): + user = User.objects.create_user(username=f"player{i + 10}", password="test") + LobbyPlayer.objects.create( + lobby=self.lobby, + user=user, + status='waiting' + ) + + # Add 1 player who left + left_user = User.objects.create_user(username="left_player", password="test") + LobbyPlayer.objects.create( + lobby=self.lobby, + user=left_user, + status='left' + ) + + self.assertFalse(self.lobby.is_full()) + + def test_can_start_game_method_not_enough_players(self): + """Test can_start_game() returns False with insufficient ready players.""" + # Add only 1 ready player + LobbyPlayer.objects.create( + lobby=self.lobby, + user=self.user1, + status='ready' + ) + + self.assertFalse(self.lobby.can_start_game()) + + def test_can_start_game_method_enough_ready_players(self): + """Test can_start_game() returns True with enough ready players.""" + # Add 2 ready players + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='ready') + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') + + self.assertTrue(self.lobby.can_start_game()) + + def test_can_start_game_method_wrong_status(self): + """Test can_start_game() returns False if lobby status is not 'waiting'.""" + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='ready') + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') + + self.lobby.status = 'playing' + self.lobby.save() + + self.assertFalse(self.lobby.can_start_game()) + + def test_get_active_players_method(self): + """Test get_active_players() returns only active players.""" + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='waiting') + LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') + + left_user = User.objects.create_user(username="left", password="test") + LobbyPlayer.objects.create(lobby=self.lobby, user=left_user, status='left') + + active_players = self.lobby.get_active_players() + self.assertEqual(active_players.count(), 2) + + def test_lobby_ordering(self): + """Test that lobbies are ordered by creation date (newest first).""" + lobby2 = Lobby.objects.create( + owner=self.user2, + name="Newer Lobby", + status='waiting' + ) + + lobbies = list(Lobby.objects.all()) + self.assertEqual(lobbies[0], lobby2) + self.assertEqual(lobbies[1], self.lobby) + + def test_private_lobby_with_password(self): + """Test creating a private lobby with password hash.""" + private_lobby = Lobby.objects.create( + owner=self.user1, + name="Private Game", + is_private=True, + password_hash="hashed_password_here", + status='waiting' + ) + + self.assertTrue(private_lobby.is_private) + self.assertEqual(private_lobby.password_hash, "hashed_password_here") + + +class LobbySettingsModelTest(TestCase): + """Test suite for LobbySettings model.""" + + def setUp(self): + """Set up test data for LobbySettings tests.""" + self.user = User.objects.create_user(username="player1", password="test123") + self.lobby = Lobby.objects.create( + owner=self.user, + name="Test Lobby", + status='waiting' + ) + + self.settings = LobbySettings.objects.create( + lobby=self.lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False, + turn_time_limit=60 + ) + + def test_lobby_settings_creation(self): + """Test that LobbySettings instances are created correctly.""" + self.assertEqual(self.settings.max_players, 4) + self.assertEqual(self.settings.card_count, 36) + self.assertTrue(self.settings.is_transferable) + + def test_lobby_settings_str_representation(self): + """Test string representation of LobbySettings.""" + expected = "Test Lobby Settings (36 cards, 4 players)" + self.assertEqual(str(self.settings), expected) + + def test_has_time_limit_method(self): + """Test has_time_limit() method.""" + self.assertTrue(self.settings.has_time_limit()) + + # Test without time limit + settings_no_limit = LobbySettings.objects.create( + lobby=Lobby.objects.create(owner=self.user, name="No Limit", status='waiting'), + max_players=2, + card_count=24, + turn_time_limit=None + ) + self.assertFalse(settings_no_limit.has_time_limit()) + + def test_is_beginner_friendly_method_true(self): + """Test is_beginner_friendly() returns True for simple settings.""" + beginner_settings = LobbySettings.objects.create( + lobby=Lobby.objects.create(owner=self.user, name="Beginner", status='waiting'), + max_players=2, + card_count=24, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + special_rule_set=None + ) + + self.assertTrue(beginner_settings.is_beginner_friendly()) + + def test_is_beginner_friendly_method_false_transferable(self): + """Test is_beginner_friendly() returns False with transferable cards.""" + self.assertFalse(self.settings.is_beginner_friendly()) + + def test_is_beginner_friendly_method_false_jokers(self): + """Test is_beginner_friendly() returns False with jokers.""" + settings = LobbySettings.objects.create( + lobby=Lobby.objects.create(owner=self.user, name="Jokers", status='waiting'), + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=True + ) + + self.assertFalse(settings.is_beginner_friendly()) + + def test_is_beginner_friendly_method_false_special_rules(self): + """Test is_beginner_friendly() returns False with special rule set.""" + special_rules = SpecialRuleSet.objects.create( + name="Advanced Rules", + description="Complex rules", + min_players=2 + ) + + settings = LobbySettings.objects.create( + lobby=Lobby.objects.create(owner=self.user, name="Special", status='waiting'), + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=False, + special_rule_set=special_rules + ) + + self.assertFalse(settings.is_beginner_friendly()) + + def test_card_count_choices(self): + """Test that only valid card counts are accepted.""" + for count in [24, 36, 52]: + settings = LobbySettings.objects.create( + lobby=Lobby.objects.create(owner=self.user, name=f"Lobby{count}", status='waiting'), + max_players=2, + card_count=count + ) + self.assertEqual(settings.card_count, count) + + +class LobbyPlayerModelTest(TestCase): + """Test suite for LobbyPlayer model.""" + + def setUp(self): + """Set up test data for LobbyPlayer tests.""" + self.user1 = User.objects.create_user(username="player1", password="test123") + self.user2 = User.objects.create_user(username="player2", password="test123") + + self.lobby = Lobby.objects.create( + owner=self.user1, + name="Test Lobby", + status='waiting' + ) + + self.lobby_player = LobbyPlayer.objects.create( + lobby=self.lobby, + user=self.user1, + status='waiting' + ) + + def test_lobby_player_creation(self): + """Test that LobbyPlayer instances are created correctly.""" + self.assertEqual(self.lobby_player.lobby, self.lobby) + self.assertEqual(self.lobby_player.user, self.user1) + self.assertEqual(self.lobby_player.status, 'waiting') + + def test_lobby_player_str_representation(self): + """Test string representation of LobbyPlayer.""" + expected = "player1 (waiting) in Test Lobby" + self.assertEqual(str(self.lobby_player), expected) + + def test_is_active_method(self): + """Test is_active() method for various statuses.""" + self.assertTrue(self.lobby_player.is_active()) + + self.lobby_player.status = 'ready' + self.assertTrue(self.lobby_player.is_active()) + + self.lobby_player.status = 'playing' + self.assertTrue(self.lobby_player.is_active()) + + self.lobby_player.status = 'left' + self.assertFalse(self.lobby_player.is_active()) + + def test_can_start_game_method(self): + """Test can_start_game() method.""" + self.assertFalse(self.lobby_player.can_start_game()) + + self.lobby_player.status = 'ready' + self.assertTrue(self.lobby_player.can_start_game()) + + self.lobby_player.status = 'playing' + self.assertFalse(self.lobby_player.can_start_game()) + + def test_leave_lobby_method(self): + """Test leave_lobby() method updates status.""" + self.lobby_player.leave_lobby() + + self.lobby_player.refresh_from_db() + self.assertEqual(self.lobby_player.status, 'left') + + def test_unique_together_constraint(self): + """Test that a user cannot join the same lobby twice.""" + with self.assertRaises(IntegrityError): + LobbyPlayer.objects.create( + lobby=self.lobby, + user=self.user1, + status='waiting' + ) + + def test_lobby_player_ordering(self): + """Test that lobby players are ordered by lobby and username.""" + user3 = User.objects.create_user(username="aaa_first", password="test") + + player2 = LobbyPlayer.objects.create( + lobby=self.lobby, + user=self.user2, + status='waiting' + ) + + player3 = LobbyPlayer.objects.create( + lobby=self.lobby, + user=user3, + status='waiting' + ) + + players = list(LobbyPlayer.objects.filter(lobby=self.lobby)) + self.assertEqual(players[0].user.username, "aaa_first") + self.assertEqual(players[1].user.username, "player1") + self.assertEqual(players[2].user.username, "player2") + + +class GameModelTest(TestCase): + """Test suite for Game model.""" + + def setUp(self): + """Set up test data for Game tests.""" + self.user1 = User.objects.create_user(username="player1", password="test123") + self.user2 = User.objects.create_user(username="player2", password="test123") + + self.lobby = Lobby.objects.create( + owner=self.user1, + name="Game Lobby", + status='playing' + ) + + # Create cards for trump + self.hearts = CardSuit.objects.create(name="Hearts", color="red") + self.ace = CardRank.objects.create(name="Ace", value=14) + self.trump_card = Card.objects.create(suit=self.hearts, rank=self.ace) + + self.game = Game.objects.create( + lobby=self.lobby, + trump_card=self.trump_card, + status='in_progress' + ) + + def test_game_creation(self): + """Test that Game instances are created correctly.""" + self.assertEqual(self.game.lobby, self.lobby) + self.assertEqual(self.game.trump_card, self.trump_card) + self.assertEqual(self.game.status, 'in_progress') + self.assertIsNone(self.game.finished_at) + self.assertIsNone(self.game.loser) + + def test_game_str_representation(self): + """Test string representation of Game.""" + expected = "Game in Game Lobby (in_progress)" + self.assertEqual(str(self.game), expected) + + def test_is_active_method(self): + """Test is_active() method.""" + self.assertTrue(self.game.is_active()) + + self.game.status = 'finished' + self.assertFalse(self.game.is_active()) + + def test_get_trump_suit_method(self): + """Test get_trump_suit() returns correct suit.""" + trump_suit = self.game.get_trump_suit() + self.assertEqual(trump_suit, self.hearts) + + def test_get_player_count_method(self): + """Test get_player_count() returns correct count.""" + self.assertEqual(self.game.get_player_count(), 0) + + GamePlayer.objects.create( + game=self.game, + user=self.user1, + seat_position=1, + cards_remaining=6 + ) + + GamePlayer.objects.create( + game=self.game, + user=self.user2, + seat_position=2, + cards_remaining=6 + ) + + self.assertEqual(self.game.get_player_count(), 2) + + def test_get_winner_method_active_game(self): + """Test get_winner() returns None for active games.""" + self.assertIsNone(self.game.get_winner()) + + def test_get_winner_method_finished_game(self): + """Test get_winner() returns winners after game finishes.""" + player1 = GamePlayer.objects.create( + game=self.game, + user=self.user1, + seat_position=1, + cards_remaining=0 + ) + + player2 = GamePlayer.objects.create( + game=self.game, + user=self.user2, + seat_position=2, + cards_remaining=3 + ) + + self.game.status = 'finished' + self.game.loser = self.user2 + self.game.finished_at = timezone.now() + self.game.save() + + winners = self.game.get_winner() + self.assertIsNotNone(winners) + self.assertEqual(winners.count(), 1) + self.assertEqual(winners.first().user, self.user1) + + def test_game_ordering(self): + """Test that games are ordered by start time (newest first).""" + game2 = Game.objects.create( + lobby=self.lobby, + trump_card=self.trump_card, + status='in_progress' + ) + + games = list(Game.objects.all()) + self.assertEqual(games[0], game2) + self.assertEqual(games[1], self.game) + + +class GamePlayerModelTest(TestCase): + """Test suite for GamePlayer model.""" + + def setUp(self): + """Set up test data for GamePlayer tests.""" + self.user = User.objects.create_user(username="player1", password="test123") + + lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + self.game_player = GamePlayer.objects.create( + game=self.game, + user=self.user, + seat_position=1, + cards_remaining=6 + ) + + def test_game_player_creation(self): + """Test that GamePlayer instances are created correctly.""" + self.assertEqual(self.game_player.game, self.game) + self.assertEqual(self.game_player.user, self.user) + self.assertEqual(self.game_player.seat_position, 1) + self.assertEqual(self.game_player.cards_remaining, 6) + + def test_game_player_str_representation(self): + """Test string representation of GamePlayer.""" + expected = "player1 (6 cards) - Position 1" + self.assertEqual(str(self.game_player), expected) + + def test_has_cards_method(self): + """Test has_cards() method.""" + self.assertTrue(self.game_player.has_cards()) + + self.game_player.cards_remaining = 0 + self.assertFalse(self.game_player.has_cards()) + + def test_is_eliminated_method(self): + """Test is_eliminated() method.""" + self.assertFalse(self.game_player.is_eliminated()) + + self.game_player.cards_remaining = 0 + self.assertTrue(self.game_player.is_eliminated()) + + def test_get_hand_cards_method(self): + """Test get_hand_cards() returns correct queryset.""" + # Create some cards + hearts = CardSuit.objects.create(name="Hearts", color="red") + seven = CardRank.objects.create(name="Seven", value=7) + card = Card.objects.create(suit=hearts, rank=seven) + + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=card, + order_in_hand=1 + ) + + hand_cards = self.game_player.get_hand_cards() + self.assertEqual(hand_cards.count(), 1) + self.assertEqual(hand_cards.first().card, card) + + def test_unique_together_constraint(self): + """Test that a user cannot be added to same game twice.""" + with self.assertRaises(IntegrityError): + GamePlayer.objects.create( + game=self.game, + user=self.user, + seat_position=2, + cards_remaining=6 + ) + + def test_game_player_ordering(self): + """Test that game players are ordered by seat position.""" + user2 = User.objects.create_user(username="player2", password="test") + user3 = User.objects.create_user(username="player3", password="test") + + player2 = GamePlayer.objects.create( + game=self.game, + user=user2, + seat_position=3, + cards_remaining=6 + ) + + player3 = GamePlayer.objects.create( + game=self.game, + user=user3, + seat_position=2, + cards_remaining=6 + ) + + players = list(GamePlayer.objects.filter(game=self.game)) + self.assertEqual(players[0].seat_position, 1) + self.assertEqual(players[1].seat_position, 2) + self.assertEqual(players[2].seat_position, 3) + + +class SpecialCardModelTest(TestCase): + """Test suite for SpecialCard model.""" + + def setUp(self): + """Set up test data for SpecialCard tests.""" + self.skip_card = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + effect_value={}, + description="Next player loses their turn" + ) + + self.draw_card = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + effect_value={"card_count": 2}, + description="Target draws cards" + ) + + def test_special_card_creation(self): + """Test that SpecialCard instances are created correctly.""" + self.assertEqual(self.skip_card.name, "Skip Turn") + self.assertEqual(self.skip_card.effect_type, "skip") + self.assertEqual(self.skip_card.effect_value, {}) + + def test_special_card_str_representation(self): + """Test string representation of SpecialCard.""" + self.assertEqual(str(self.skip_card), "Skip Turn") + + def test_get_effect_description_with_card_count(self): + """Test get_effect_description() includes card count for draw effects.""" + description = self.draw_card.get_effect_description() + self.assertIn("2 cards", description) + + def test_get_effect_description_without_card_count(self): + """Test get_effect_description() returns base description.""" + description = self.skip_card.get_effect_description() + self.assertEqual(description, "Next player loses their turn") + + def test_is_targetable_method(self): + """Test is_targetable() for different effect types.""" + self.assertTrue(self.skip_card.is_targetable()) + self.assertTrue(self.draw_card.is_targetable()) + + reverse_card = SpecialCard.objects.create( + name="Reverse", + effect_type="reverse", + description="Reverse turn order" + ) + self.assertFalse(reverse_card.is_targetable()) + + def test_can_be_countered_default(self): + """Test can_be_countered() returns True by default.""" + self.assertTrue(self.skip_card.can_be_countered()) + + def test_can_be_countered_explicit(self): + """Test can_be_countered() respects effect_value setting.""" + uncounterable = SpecialCard.objects.create( + name="Unstoppable", + effect_type="custom", + effect_value={"counterable": False}, + description="Cannot be countered" + ) + + self.assertFalse(uncounterable.can_be_countered()) + + def test_special_card_ordering(self): + """Test that special cards are ordered by name.""" + cards = list(SpecialCard.objects.all()) + self.assertEqual(cards[0].name, "Draw Two") + self.assertEqual(cards[1].name, "Skip Turn") + + +class SpecialRuleSetModelTest(TestCase): + """Test suite for SpecialRuleSet model.""" + + def setUp(self): + """Set up test data for SpecialRuleSet tests.""" + self.rule_set = SpecialRuleSet.objects.create( + name="Beginner Special", + description="Simple special cards for new players", + min_players=2 + ) + + self.special_card = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + description="Skip next turn" + ) + + def test_special_rule_set_creation(self): + """Test that SpecialRuleSet instances are created correctly.""" + self.assertEqual(self.rule_set.name, "Beginner Special") + self.assertEqual(self.rule_set.min_players, 2) + + def test_special_rule_set_str_representation(self): + """Test string representation of SpecialRuleSet.""" + self.assertEqual(str(self.rule_set), "Beginner Special") + + def test_get_special_card_count_method(self): + """Test get_special_card_count() returns correct count.""" + self.assertEqual(self.rule_set.get_special_card_count(), 0) + + SpecialRuleSetCard.objects.create( + rule_set=self.rule_set, + card=self.special_card, + is_enabled=True + ) + + self.assertEqual(self.rule_set.get_special_card_count(), 1) + + def test_is_compatible_with_player_count_method(self): + """Test is_compatible_with_player_count() validation.""" + self.assertTrue(self.rule_set.is_compatible_with_player_count(2)) + self.assertTrue(self.rule_set.is_compatible_with_player_count(4)) + self.assertFalse(self.rule_set.is_compatible_with_player_count(1)) + + def test_get_enabled_special_cards_method(self): + """Test get_enabled_special_cards() returns only enabled cards.""" + enabled_card = SpecialCard.objects.create( + name="Enabled", + effect_type="skip", + description="Enabled card" + ) + + disabled_card = SpecialCard.objects.create( + name="Disabled", + effect_type="skip", + description="Disabled card" + ) + + SpecialRuleSetCard.objects.create( + rule_set=self.rule_set, + card=enabled_card, + is_enabled=True + ) + + SpecialRuleSetCard.objects.create( + rule_set=self.rule_set, + card=disabled_card, + is_enabled=False + ) + + enabled_cards = self.rule_set.get_enabled_special_cards() + self.assertEqual(enabled_cards.count(), 1) + self.assertEqual(enabled_cards.first(), enabled_card) + + def test_can_be_used_in_lobby_method_compatible(self): + """Test can_be_used_in_lobby() with compatible settings.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + allow_jokers=True + ) + + self.assertTrue(self.rule_set.can_be_used_in_lobby(settings)) + + def test_can_be_used_in_lobby_method_no_jokers(self): + """Test can_be_used_in_lobby() fails without jokers enabled.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + allow_jokers=False + ) + + self.assertFalse(self.rule_set.can_be_used_in_lobby(settings)) + + def test_can_be_used_in_lobby_method_insufficient_players(self): + """Test can_be_used_in_lobby() fails with too few players.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=1, # Less than min_players + card_count=36, + allow_jokers=True + ) + + self.assertFalse(self.rule_set.can_be_used_in_lobby(settings)) + + +class SpecialRuleSetCardModelTest(TestCase): + """Test suite for SpecialRuleSetCard model.""" + + def setUp(self): + """Set up test data for SpecialRuleSetCard tests.""" + self.rule_set = SpecialRuleSet.objects.create( + name="Test Rules", + description="Test rule set", + min_players=2 + ) + + self.special_card = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + description="Skip turn" + ) + + self.rule_set_card = SpecialRuleSetCard.objects.create( + rule_set=self.rule_set, + card=self.special_card, + is_enabled=True + ) + + def test_special_rule_set_card_creation(self): + """Test that SpecialRuleSetCard instances are created correctly.""" + self.assertEqual(self.rule_set_card.rule_set, self.rule_set) + self.assertEqual(self.rule_set_card.card, self.special_card) + self.assertTrue(self.rule_set_card.is_enabled) + + def test_special_rule_set_card_str_representation(self): + """Test string representation of SpecialRuleSetCard.""" + expected = "Skip Turn in Test Rules (enabled)" + self.assertEqual(str(self.rule_set_card), expected) + + self.rule_set_card.is_enabled = False + expected_disabled = "Skip Turn in Test Rules (disabled)" + self.assertEqual(str(self.rule_set_card), expected_disabled) + + def test_toggle_enabled_method(self): + """Test toggle_enabled() switches enabled status.""" + self.assertTrue(self.rule_set_card.is_enabled) + + result = self.rule_set_card.toggle_enabled() + self.assertFalse(result) + self.rule_set_card.refresh_from_db() + self.assertFalse(self.rule_set_card.is_enabled) + + result = self.rule_set_card.toggle_enabled() + self.assertTrue(result) + self.rule_set_card.refresh_from_db() + self.assertTrue(self.rule_set_card.is_enabled) + + def test_can_be_used_in_game_enabled(self): + """Test can_be_used_in_game() with enabled card.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + allow_jokers=True, + special_rule_set=self.rule_set + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump = Card.objects.create(suit=hearts, rank=ace) + + game = Game.objects.create( + lobby=lobby, + trump_card=trump, + status='in_progress' + ) + + self.assertTrue(self.rule_set_card.can_be_used_in_game(game)) + + def test_can_be_used_in_game_disabled(self): + """Test can_be_used_in_game() with disabled card.""" + self.rule_set_card.is_enabled = False + self.rule_set_card.save() + + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + allow_jokers=True, + special_rule_set=self.rule_set + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump = Card.objects.create(suit=hearts, rank=ace) + + game = Game.objects.create( + lobby=lobby, + trump_card=trump, + status='in_progress' + ) + + self.assertFalse(self.rule_set_card.can_be_used_in_game(game)) + + def test_can_be_used_in_game_no_jokers(self): + """Test can_be_used_in_game() fails without jokers.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + allow_jokers=False, + special_rule_set=self.rule_set + ) + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump = Card.objects.create(suit=hearts, rank=ace) + + game = Game.objects.create( + lobby=lobby, + trump_card=trump, + status='in_progress' + ) + + self.assertFalse(self.rule_set_card.can_be_used_in_game(game)) + + def test_unique_together_constraint(self): + """Test that card can only be added to rule set once.""" + with self.assertRaises(IntegrityError): + SpecialRuleSetCard.objects.create( + rule_set=self.rule_set, + card=self.special_card, + is_enabled=False + ) + + +class GameDeckModelTest(TestCase): + """Test suite for GameDeck model.""" + + def setUp(self): + """Set up test data for GameDeck tests.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + self.ace = CardRank.objects.create(name="Ace", value=14) + self.seven = CardRank.objects.create(name="Seven", value=7) + + self.trump_card = Card.objects.create(suit=hearts, rank=self.ace) + self.deck_card = Card.objects.create(suit=hearts, rank=self.seven) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=self.trump_card, + status='in_progress' + ) + + self.game_deck = GameDeck.objects.create( + game=self.game, + card=self.deck_card, + position=1 + ) + + def test_game_deck_creation(self): + """Test that GameDeck instances are created correctly.""" + self.assertEqual(self.game_deck.game, self.game) + self.assertEqual(self.game_deck.card, self.deck_card) + self.assertEqual(self.game_deck.position, 1) + + def test_game_deck_str_representation(self): + """Test string representation of GameDeck.""" + self.assertIn("Seven of Hearts", str(self.game_deck)) + self.assertIn("position 1", str(self.game_deck)) + + def test_get_top_card_method(self): + """Test get_top_card() returns lowest position card.""" + spades = CardSuit.objects.create(name="Spades", color="black") + card2 = Card.objects.create(suit=spades, rank=self.ace) + + GameDeck.objects.create( + game=self.game, + card=card2, + position=2 + ) + + top_card = GameDeck.get_top_card(self.game) + self.assertEqual(top_card.position, 1) + self.assertEqual(top_card.card, self.deck_card) + + def test_get_top_card_empty_deck(self): + """Test get_top_card() returns None for empty deck.""" + self.game_deck.delete() + + top_card = GameDeck.get_top_card(self.game) + self.assertIsNone(top_card) + + def test_draw_card_method(self): + """Test draw_card() removes and returns top card.""" + drawn_card = GameDeck.draw_card(self.game) + + self.assertEqual(drawn_card, self.deck_card) + self.assertEqual(GameDeck.objects.filter(game=self.game).count(), 0) + + def test_draw_card_empty_deck(self): + """Test draw_card() returns None for empty deck.""" + self.game_deck.delete() + + drawn_card = GameDeck.draw_card(self.game) + self.assertIsNone(drawn_card) + + def test_is_last_card_method(self): + """Test is_last_card() detection.""" + self.assertTrue(self.game_deck.is_last_card()) + + spades = CardSuit.objects.create(name="Spades", color="black") + card2 = Card.objects.create(suit=spades, rank=self.ace) + + GameDeck.objects.create( + game=self.game, + card=card2, + position=2 + ) + + self.assertFalse(self.game_deck.is_last_card()) + + def test_game_deck_ordering(self): + """Test that deck cards are ordered by position.""" + spades = CardSuit.objects.create(name="Spades", color="black") + king = CardRank.objects.create(name="King", value=13) + + card2 = Card.objects.create(suit=spades, rank=king) + card3 = Card.objects.create(suit=spades, rank=self.seven) + + GameDeck.objects.create(game=self.game, card=card3, position=3) + GameDeck.objects.create(game=self.game, card=card2, position=2) + + deck_cards = list(GameDeck.objects.filter(game=self.game)) + self.assertEqual(deck_cards[0].position, 1) + self.assertEqual(deck_cards[1].position, 2) + self.assertEqual(deck_cards[2].position, 3) + + def test_unique_together_constraint(self): + """Test that game and position combination is unique.""" + spades = CardSuit.objects.create(name="Spades", color="black") + card2 = Card.objects.create(suit=spades, rank=self.ace) + + with self.assertRaises(IntegrityError): + GameDeck.objects.create( + game=self.game, + card=card2, + position=1 # Same position as existing card + ) + + +class PlayerHandModelTest(TestCase): + """Test suite for PlayerHand model.""" + + def setUp(self): + """Set up test data for PlayerHand tests.""" + self.user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + + trump_card = Card.objects.create(suit=hearts, rank=ace) + self.hand_card = Card.objects.create( + suit=hearts, + rank=CardRank.objects.create(name="Seven", value=7) + ) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + self.game_player = GamePlayer.objects.create( + game=self.game, + user=self.user, + seat_position=1, + cards_remaining=6 + ) + + self.player_hand = PlayerHand.objects.create( + game=self.game, + player=self.user, + card=self.hand_card, + order_in_hand=1 + ) + + def test_player_hand_creation(self): + """Test that PlayerHand instances are created correctly.""" + self.assertEqual(self.player_hand.game, self.game) + self.assertEqual(self.player_hand.player, self.user) + self.assertEqual(self.player_hand.card, self.hand_card) + self.assertEqual(self.player_hand.order_in_hand, 1) + + def test_player_hand_str_representation(self): + """Test string representation of PlayerHand.""" + self.assertIn("player", str(self.player_hand)) + self.assertIn("Seven of Hearts", str(self.player_hand)) + + def test_get_player_hand_method(self): + """Test get_player_hand() returns all cards for player.""" + spades = CardSuit.objects.create(name="Spades", color="black") + king = CardRank.objects.create(name="King", value=13) + card2 = Card.objects.create(suit=spades, rank=king) + + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=card2, + order_in_hand=2 + ) + + hand = PlayerHand.get_player_hand(self.game, self.user) + self.assertEqual(hand.count(), 2) + self.assertEqual(hand.first().order_in_hand, 1) + self.assertEqual(hand.last().order_in_hand, 2) + + def test_get_hand_size_method(self): + """Test get_hand_size() returns correct count.""" + self.assertEqual(PlayerHand.get_hand_size(self.game, self.user), 1) + + spades = CardSuit.objects.create(name="Spades", color="black") + king = CardRank.objects.create(name="King", value=13) + card2 = Card.objects.create(suit=spades, rank=king) + + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=card2, + order_in_hand=2 + ) + + self.assertEqual(PlayerHand.get_hand_size(self.game, self.user), 2) + + def test_remove_from_hand_method(self): + """Test remove_from_hand() deletes card and updates counter.""" + self.player_hand.remove_from_hand() + + self.assertEqual(PlayerHand.objects.filter(game=self.game, player=self.user).count(), 0) + + self.game_player.refresh_from_db() + self.assertEqual(self.game_player.cards_remaining, 5) + + def test_remove_from_hand_prevents_negative(self): + """Test remove_from_hand() doesn't go below zero cards.""" + self.game_player.cards_remaining = 0 + self.game_player.save() + + self.player_hand.remove_from_hand() + + self.game_player.refresh_from_db() + self.assertEqual(self.game_player.cards_remaining, 0) + + def test_unique_together_constraint(self): + """Test that same card cannot be in player's hand twice.""" + with self.assertRaises(IntegrityError): + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=self.hand_card, + order_in_hand=2 + ) + + def test_player_hand_ordering(self): + """Test that hand cards are ordered by order_in_hand.""" + spades = CardSuit.objects.create(name="Spades", color="black") + king = CardRank.objects.create(name="King", value=13) + queen = CardRank.objects.create(name="Queen", value=12) + + card2 = Card.objects.create(suit=spades, rank=king) + card3 = Card.objects.create(suit=spades, rank=queen) + + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=card3, + order_in_hand=3 + ) + + PlayerHand.objects.create( + game=self.game, + player=self.user, + card=card2, + order_in_hand=2 + ) + + hand = list(PlayerHand.objects.filter(game=self.game, player=self.user)) + self.assertEqual(hand[0].order_in_hand, 1) + self.assertEqual(hand[1].order_in_hand, 2) + self.assertEqual(hand[2].order_in_hand, 3) + + +class TableCardModelTest(TestCase): + """Test suite for TableCard model.""" + + def setUp(self): + """Set up test data for TableCard tests.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + self.hearts = CardSuit.objects.create(name="Hearts", color="red") + self.spades = CardSuit.objects.create(name="Spades", color="black") + + ace = CardRank.objects.create(name="Ace", value=14) + seven = CardRank.objects.create(name="Seven", value=7) + ten = CardRank.objects.create(name="Ten", value=10) + + self.trump_card = Card.objects.create(suit=self.hearts, rank=ace) + self.attack_card = Card.objects.create(suit=self.hearts, rank=seven) + self.defense_card = Card.objects.create(suit=self.hearts, rank=ten) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=self.trump_card, + status='in_progress' + ) + + self.table_card = TableCard.objects.create( + game=self.game, + attack_card=self.attack_card + ) + + def test_table_card_creation(self): + """Test that TableCard instances are created correctly.""" + self.assertEqual(self.table_card.game, self.game) + self.assertEqual(self.table_card.attack_card, self.attack_card) + self.assertIsNone(self.table_card.defense_card) + + def test_table_card_str_undefended(self): + """Test string representation of undefended table card.""" + self.assertIn("Seven of Hearts", str(self.table_card)) + self.assertIn("undefended", str(self.table_card)) + + def test_table_card_str_defended(self): + """Test string representation of defended table card.""" + self.table_card.defense_card = self.defense_card + self.table_card.save() + + self.assertIn("Seven of Hearts", str(self.table_card)) + self.assertIn("defended by", str(self.table_card)) + self.assertIn("Ten of Hearts", str(self.table_card)) + + def test_is_defended_method(self): + """Test is_defended() method.""" + self.assertFalse(self.table_card.is_defended()) + + self.table_card.defense_card = self.defense_card + self.assertTrue(self.table_card.is_defended()) + + def test_is_valid_defense_same_suit_higher_rank(self): + """Test is_valid_defense() with valid same-suit defense.""" + trump_suit = self.spades + + is_valid = self.table_card.is_valid_defense(self.defense_card, trump_suit) + self.assertTrue(is_valid) + + def test_is_valid_defense_same_suit_lower_rank(self): + """Test is_valid_defense() fails with lower rank.""" + trump_suit = self.spades + + six = CardRank.objects.create(name="Six", value=6) + weak_defense = Card.objects.create(suit=self.hearts, rank=six) + + is_valid = self.table_card.is_valid_defense(weak_defense, trump_suit) + self.assertFalse(is_valid) + + def test_is_valid_defense_trump_vs_non_trump(self): + """Test is_valid_defense() with trump card defending non-trump.""" + trump_suit = self.spades + + six = CardRank.objects.create(name="Six", value=6) + trump_defense = Card.objects.create(suit=self.spades, rank=six) + + is_valid = self.table_card.is_valid_defense(trump_defense, trump_suit) + self.assertTrue(is_valid) + + def test_is_valid_defense_already_defended(self): + """Test is_valid_defense() returns False if already defended.""" + self.table_card.defense_card = self.defense_card + self.table_card.save() + + ace = CardRank.objects.create(name="Ace", value=14) + another_defense = Card.objects.create(suit=self.hearts, rank=ace) + + is_valid = self.table_card.is_valid_defense(another_defense, self.hearts) + self.assertFalse(is_valid) + + def test_defend_with_valid_defense(self): + """Test defend_with() successfully defends with valid card.""" + trump_suit = self.spades + + result = self.table_card.defend_with(self.defense_card, trump_suit) + + self.assertTrue(result) + self.table_card.refresh_from_db() + self.assertEqual(self.table_card.defense_card, self.defense_card) + + def test_defend_with_invalid_defense(self): + """Test defend_with() fails with invalid card.""" + trump_suit = self.spades + + six = CardRank.objects.create(name="Six", value=6) + weak_defense = Card.objects.create(suit=self.hearts, rank=six) + + result = self.table_card.defend_with(weak_defense, trump_suit) + + self.assertFalse(result) + self.table_card.refresh_from_db() + self.assertIsNone(self.table_card.defense_card) + + +class DiscardPileModelTest(TestCase): + """Test suite for DiscardPile model.""" + + def setUp(self): + """Set up test data for DiscardPile tests.""" + user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=user, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + seven = CardRank.objects.create(name="Seven", value=7) + + trump_card = Card.objects.create(suit=hearts, rank=ace) + self.discarded_card = Card.objects.create(suit=hearts, rank=seven) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + self.discard_pile = DiscardPile.objects.create( + game=self.game, + card=self.discarded_card, + position=1 + ) + + def test_discard_pile_creation(self): + """Test that DiscardPile instances are created correctly.""" + self.assertEqual(self.discard_pile.game, self.game) + self.assertEqual(self.discard_pile.card, self.discarded_card) + self.assertEqual(self.discard_pile.position, 1) + + def test_discard_pile_str_with_position(self): + """Test string representation with position.""" + self.assertIn("Discarded", str(self.discard_pile)) + self.assertIn("Seven of Hearts", str(self.discard_pile)) + self.assertIn("position 1", str(self.discard_pile)) + + def test_discard_pile_str_without_position(self): + """Test string representation without position.""" + discard = DiscardPile.objects.create( + game=self.game, + card=self.discarded_card, + position=None + ) + + result = str(discard) + self.assertIn("Discarded", result) + self.assertIn("Seven of Hearts", result) + + +class TurnModelTest(TestCase): + """Test suite for Turn model.""" + + def setUp(self): + """Set up test data for Turn tests.""" + self.user1 = User.objects.create_user(username="player1", password="test") + self.user2 = User.objects.create_user(username="player2", password="test") + + lobby = Lobby.objects.create(owner=self.user1, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + trump_card = Card.objects.create(suit=hearts, rank=ace) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + self.turn = Turn.objects.create( + game=self.game, + player=self.user1, + turn_number=1 + ) + + def test_turn_creation(self): + """Test that Turn instances are created correctly.""" + self.assertEqual(self.turn.game, self.game) + self.assertEqual(self.turn.player, self.user1) + self.assertEqual(self.turn.turn_number, 1) + + def test_turn_str_representation(self): + """Test string representation of Turn.""" + expected = "Turn 1: player1" + self.assertIn(expected, str(self.turn)) + + def test_get_moves_method(self): + """Test get_moves() returns all moves for turn.""" + # Initially no moves + self.assertEqual(self.turn.get_moves().count(), 0) + + # Create a move + seven = CardRank.objects.create(name="Seven", value=7) + attack_card = Card.objects.create( + suit=CardSuit.objects.create(name="Spades", color="black"), + rank=seven + ) + + table_card = TableCard.objects.create( + game=self.game, + attack_card=attack_card + ) + + Move.objects.create( + turn=self.turn, + table_card=table_card, + action_type='attack' + ) + + self.assertEqual(self.turn.get_moves().count(), 1) + + def test_is_complete_method(self): + """Test is_complete() method.""" + self.assertFalse(self.turn.is_complete()) + + # Add a move + seven = CardRank.objects.create(name="Seven", value=7) + attack_card = Card.objects.create( + suit=CardSuit.objects.create(name="Spades", color="black"), + rank=seven + ) + + table_card = TableCard.objects.create( + game=self.game, + attack_card=attack_card + ) + + Move.objects.create( + turn=self.turn, + table_card=table_card, + action_type='attack' + ) + + self.assertTrue(self.turn.is_complete()) + + def test_get_current_turn_method(self): + """Test get_current_turn() returns most recent turn.""" + current = Turn.get_current_turn(self.game) + self.assertEqual(current, self.turn) + + # Create a newer turn + turn2 = Turn.objects.create( + game=self.game, + player=self.user2, + turn_number=2 + ) + + current = Turn.get_current_turn(self.game) + self.assertEqual(current, turn2) + + def test_get_current_turn_no_turns(self): + """Test get_current_turn() returns None for game without turns.""" + # Create new game without turns + lobby = Lobby.objects.create(owner=self.user1, name="New", status='playing') + hearts = CardSuit.objects.create(name="Diamonds", color="red") + ace = CardRank.objects.create(name="King", value=13) + trump = Card.objects.create(suit=hearts, rank=ace) + + new_game = Game.objects.create( + lobby=lobby, + trump_card=trump, + status='in_progress' + ) + + current = Turn.get_current_turn(new_game) + self.assertIsNone(current) + + def test_create_next_turn_method(self): + """Test create_next_turn() creates sequential turns.""" + next_turn = Turn.create_next_turn(self.game, self.user2) + + self.assertEqual(next_turn.turn_number, 2) + self.assertEqual(next_turn.player, self.user2) + self.assertEqual(next_turn.game, self.game) + + def test_unique_together_constraint(self): + """Test that game and turn_number combination is unique.""" + with self.assertRaises(IntegrityError): + Turn.objects.create( + game=self.game, + player=self.user2, + turn_number=1 # Same as existing turn + ) + + def test_turn_ordering(self): + """Test that turns are ordered by turn_number.""" + turn2 = Turn.objects.create( + game=self.game, + player=self.user2, + turn_number=2 + ) + + turn3 = Turn.objects.create( + game=self.game, + player=self.user1, + turn_number=3 + ) + + turns = list(Turn.objects.filter(game=self.game)) + self.assertEqual(turns[0].turn_number, 1) + self.assertEqual(turns[1].turn_number, 2) + self.assertEqual(turns[2].turn_number, 3) + + +class MoveModelTest(TestCase): + """Test suite for Move model.""" + + def setUp(self): + """Set up test data for Move tests.""" + self.user = User.objects.create_user(username="player", password="test") + lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') + + hearts = CardSuit.objects.create(name="Hearts", color="red") + ace = CardRank.objects.create(name="Ace", value=14) + seven = CardRank.objects.create(name="Seven", value=7) + + trump_card = Card.objects.create(suit=hearts, rank=ace) + attack_card = Card.objects.create(suit=hearts, rank=seven) + + self.game = Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status='in_progress' + ) + + self.turn = Turn.objects.create( + game=self.game, + player=self.user, + turn_number=1 + ) + + self.table_card = TableCard.objects.create( + game=self.game, + attack_card=attack_card + ) + + self.move = Move.objects.create( + turn=self.turn, + table_card=self.table_card, + action_type='attack' + ) + + def test_move_creation(self): + """Test that Move instances are created correctly.""" + self.assertEqual(self.move.turn, self.turn) + self.assertEqual(self.move.table_card, self.table_card) + self.assertEqual(self.move.action_type, 'attack') + + def test_move_str_representation(self): + """Test string representation of Move.""" + result = str(self.move) + self.assertIn("Attack", result) + self.assertIn("player", result) + self.assertIn("Seven of Hearts", result) + + def test_get_player_method(self): + """Test get_player() returns correct user.""" + player = self.move.get_player() + self.assertEqual(player, self.user) + + def test_is_attack_method(self): + """Test is_attack() method.""" + self.assertTrue(self.move.is_attack()) + + self.move.action_type = 'defend' + self.assertFalse(self.move.is_attack()) + + def test_is_defense_method(self): + """Test is_defense() method.""" + self.assertFalse(self.move.is_defense()) + + self.move.action_type = 'defend' + self.assertTrue(self.move.is_defense()) + + def test_is_pickup_method(self): + """Test is_pickup() method.""" + self.assertFalse(self.move.is_pickup()) + + self.move.action_type = 'pickup' + self.assertTrue(self.move.is_pickup()) + + def test_get_game_moves_method(self): + """Test get_game_moves() returns all moves for game.""" + # Create another move + ten = CardRank.objects.create(name="Ten", value=10) + defense_card = Card.objects.create( + suit=CardSuit.objects.first(), + rank=ten + ) + + self.table_card.defense_card = defense_card + self.table_card.save() + + move2 = Move.objects.create( + turn=self.turn, + table_card=self.table_card, + action_type='defend' + ) + + moves = Move.get_game_moves(self.game) + self.assertEqual(moves.count(), 2) + + def test_get_player_moves_method(self): + """Test get_player_moves() returns moves for specific player.""" + user2 = User.objects.create_user(username="player2", password="test") + + turn2 = Turn.objects.create( + game=self.game, + player=user2, + turn_number=2 + ) + + spades = CardSuit.objects.create(name="Spades", color="black") + king = CardRank.objects.create(name="King", value=13) + card2 = Card.objects.create(suit=spades, rank=king) + + table_card2 = TableCard.objects.create( + game=self.game, + attack_card=card2 + ) + + Move.objects.create( + turn=turn2, + table_card=table_card2, + action_type='attack' + ) + + # Get moves for user1 + user1_moves = Move.get_player_moves(self.game, self.user) + self.assertEqual(user1_moves.count(), 1) + self.assertEqual(user1_moves.first().get_player(), self.user) + + # Get moves for user2 + user2_moves = Move.get_player_moves(self.game, user2) + self.assertEqual(user2_moves.count(), 1) + self.assertEqual(user2_moves.first().get_player(), user2) + + def test_move_ordering(self): + """Test that moves are ordered by creation time.""" + # Create another move slightly later + ten = CardRank.objects.create(name="Ten", value=10) + defense_card = Card.objects.create( + suit=CardSuit.objects.first(), + rank=ten + ) + + self.table_card.defense_card = defense_card + self.table_card.save() + + move2 = Move.objects.create( + turn=self.turn, + table_card=self.table_card, + action_type='defend' + ) + + moves = list(Move.objects.filter(turn=self.turn)) + self.assertEqual(moves[0], self.move) + self.assertEqual(moves[1], move2) + + def test_action_type_choices(self): + """Test all action type choices are valid.""" + for action_type, _ in Move.ACTION_CHOICES: + move = Move.objects.create( + turn=self.turn, + table_card=self.table_card, + action_type=action_type + ) + self.assertEqual(move.action_type, action_type) From 24eb1f1a3cacf58637b7aaad0a119eb4a148b436 Mon Sep 17 00:00:00 2001 From: Viton8 Date: Tue, 21 Oct 2025 14:11:10 +0300 Subject: [PATCH 08/38] User Authentication and test for all scenarios --- Fools_Arena/urls.py | 8 +- accounts/api_urls.py | 9 ++ accounts/api_views.py | 37 ++++++ accounts/forms.py | 13 ++ accounts/serializers.py | 33 +++++ accounts/templates/accounts/login.html | 7 ++ accounts/templates/accounts/profile.html | 7 ++ accounts/templates/accounts/registration.html | 7 ++ accounts/tests/__init__.py | 0 accounts/tests/test_auth.py | 116 ++++++++++++++++++ accounts/urls.py | 9 ++ accounts/views.py | 39 ++++++ 12 files changed, 284 insertions(+), 1 deletion(-) create mode 100644 accounts/api_urls.py create mode 100644 accounts/api_views.py create mode 100644 accounts/forms.py create mode 100644 accounts/serializers.py create mode 100644 accounts/templates/accounts/login.html create mode 100644 accounts/templates/accounts/profile.html create mode 100644 accounts/templates/accounts/registration.html create mode 100644 accounts/tests/__init__.py create mode 100644 accounts/tests/test_auth.py create mode 100644 accounts/urls.py diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index 653613e..7880069 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -16,8 +16,14 @@ """ from django.contrib import admin -from django.urls import path +from django.urls import path, include urlpatterns = [ path("admin/", admin.site.urls), +# UI (шаблоны) + path('accounts/', include('accounts.urls')), + + # API + path('api/', include('accounts.api_urls')), + ] diff --git a/accounts/api_urls.py b/accounts/api_urls.py new file mode 100644 index 0000000..6b3f3ea --- /dev/null +++ b/accounts/api_urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI + +urlpatterns = [ + path('auth/register/', RegistrationAPI.as_view(), name='api_register'), + path('auth/login/', LoginAPI.as_view(), name='api_login'), + path('auth/profile/', ProfileAPI.as_view(), name='api_profile'), + path('auth/logout/', LogoutAPI.as_view(), name='api_logout'), +] diff --git a/accounts/api_views.py b/accounts/api_views.py new file mode 100644 index 0000000..578ee45 --- /dev/null +++ b/accounts/api_views.py @@ -0,0 +1,37 @@ +from django.contrib.auth import login as auth_login, logout as auth_logout +from rest_framework import generics, permissions, status +from rest_framework.response import Response +from rest_framework.views import APIView +from .serializers import RegistrationSerializer, LoginSerializer, ProfileSerializer + +class RegistrationAPI(generics.CreateAPIView): + serializer_class = RegistrationSerializer + permission_classes = [permissions.AllowAny] + + def perform_create(self, serializer): + user = serializer.save() + auth_login(self.request, user) + +class LoginAPI(APIView): + permission_classes = [permissions.AllowAny] + + def post(self, request): + serializer = LoginSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + user = serializer.validated_data['user'] + auth_login(request, user) + return Response(ProfileSerializer(user).data) + +class ProfileAPI(generics.RetrieveAPIView): + serializer_class = ProfileSerializer + permission_classes = [permissions.IsAuthenticated] # <-- добавляем + + def get_object(self): + return self.request.user + +class LogoutAPI(APIView): + permission_classes = [permissions.IsAuthenticated] + + def post(self, request): + auth_logout(request) + return Response({'detail': 'You are out of the system'}, status=status.HTTP_200_OK) diff --git a/accounts/forms.py b/accounts/forms.py new file mode 100644 index 0000000..64d4f20 --- /dev/null +++ b/accounts/forms.py @@ -0,0 +1,13 @@ +from django import forms +from django.contrib.auth.forms import UserCreationForm, AuthenticationForm +from django.contrib.auth.models import User + +class RegistrationForm(UserCreationForm): + email = forms.EmailField(required=True) + + class Meta: + model = User + fields = ('username', 'email', 'password1', 'password2') + +class LoginForm(AuthenticationForm): + pass diff --git a/accounts/serializers.py b/accounts/serializers.py new file mode 100644 index 0000000..b9becfd --- /dev/null +++ b/accounts/serializers.py @@ -0,0 +1,33 @@ +from django.contrib.auth.models import User +from django.contrib.auth import authenticate +from rest_framework import serializers + +class RegistrationSerializer(serializers.ModelSerializer): + password = serializers.CharField(write_only=True, min_length=8) + + class Meta: + model = User + fields = ('username', 'email', 'password') + + def create(self, validated_data): + return User.objects.create_user( + username=validated_data['username'], + email=validated_data.get('email', ''), + password=validated_data['password'], + ) + +class LoginSerializer(serializers.Serializer): + username = serializers.CharField() + password = serializers.CharField(write_only=True) + + def validate(self, attrs): + user = authenticate(username=attrs['username'], password=attrs['password']) + if not user: + raise serializers.ValidationError('Incorrect login details') + attrs['user'] = user + return attrs + +class ProfileSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ('id', 'username', 'email') diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html new file mode 100644 index 0000000..9ce547f --- /dev/null +++ b/accounts/templates/accounts/login.html @@ -0,0 +1,7 @@ +

Extrance

+
+ {% csrf_token %} + {{ form.as_p }} + +
+Registration diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html new file mode 100644 index 0000000..88ba517 --- /dev/null +++ b/accounts/templates/accounts/profile.html @@ -0,0 +1,7 @@ +

Profile

+

User name: {{ user.username }}

+

Email: {{ user.email }}

+
+ {% csrf_token %} + +
diff --git a/accounts/templates/accounts/registration.html b/accounts/templates/accounts/registration.html new file mode 100644 index 0000000..dfba916 --- /dev/null +++ b/accounts/templates/accounts/registration.html @@ -0,0 +1,7 @@ +

Registration

+
+ {% csrf_token %} + {{ form.as_p }} + +
+Already exist? Login diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py new file mode 100644 index 0000000..ea30f69 --- /dev/null +++ b/accounts/tests/test_auth.py @@ -0,0 +1,116 @@ +from django.contrib.auth.models import User +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient + + +class TemplateAuthTests(TestCase): + """Тесты для шаблонных (UI) вьюх""" + + def test_register_valid(self): + resp = self.client.post(reverse('register'), { + 'username': 'maksim', + 'email': 'm@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass123', + }) + self.assertRedirects(resp, reverse('profile')) + self.assertTrue(User.objects.filter(username='maksim').exists()) + + def test_register_invalid_password_mismatch(self): + resp = self.client.post(reverse('register'), { + 'username': 'bad', + 'email': 'b@example.com', + 'password1': 'StrongPass123', + 'password2': 'StrongPass124', + }) + self.assertEqual(resp.status_code, 200) + self.assertFalse(User.objects.filter(username='bad').exists()) + + def test_login_valid(self): + User.objects.create_user('u', 'u@example.com', 'p@55word!') + resp = self.client.post(reverse('login'), { + 'username': 'u', + 'password': 'p@55word!' + }) + self.assertRedirects(resp, reverse('profile')) + + def test_login_invalid(self): + resp = self.client.post(reverse('login'), { + 'username': 'nope', + 'password': 'wrong' + }) + self.assertEqual(resp.status_code, 200) + + def test_profile_requires_authentication(self): + resp = self.client.get(reverse('profile')) + self.assertEqual(resp.status_code, 302) # редирект на login + + def test_logout(self): + User.objects.create_user('u', 'u@example.com', 'p@55word!') + self.client.post(reverse('login'), {'username': 'u', 'password': 'p@55word!'}) + resp = self.client.post(reverse('logout')) + self.assertRedirects(resp, reverse('login')) + + +class APIAuthTests(TestCase): + """Тесты для API эндпоинтов""" + + def setUp(self): + self.client = APIClient() + + def test_api_register_valid(self): + resp = self.client.post('/api/auth/register/', { + 'username': 'maksim_api', + 'email': 'mapi@example.com', + 'password': 'StrongPass123', + }, format='json') + self.assertEqual(resp.status_code, 201) + self.assertTrue(User.objects.filter(username='maksim_api').exists()) + + def test_api_register_invalid(self): + resp = self.client.post('/api/auth/register/', { + 'username': '', + 'email': 'bad', + 'password': 'short', + }, format='json') + self.assertEqual(resp.status_code, 400) + + def test_api_login_valid(self): + User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') + resp = self.client.post('/api/auth/login/', { + 'username': 'uapi', + 'password': 'p@55word!' + }, format='json') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['username'], 'uapi') + + def test_api_login_invalid(self): + resp = self.client.post('/api/auth/login/', { + 'username': 'nope', + 'password': 'wrong' + }, format='json') + self.assertEqual(resp.status_code, 400) + + def test_api_profile_authenticated(self): + User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') + self.client.post('/api/auth/login/', { + 'username': 'uapi', + 'password': 'p@55word!' + }, format='json') + resp = self.client.get('/api/auth/profile/') + self.assertEqual(resp.status_code, 200) + self.assertEqual(resp.data['username'], 'uapi') + + def test_api_profile_unauthenticated(self): + resp = self.client.get('/api/auth/profile/') + self.assertEqual(resp.status_code, 403) + + def test_api_logout(self): + User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') + self.client.post('/api/auth/login/', { + 'username': 'uapi', + 'password': 'p@55word!' + }, format='json') + resp = self.client.post('/api/auth/logout/') + self.assertEqual(resp.status_code, 200) diff --git a/accounts/urls.py b/accounts/urls.py new file mode 100644 index 0000000..5f5feeb --- /dev/null +++ b/accounts/urls.py @@ -0,0 +1,9 @@ +from django.urls import path +from .views import register_view, login_view, profile_view, logout_view + +urlpatterns = [ + path('register/', register_view, name='register'), + path('login/', login_view, name='login'), + path('profile/', profile_view, name='profile'), + path('logout/', logout_view, name='logout'), +] diff --git a/accounts/views.py b/accounts/views.py index 91ea44a..fa84445 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,42 @@ from django.shortcuts import render # Create your views here. +from django.contrib.auth import login as auth_login, logout as auth_logout +from django.contrib.auth.decorators import login_required +from django.shortcuts import render, redirect +from django.views.decorators.csrf import csrf_protect +from .forms import RegistrationForm, LoginForm + +@csrf_protect +def register_view(request): + if request.method == 'POST': + form = RegistrationForm(request.POST) + if form.is_valid(): + user = form.save() + auth_login(request, user) + return redirect('profile') + else: + form = RegistrationForm() + return render(request, 'accounts/registration.html', {'form': form}) + +@csrf_protect +def login_view(request): + if request.method == 'POST': + form = LoginForm(request, data=request.POST) + if form.is_valid(): + auth_login(request, form.get_user()) + return redirect('profile') + else: + form = LoginForm() + return render(request, 'accounts/login.html', {'form': form}) + +@login_required +def profile_view(request): + return render(request, 'accounts/profile.html') + +@csrf_protect +def logout_view(request): + if request.method == 'POST': + auth_logout(request) + return redirect('login') + return redirect('profile') From 1a2f7c1ef9f0994d3bf168f5e715d6a89a5a54fb Mon Sep 17 00:00:00 2001 From: Viton8 Date: Fri, 24 Oct 2025 19:21:20 +0300 Subject: [PATCH 09/38] Added some comments --- Fools_Arena/urls.py | 2 +- accounts/api_views.py | 10 +++++++++- accounts/forms.py | 2 ++ accounts/serializers.py | 7 +++++++ accounts/tests/test_auth.py | 20 +++++++++++++++++--- accounts/views.py | 4 ++++ 6 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index 7880069..21ea9bc 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -20,7 +20,7 @@ urlpatterns = [ path("admin/", admin.site.urls), -# UI (шаблоны) +# UI path('accounts/', include('accounts.urls')), # API diff --git a/accounts/api_views.py b/accounts/api_views.py index 578ee45..a025b9d 100644 --- a/accounts/api_views.py +++ b/accounts/api_views.py @@ -5,17 +5,21 @@ from .serializers import RegistrationSerializer, LoginSerializer, ProfileSerializer class RegistrationAPI(generics.CreateAPIView): + """API endpoint for user registration.""" serializer_class = RegistrationSerializer permission_classes = [permissions.AllowAny] def perform_create(self, serializer): + """Create a new user and log them in automatically.""" user = serializer.save() auth_login(self.request, user) class LoginAPI(APIView): + """API endpoint for user login.""" permission_classes = [permissions.AllowAny] def post(self, request): + """Authenticate user credentials and start a session.""" serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] @@ -23,15 +27,19 @@ def post(self, request): return Response(ProfileSerializer(user).data) class ProfileAPI(generics.RetrieveAPIView): + """API endpoint for retrieving the authenticated user's profile.""" serializer_class = ProfileSerializer - permission_classes = [permissions.IsAuthenticated] # <-- добавляем + permission_classes = [permissions.IsAuthenticated] def get_object(self): + """Return the current authenticated user.""" return self.request.user class LogoutAPI(APIView): + """API endpoint for logging out the current user.""" permission_classes = [permissions.IsAuthenticated] def post(self, request): + """End the current user session.""" auth_logout(request) return Response({'detail': 'You are out of the system'}, status=status.HTTP_200_OK) diff --git a/accounts/forms.py b/accounts/forms.py index 64d4f20..ae7981d 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -3,6 +3,7 @@ from django.contrib.auth.models import User class RegistrationForm(UserCreationForm): + """Form for user registration with email field included.""" email = forms.EmailField(required=True) class Meta: @@ -10,4 +11,5 @@ class Meta: fields = ('username', 'email', 'password1', 'password2') class LoginForm(AuthenticationForm): + """Form for user login using Django's built-in authentication.""" pass diff --git a/accounts/serializers.py b/accounts/serializers.py index b9becfd..f4a89f1 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -3,6 +3,8 @@ from rest_framework import serializers class RegistrationSerializer(serializers.ModelSerializer): + # Serializer for user registration + # Validates and creates a new user instance password = serializers.CharField(write_only=True, min_length=8) class Meta: @@ -10,6 +12,7 @@ class Meta: fields = ('username', 'email', 'password') def create(self, validated_data): + # Creates a new user with encrypted password return User.objects.create_user( username=validated_data['username'], email=validated_data.get('email', ''), @@ -17,10 +20,13 @@ def create(self, validated_data): ) class LoginSerializer(serializers.Serializer): + # Serializer for user login + # Authenticates user credentials username = serializers.CharField() password = serializers.CharField(write_only=True) def validate(self, attrs): + # Validates the provided username and password user = authenticate(username=attrs['username'], password=attrs['password']) if not user: raise serializers.ValidationError('Incorrect login details') @@ -28,6 +34,7 @@ def validate(self, attrs): return attrs class ProfileSerializer(serializers.ModelSerializer): + # Serializer for displaying user profile data class Meta: model = User fields = ('id', 'username', 'email') diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index ea30f69..0425052 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -5,9 +5,10 @@ class TemplateAuthTests(TestCase): - """Тесты для шаблонных (UI) вьюх""" + """UI-based authentication tests.""" def test_register_valid(self): + """User can register with valid data.""" resp = self.client.post(reverse('register'), { 'username': 'maksim', 'email': 'm@example.com', @@ -18,6 +19,7 @@ def test_register_valid(self): self.assertTrue(User.objects.filter(username='maksim').exists()) def test_register_invalid_password_mismatch(self): + """Registration fails when passwords do not match.""" resp = self.client.post(reverse('register'), { 'username': 'bad', 'email': 'b@example.com', @@ -28,6 +30,7 @@ def test_register_invalid_password_mismatch(self): self.assertFalse(User.objects.filter(username='bad').exists()) def test_login_valid(self): + """User can log in with correct credentials.""" User.objects.create_user('u', 'u@example.com', 'p@55word!') resp = self.client.post(reverse('login'), { 'username': 'u', @@ -36,6 +39,7 @@ def test_login_valid(self): self.assertRedirects(resp, reverse('profile')) def test_login_invalid(self): + """Login fails with invalid credentials.""" resp = self.client.post(reverse('login'), { 'username': 'nope', 'password': 'wrong' @@ -43,10 +47,12 @@ def test_login_invalid(self): self.assertEqual(resp.status_code, 200) def test_profile_requires_authentication(self): + """Profile page redirects unauthenticated users to login.""" resp = self.client.get(reverse('profile')) - self.assertEqual(resp.status_code, 302) # редирект на login + self.assertEqual(resp.status_code, 302) # redirect to login def test_logout(self): + """User can log out and is redirected to login page.""" User.objects.create_user('u', 'u@example.com', 'p@55word!') self.client.post(reverse('login'), {'username': 'u', 'password': 'p@55word!'}) resp = self.client.post(reverse('logout')) @@ -54,12 +60,14 @@ def test_logout(self): class APIAuthTests(TestCase): - """Тесты для API эндпоинтов""" + """API authentication endpoint tests.""" def setUp(self): + """Initialize API client before each test.""" self.client = APIClient() def test_api_register_valid(self): + """API: register user with valid data.""" resp = self.client.post('/api/auth/register/', { 'username': 'maksim_api', 'email': 'mapi@example.com', @@ -69,6 +77,7 @@ def test_api_register_valid(self): self.assertTrue(User.objects.filter(username='maksim_api').exists()) def test_api_register_invalid(self): + """API: registration fails with invalid data.""" resp = self.client.post('/api/auth/register/', { 'username': '', 'email': 'bad', @@ -77,6 +86,7 @@ def test_api_register_invalid(self): self.assertEqual(resp.status_code, 400) def test_api_login_valid(self): + """API: user can log in with correct credentials.""" User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') resp = self.client.post('/api/auth/login/', { 'username': 'uapi', @@ -86,6 +96,7 @@ def test_api_login_valid(self): self.assertEqual(resp.data['username'], 'uapi') def test_api_login_invalid(self): + """API: login fails with invalid credentials.""" resp = self.client.post('/api/auth/login/', { 'username': 'nope', 'password': 'wrong' @@ -93,6 +104,7 @@ def test_api_login_invalid(self): self.assertEqual(resp.status_code, 400) def test_api_profile_authenticated(self): + """API: authenticated user can access their profile.""" User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') self.client.post('/api/auth/login/', { 'username': 'uapi', @@ -103,10 +115,12 @@ def test_api_profile_authenticated(self): self.assertEqual(resp.data['username'], 'uapi') def test_api_profile_unauthenticated(self): + """API: unauthenticated user cannot access profile.""" resp = self.client.get('/api/auth/profile/') self.assertEqual(resp.status_code, 403) def test_api_logout(self): + """API: user can log out successfully.""" User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') self.client.post('/api/auth/login/', { 'username': 'uapi', diff --git a/accounts/views.py b/accounts/views.py index fa84445..f415a63 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -9,6 +9,7 @@ @csrf_protect def register_view(request): + """Register a new user and log them in.""" if request.method == 'POST': form = RegistrationForm(request.POST) if form.is_valid(): @@ -21,6 +22,7 @@ def register_view(request): @csrf_protect def login_view(request): + """Authenticate and log in an existing user.""" if request.method == 'POST': form = LoginForm(request, data=request.POST) if form.is_valid(): @@ -32,10 +34,12 @@ def login_view(request): @login_required def profile_view(request): + """Display the authenticated user's profile.""" return render(request, 'accounts/profile.html') @csrf_protect def logout_view(request): + """Log out the current user.""" if request.method == 'POST': auth_logout(request) return redirect('login') From 130b6efc8ca41f05494d284f963fd481d4b61bb2 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sat, 25 Oct 2025 11:00:00 +0200 Subject: [PATCH 10/38] PR: requested changes Empty line added at the end of accounts/tests.py --- accounts/tests.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/accounts/tests.py b/accounts/tests.py index d5372a3..3d68a4f 100644 --- a/accounts/tests.py +++ b/accounts/tests.py @@ -272,14 +272,14 @@ def test_can_join_lobby_closed(self): name="Closed Lobby", status='closed' ) - + # Create settings for the lobby LobbySettings.objects.create( lobby=lobby, max_players=4, card_count=36 ) - + self.assertFalse(self.user.can_join_lobby(lobby)) def test_leave_current_lobby_success(self): @@ -533,4 +533,4 @@ def test_user_related_objects_accessible(self): self.assertIsNotNone(self.user.sent_messages) self.assertIsNotNone(self.user.received_messages) self.assertIsNotNone(self.user.lobby_set) - self.assertIsNotNone(self.user.lobbyplayer_set) \ No newline at end of file + self.assertIsNotNone(self.user.lobbyplayer_set) From 0c87313e4dbc400a9929be3ec0e6f286f4a37ca6 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:10:02 +0300 Subject: [PATCH 11/38] Added generate_test_users command. --- .../commands/generate_test_users.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 accounts/management/commands/generate_test_users.py diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py new file mode 100644 index 0000000..0778399 --- /dev/null +++ b/accounts/management/commands/generate_test_users.py @@ -0,0 +1,226 @@ +""" +generate_test_users management command. + +Creates one or more test users for development/testing. + +Example: + # create 5 regular test users with default password + python manage.py generate_test_users --count 5 --prefix testuser + + # create 3 staff users with a custom email domain and password + python manage.py generate_test_users -c 3 --prefix staff_ --email-domain example.org --password secret123 --staff + + # create 1 superuser + python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass + +Google style docstrings are used in the class and methods. +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +import random +import string +from typing import List + +User = get_user_model() + + +def _random_suffix(length: int = 4) -> str: + """Return a short random alphanumeric suffix. + + Args: + length: Length of the suffix. + + Returns: + A random string composed of lowercase letters and digits. + """ + chars = string.ascii_lowercase + string.digits + return ''.join(random.choice(chars) for _ in range(length)) + + +class Command(BaseCommand): + """Django command to generate test users. + + The command creates users using `User.objects.create_user()` (or + `create_superuser()` when the `--superuser` flag is used). It will skip + usernames that already exist unless `--force` is provided, in which case + a short random suffix is appended to the username. + + Methods + ------- + add_arguments(parser): + Add command-line arguments. + handle(*args, **options): + Main entry point that creates users according to parsed options. + """ + + help = "Generate test users (regular, staff, or superuser) for development." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: The argparse parser to configure. + """ + parser.add_argument( + '--count', '-c', + type=int, + default=1, + help='Number of users to create (default: 1)' + ) + parser.add_argument( + '--prefix', '-p', + type=str, + default='testuser', + help='Prefix for usernames (default: "testuser")' + ) + parser.add_argument( + '--start', + type=int, + default=1, + help='Starting index appended to username (default: 1)' + ) + parser.add_argument( + '--email-domain', + type=str, + default='example.com', + help='Email domain to use for generated users (default: example.com)' + ) + parser.add_argument( + '--password', + type=str, + default='test_password', + help='Password to set for all created users (default: "test_password")' + ) + parser.add_argument( + '--staff', + action='store_true', + help='Mark created users as staff (is_staff=True)' + ) + parser.add_argument( + '--superuser', + action='store_true', + help='Create superuser(s) (uses create_superuser)' + ) + parser.add_argument( + '--inactive', + action='store_true', + help='Create users with is_active=False' + ) + parser.add_argument( + '--force', + action='store_true', + help='If username exists, append random suffix and create anyway' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Print what would be created without saving to the database' + ) + + def _make_username(self, prefix: str, idx: int) -> str: + """Construct a username from prefix and index. + + Args: + prefix: Username prefix. + idx: Index to append. + + Returns: + The composed username (e.g. "prefix1"). + """ + return f"{prefix}{idx}" + + def _email_for_username(self, username: str, domain: str) -> str: + """Construct an email address for a username. + + Args: + username: The username to use before the @. + domain: The domain to use after the @. + + Returns: + A complete email address string. + """ + return f"{username}@{domain}" + + def handle(self, *args, **options): + """Create the requested number of test users. + + Args: + *args: positional args (unused). + **options: Parsed command-line options. + + Returns: + None + """ + count: int = options['count'] + prefix: str = options['prefix'] + start: int = options['start'] + email_domain: str = options['email_domain'] + password: str = options['password'] + make_staff: bool = options['staff'] + make_superuser: bool = options['superuser'] + inactive: bool = options['inactive'] + force: bool = options['force'] + dry_run: bool = options['dry_run'] + + created: List[User] = [] + + # loop to create the requested number of users + for i in range(start, start + count): + username = self._make_username(prefix, i) + email = self._email_for_username(username, email_domain) + + # If the username already exists and --force is not used, skip it. + if User.objects.filter(username=username).exists(): + if not force: + self.stdout.write(self.style.WARNING( + f"Skipping existing username: {username}" + )) + continue + + # If force mode, append a short random suffix to make it unique. + username = f"{username}_{_random_suffix()}" + email = self._email_for_username(username, email_domain) + self.stdout.write(self.style.NOTICE( + f"Username existed; using fallback username: {username}" + )) + + # Dry-run prints and does not save to DB. + if dry_run: + self.stdout.write(f"[DRY RUN] Would create: username='{username}', email='{email}', " + f"is_staff={make_staff}, is_superuser={make_superuser}, is_active={not inactive}") + continue + + # Create a superuser if requested (this calls create_superuser which + # typically sets is_staff/is_superuser automatically). + if make_superuser: + # create_superuser signature: (username, email=None, password=None, **extra_fields) + user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] + # Ensure flags align with requested options (some custom user models + # might require setting them explicitly). + user.is_staff = True + user.is_superuser = True + else: + # Regular user creation (hashes the password) + user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] + user.is_staff = bool(make_staff) + user.is_superuser = False + + # Set active state based on --inactive + user.is_active = not bool(inactive) + + # Save changes (if any) and collect result. + user.save() + created.append(user) + + # Informational output for each created user. + self.stdout.write(self.style.SUCCESS( + f"Created user: username='{username}' email='{email}' " + f"{'(staff)' if user.is_staff else ''} {'(superuser)' if user.is_superuser else ''}" + )) + + # Summary output + if dry_run: + self.stdout.write(self.style.WARNING("Dry run complete. No users were created.")) + else: + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) From 1dc79062789a8f38a523a4678ab759c1e3056cf5 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:13:07 +0300 Subject: [PATCH 12/38] Removed unnecessary comment line. --- accounts/management/commands/generate_test_users.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 0778399..6fe0a9f 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -12,8 +12,6 @@ # create 1 superuser python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass - -Google style docstrings are used in the class and methods. """ from django.core.management.base import BaseCommand From 8b9d816311c0066ffc4f09ac9b50245e712ff91d Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sun, 26 Oct 2025 15:14:12 +0300 Subject: [PATCH 13/38] Added __init__ in every managment/commands folder so pyhton whould understand what this is a commands, Added export_db, import_db commands. --- accounts/management/__init__.py | 0 accounts/management/commands/__init__.py | 0 chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 game/management/__init__.py | 0 game/management/commands/__init__.py | 0 game/management/commands/export_db.py | 252 +++++++++++++++++++++++ game/management/commands/import_db.py | 213 +++++++++++++++++++ 8 files changed, 465 insertions(+) create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 game/management/__init__.py create mode 100644 game/management/commands/__init__.py create mode 100644 game/management/commands/export_db.py create mode 100644 game/management/commands/import_db.py diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/__init__.py b/game/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py new file mode 100644 index 0000000..7686253 --- /dev/null +++ b/game/management/commands/export_db.py @@ -0,0 +1,252 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.apps import apps +from django.conf import settings +from django.core import serializers + + +EXCLUDE_MODEL_NAMES = { + "ContentType", + "Session", +} + + +def resolve_output_path(path: str) -> str: + """Resolve a possibly-relative output path against BASE_DIR. + + Args: + path: Output file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + return path + + +class Command(BaseCommand): + """Export database rows to a JSON file (Django serialization) with optional apps filter. + + This command streams model instances to a JSON array in chunks (to avoid + building one huge list in memory). Use ``--apps`` to limit exported objects + to models that belong to a set of app labels (comma-separated). Use + ``--indent N`` to pretty-print multi-line JSON. + + Example usage: + # Default: writes db_backups/backup.json under BASE_DIR + python manage.py export_db + + # Pretty-printed, only export 'game' and 'auth' apps + python manage.py export_db --apps game,auth --indent 2 + + # Gzipped output inside project + python manage.py export_db -o db_backups/backup.json.gz --indent 2 + """ + + help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "--output", + "-o", + help=( + "Output file path (relative paths are created inside BASE_DIR). " + "Use '-' for stdout. Use .gz to gzip." + ), + default="db_backups/backup.json", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to export (e.g. 'auth,game'). If omitted exports all apps.", + default=None, + ) + parser.add_argument( + "--exclude", + help="Comma-separated model names to exclude (ModelName or app_label.ModelName).", + default="", + ) + parser.add_argument( + "--indent", + type=int, + default=None, + help="JSON indent level (pass an integer). If omitted output is compact (single line).", + ) + parser.add_argument( + "--natural-foreign", + action="store_true", + help="Use natural foreign keys when serializing (if supported by models).", + ) + parser.add_argument( + "--natural-primary", + action="store_true", + help="Use natural primary keys when serializing (if supported).", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=1000, + help="Number of objects to serialize per chunk (memory / performance tuning).", + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Collect models to export (apply --apps and --exclude filters). + 2. Stream objects per-model and per-chunk, serializing each chunk and + writing the inner JSON to the output file/stream. + 3. Produce pretty JSON when --indent is provided. + """ + output = options["output"] + apps_arg = options["apps"] + exclude_arg = options["exclude"] + indent = options["indent"] + use_nat_foreign = options["natural_foreign"] + use_nat_primary = options["natural_primary"] + chunk_size = options["chunk_size"] + + apps_filter = self._parse_apps_arg(apps_arg) + exclude_set = set(x.strip() for x in exclude_arg.split(",") if x.strip()) + + # Collect models, applying app & exclude filters. + all_models = list(apps.get_models()) + models_to_export: List[type] = [] + for m in all_models: + full_name = f"{m._meta.app_label}.{m.__name__}" + if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: + # skip common internal models or anything explicitly excluded + continue + if apps_filter is not None and m._meta.app_label not in apps_filter: + # skip models that are not in the requested apps + continue + models_to_export.append(m) + + if not models_to_export: + raise CommandError("No models found to export (check --apps and --exclude).") + + # Determine output destination + write_to_stdout = (output == "-") + if not write_to_stdout: + output = resolve_output_path(output) + + # Open output (gz or plain file) or use stdout + if write_to_stdout: + write_chunk = lambda s: self.stdout.write(s) + close_fh = lambda: None + else: + if output.endswith(".gz"): + fh = gzip.open(output, "wt", encoding="utf-8") + else: + fh = open(output, "w", encoding="utf-8") + write_chunk = fh.write + close_fh = fh.close + + total_objects = 0 + first_piece = True + + try: + # Prepare array formatting depending on pretty vs compact + if indent is not None: + write_chunk("[\n") + separator = ",\n" + closing = "\n]\n" + else: + write_chunk("[") + separator = "," + closing = "]" + + # Stream per model to limit memory usage + for model in models_to_export: + qs = model._default_manager.all().iterator() + chunk: List[object] = [] + for obj in qs: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + # Extract the JSON inner array content (strip leading/trailing [ ]) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + # Trim surrounding newlines for nicer concatenation + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] + + # Flush any remaining objects for this model + if chunk: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + + # Close JSON array + write_chunk(closing) + finally: + close_fh() + + self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output}")) diff --git a/game/management/commands/import_db.py b/game/management/commands/import_db.py new file mode 100644 index 0000000..2b5d83f --- /dev/null +++ b/game/management/commands/import_db.py @@ -0,0 +1,213 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.core import serializers +from django.db import transaction, IntegrityError, connection +from django.core.management import call_command +from django.conf import settings +from django.apps import apps +from django.core.management.color import no_style + + +def resolve_input_path(path: str) -> str: + """Resolve a possibly-relative input path against BASE_DIR. + + Args: + path: Input file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + return path + + +class Command(BaseCommand): + """Import JSON exported by export_db (Django serialization) with optional app filter. + + The command accepts a Django-serialized JSON array (optionally gzipped) and + deserializes objects into the database. Use `--apps` to restrict import to + objects belonging to a set of app labels (comma-separated). When PostgreSQL + is detected (or `--reset-sequences` is passed) the command will attempt to + reset DB sequences — by default for all models, or only for the selected apps + when `--apps` is used. + + Example usages: + # Import everything + python manage.py import_db db_backups/backup.json + + # Import gzipped file and continue past errors + python manage.py import_db db_backups/backup.json.gz --ignore-errors + + # Import only objects for 'game' and 'auth' apps + python manage.py import_db db_backups/backup.json --apps game,auth + + # Clear DB first (dangerous) + python manage.py import_db db_backups/backup.json --clear + """ + + help = "Import JSON exported by export_db (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "input", + help="Input JSON file path (relative paths searched in BASE_DIR). Use '-' for stdin. Supports .gz.", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Clear the database via `flush --noinput` before importing. USE WITH CARE.", + ) + parser.add_argument( + "--ignore-errors", + action="store_true", + help="Try to continue past individual object errors (logs them).", + ) + parser.add_argument( + "--reset-sequences", + action="store_true", + help="Attempt to reset DB sequences after import (Postgres only). Default: on for Postgres.", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to import (e.g. 'auth,game'). If omitted imports all apps.", + default=None, + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + # strip whitespace and ignore empty pieces + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Resolve input path (or read stdin). + 2. Optionally flush DB (--clear). + 3. Read and deserialize JSON (supports .gz). + 4. Iterate deserialized objects, optionally filtering by --apps, saving each. + 5. Optionally reset sequences for Postgres (either all models or filtered models). + """ + input_path = options["input"] + do_clear = options["clear"] + ignore_errors = options["ignore_errors"] + reset_sequences_flag = options["reset_sequences"] + apps_arg = options["apps"] + + apps_filter = self._parse_apps_arg(apps_arg) + + # Resolve path if not stdin + read_from_stdin = (input_path == "-") + if not read_from_stdin: + input_path = resolve_input_path(input_path) + if not os.path.exists(input_path): + raise CommandError(f"Input file not found: {input_path}") + + # Optionally clear the DB (flush clears all data) + if do_clear: + self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) + call_command("flush", "--noinput") + + # Read input file / stdin + if read_from_stdin: + raw = self.stdin.read() + else: + if input_path.endswith(".gz"): + with gzip.open(input_path, "rt", encoding="utf-8") as fh: + raw = fh.read() + else: + with open(input_path, "r", encoding="utf-8") as fh: + raw = fh.read() + + if not raw.strip(): + raise CommandError("Input file is empty.") + + # Create a generator of deserialized objects. This does not eagerly load into a list. + try: + deserialized_iter = serializers.deserialize("json", raw) + except Exception as e: + raise CommandError(f"Failed to deserialize input JSON: {e}") + + saved = 0 + skipped = 0 + errors: List[str] = [] + + # Save objects inside a single atomic transaction. If --ignore-errors, continue past failing objects. + with transaction.atomic(): + for dobj in deserialized_iter: + # Determine the app label of the object's model + try: + obj_app_label = dobj.object._meta.app_label + except Exception: + # Defensive: if object lacks expected attributes, skip it + skipped += 1 + continue + + # If --apps filter is provided, skip objects not in that set + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + try: + # dobj.save() persists the object and handles m2m relationships + dobj.save() + saved += 1 + except IntegrityError as e: + msg = f"IntegrityError saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + # Re-raise to abort the transaction + raise + except Exception as e: + msg = f"Error saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + raise + + # Decide whether to reset sequences: + engine = connection.settings_dict.get("ENGINE", "") + is_postgres = "postgresql" in engine or connection.vendor == "postgresql" + do_reset = reset_sequences_flag or is_postgres + + if do_reset and is_postgres: + # Build model list: either all models or only models in selected apps + if apps_filter is None: + models_to_reset = list(apps.get_models()) + else: + models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] + + style = no_style() + try: + sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) + with connection.cursor() as cursor: + for sql in sql_list: + if sql.strip(): + cursor.execute(sql) + self.stdout.write(self.style.SUCCESS("Postgres sequences reset successfully.")) + except Exception as e: + err_msg = f"Failed to reset sequences: {e}" + self.stderr.write(self.style.ERROR(err_msg)) + errors.append(err_msg) + + # Summary output + self.stdout.write(self.style.SUCCESS(f"Imported {saved} objects.")) + if skipped: + self.stdout.write(self.style.WARNING(f"Skipped {skipped} objects (outside --apps or invalid).")) + if errors: + self.stdout.write(self.style.WARNING(f"{len(errors)} errors occurred during import. See stderr for details.")) From b6d4f14fcee66b21c0837676abf62c1cdfbef79c Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sun, 26 Oct 2025 14:26:09 +0100 Subject: [PATCH 14/38] Major changes in tests Project now uses pytest A lot of refactoring in all tests, pytest conffiguration, conftest.py for global fixtures All tests has been moved to tests\ folder in corresponding app and has been separated into different files by testing functionallity Readme.md, requirements.txt updates --- README.md | 8 +- accounts/tests.py | 536 ------- accounts/tests/__init__.py | 4 + accounts/tests/test_models.py | 83 ++ accounts/tests/test_user_game.py | 52 + accounts/tests/test_user_lobby.py | 116 ++ accounts/tests/test_user_statistics.py | 91 ++ chat/tests.py | 574 -------- chat/tests/__init__.py | 4 + chat/tests/test_models.py | 115 ++ chat/tests/test_queries.py | 90 ++ conftest.py | 359 +++++ game/models.py | 7 +- game/tests.py | 1859 ------------------------ game/tests/__init__.py | 5 + game/tests/test_card_models.py | 315 ++++ game/tests/test_deck_models.py | 153 ++ game/tests/test_game_models.py | 280 ++++ game/tests/test_lobby_models.py | 520 +++++++ game/tests/test_special_models.py | 107 ++ game/tests/test_turn_models.py | 114 ++ pytest.ini | 3 + requirements.txt | Bin 508 -> 554 bytes 23 files changed, 2420 insertions(+), 2975 deletions(-) delete mode 100644 accounts/tests.py create mode 100644 accounts/tests/__init__.py create mode 100644 accounts/tests/test_models.py create mode 100644 accounts/tests/test_user_game.py create mode 100644 accounts/tests/test_user_lobby.py create mode 100644 accounts/tests/test_user_statistics.py delete mode 100644 chat/tests.py create mode 100644 chat/tests/__init__.py create mode 100644 chat/tests/test_models.py create mode 100644 chat/tests/test_queries.py create mode 100644 conftest.py delete mode 100644 game/tests.py create mode 100644 game/tests/__init__.py create mode 100644 game/tests/test_card_models.py create mode 100644 game/tests/test_deck_models.py create mode 100644 game/tests/test_game_models.py create mode 100644 game/tests/test_lobby_models.py create mode 100644 game/tests/test_special_models.py create mode 100644 game/tests/test_turn_models.py create mode 100644 pytest.ini diff --git a/README.md b/README.md index 3b419a6..5973da0 100644 --- a/README.md +++ b/README.md @@ -41,14 +41,14 @@ docker-compose exec web python manage.py collectstatic ### 6. Work with Django All commands should be executed inside the web container. Examples: ```bash -docker-compose exec web python manage.py shell -docker-compose exec web python manage.py makemigrations -docker-compose exec web python manage.py test +docker compose exec web python manage.py shell +docker compose exec web python manage.py makemigrations +docker compose exec web pytest -v ``` ### 7. Stop containers ```bash -docker-compose down +docker compose down ``` --- diff --git a/accounts/tests.py b/accounts/tests.py deleted file mode 100644 index 3d68a4f..0000000 --- a/accounts/tests.py +++ /dev/null @@ -1,536 +0,0 @@ -from django.test import TestCase -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from game.models import Lobby, LobbyPlayer, LobbySettings, Game, GamePlayer, Card, CardSuit, CardRank - -User = get_user_model() - - -class UserModelTest(TestCase): - """Test suite for User model.""" - - def setUp(self): - """Set up test data for User tests.""" - self.user = User.objects.create_user( - username="testplayer", - email="test@example.com", - password="testpass123" - ) - - self.user_with_avatar = User.objects.create_user( - username="avataruser", - email="avatar@example.com", - password="testpass123", - avatar_url="https://example.com/avatar.jpg" - ) - - def test_user_creation(self): - """Test that User instances are created correctly.""" - self.assertEqual(self.user.username, "testplayer") - self.assertEqual(self.user.email, "test@example.com") - self.assertTrue(self.user.check_password("testpass123")) - - def test_user_uuid_generation(self): - """Test that UUID is automatically generated for users.""" - self.assertIsNotNone(self.user.id) - # UUID should be a valid UUID4 - self.assertEqual(len(str(self.user.id)), 36) - - def test_user_str_representation(self): - """Test string representation of User.""" - self.assertEqual(str(self.user), "testplayer") - - def test_user_created_at_auto_generation(self): - """Test that created_at timestamp is automatically set.""" - self.assertIsNotNone(self.user.created_at) - - def test_get_full_display_name_with_full_name(self): - """Test get_full_display_name() returns full name when available.""" - self.user.first_name = "John" - self.user.last_name = "Doe" - self.user.save() - - self.assertEqual(self.user.get_full_display_name(), "John Doe") - - def test_get_full_display_name_without_full_name(self): - """Test get_full_display_name() falls back to username.""" - self.assertEqual(self.user.get_full_display_name(), "testplayer") - - def test_get_full_display_name_with_partial_name(self): - """Test get_full_display_name() with only first name.""" - self.user.first_name = "John" - self.user.save() - - self.assertEqual(self.user.get_full_display_name(), "John") - - def test_has_avatar_true(self): - """Test has_avatar() returns True when avatar is set.""" - self.assertTrue(self.user_with_avatar.has_avatar()) - - def test_has_avatar_false(self): - """Test has_avatar() returns False when avatar is not set.""" - self.assertFalse(self.user.has_avatar()) - - def test_has_avatar_empty_string(self): - """Test has_avatar() returns False for empty string.""" - self.user.avatar_url = "" - self.user.save() - - self.assertFalse(self.user.has_avatar()) - - def test_get_active_lobby_no_lobby(self): - """Test get_active_lobby() returns None when user is not in a lobby.""" - self.assertIsNone(self.user.get_active_lobby()) - - def test_get_active_lobby_waiting_status(self): - """Test get_active_lobby() returns lobby when user is waiting.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='waiting' - ) - - self.assertEqual(self.user.get_active_lobby(), lobby) - - def test_get_active_lobby_ready_status(self): - """Test get_active_lobby() returns lobby when user is ready.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='ready' - ) - - self.assertEqual(self.user.get_active_lobby(), lobby) - - def test_get_active_lobby_playing_status(self): - """Test get_active_lobby() returns lobby when user is playing.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='playing' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='playing' - ) - - self.assertEqual(self.user.get_active_lobby(), lobby) - - def test_get_active_lobby_left_status(self): - """Test get_active_lobby() returns None when user has left.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='left' - ) - - self.assertIsNone(self.user.get_active_lobby()) - - def test_get_current_game_no_game(self): - """Test get_current_game() returns None when user is not in a game.""" - self.assertIsNone(self.user.get_current_game()) - - def test_get_current_game_active_game(self): - """Test get_current_game() returns game when user is playing.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Game Lobby", - status='playing' - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - GamePlayer.objects.create( - game=game, - user=self.user, - seat_position=1, - cards_remaining=6 - ) - - self.assertEqual(self.user.get_current_game(), game) - - def test_get_current_game_finished_game(self): - """Test get_current_game() returns None for finished games.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Game Lobby", - status='playing' - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='finished' - ) - - GamePlayer.objects.create( - game=game, - user=self.user, - seat_position=1, - cards_remaining=0 - ) - - self.assertIsNone(self.user.get_current_game()) - - def test_can_join_lobby_success(self): - """Test can_join_lobby() returns True for valid join.""" - lobby = Lobby.objects.create( - owner=self.user_with_avatar, - name="Open Lobby", - status='waiting' - ) - - LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36 - ) - - self.assertTrue(self.user.can_join_lobby(lobby)) - - def test_can_join_lobby_already_in_lobby(self): - """Test can_join_lobby() returns False when user is already in a lobby.""" - lobby1 = Lobby.objects.create( - owner=self.user, - name="Current Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby1, - user=self.user, - status='waiting' - ) - - lobby2 = Lobby.objects.create( - owner=self.user_with_avatar, - name="Other Lobby", - status='waiting' - ) - - self.assertFalse(self.user.can_join_lobby(lobby2)) - - def test_can_join_lobby_full(self): - """Test can_join_lobby() returns False when lobby is full.""" - lobby = Lobby.objects.create( - owner=self.user_with_avatar, - name="Full Lobby", - status='waiting' - ) - - LobbySettings.objects.create( - lobby=lobby, - max_players=2, - card_count=36 - ) - - # Fill the lobby - user2 = User.objects.create_user(username="player2", password="test") - user3 = User.objects.create_user(username="player3", password="test") - - LobbyPlayer.objects.create(lobby=lobby, user=user2, status='waiting') - LobbyPlayer.objects.create(lobby=lobby, user=user3, status='waiting') - - self.assertFalse(self.user.can_join_lobby(lobby)) - - def test_can_join_lobby_closed(self): - """Test can_join_lobby() returns False for closed lobbies.""" - lobby = Lobby.objects.create( - owner=self.user_with_avatar, - name="Closed Lobby", - status='closed' - ) - - # Create settings for the lobby - LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36 - ) - - self.assertFalse(self.user.can_join_lobby(lobby)) - - def test_leave_current_lobby_success(self): - """Test leave_current_lobby() successfully removes user from lobby.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='waiting' - ) - - result = self.user.leave_current_lobby() - - self.assertTrue(result) - self.assertIsNone(self.user.get_active_lobby()) - - def test_leave_current_lobby_not_in_lobby(self): - """Test leave_current_lobby() returns False when not in a lobby.""" - result = self.user.leave_current_lobby() - - self.assertFalse(result) - - def test_leave_current_lobby_already_left(self): - """Test leave_current_lobby() returns False when already left.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - LobbyPlayer.objects.create( - lobby=lobby, - user=self.user, - status='left' - ) - - result = self.user.leave_current_lobby() - - self.assertFalse(result) - - def test_get_game_statistics_no_games(self): - """Test get_game_statistics() with no games played.""" - stats = self.user.get_game_statistics() - - self.assertEqual(stats['total_games'], 0) - self.assertEqual(stats['games_won'], 0) - self.assertEqual(stats['games_lost'], 0) - self.assertEqual(stats['win_rate'], 0.0) - - def test_get_game_statistics_with_wins_and_losses(self): - """Test get_game_statistics() with mixed results.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Stats Lobby", - status='playing' - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - # Create 3 finished games: 2 wins, 1 loss - for i in range(3): - game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='finished', - loser=self.user if i == 0 else self.user_with_avatar - ) - - GamePlayer.objects.create( - game=game, - user=self.user, - seat_position=1, - cards_remaining=0 - ) - - GamePlayer.objects.create( - game=game, - user=self.user_with_avatar, - seat_position=2, - cards_remaining=0 - ) - - stats = self.user.get_game_statistics() - - self.assertEqual(stats['total_games'], 3) - self.assertEqual(stats['games_won'], 2) - self.assertEqual(stats['games_lost'], 1) - self.assertEqual(stats['win_rate'], 66.7) - - def test_get_game_statistics_all_wins(self): - """Test get_game_statistics() with all wins.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Stats Lobby", - status='playing' - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - # Create 2 games where user always wins - for i in range(2): - game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='finished', - loser=self.user_with_avatar - ) - - GamePlayer.objects.create( - game=game, - user=self.user, - seat_position=1, - cards_remaining=0 - ) - - GamePlayer.objects.create( - game=game, - user=self.user_with_avatar, - seat_position=2, - cards_remaining=0 - ) - - stats = self.user.get_game_statistics() - - self.assertEqual(stats['total_games'], 2) - self.assertEqual(stats['games_won'], 2) - self.assertEqual(stats['games_lost'], 0) - self.assertEqual(stats['win_rate'], 100.0) - - def test_get_game_statistics_ignores_active_games(self): - """Test get_game_statistics() only counts finished games.""" - lobby = Lobby.objects.create( - owner=self.user, - name="Stats Lobby", - status='playing' - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - # Create an active game - active_game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - GamePlayer.objects.create( - game=active_game, - user=self.user, - seat_position=1, - cards_remaining=6 - ) - - # Create a finished game - finished_game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='finished', - loser=self.user_with_avatar - ) - - GamePlayer.objects.create( - game=finished_game, - user=self.user, - seat_position=1, - cards_remaining=0 - ) - - GamePlayer.objects.create( - game=finished_game, - user=self.user_with_avatar, - seat_position=2, - cards_remaining=0 - ) - - stats = self.user.get_game_statistics() - - # Should only count the finished game - self.assertEqual(stats['total_games'], 1) - self.assertEqual(stats['games_won'], 1) - - def test_user_ordering(self): - """Test that users are ordered by username.""" - user_z = User.objects.create_user(username="zzz", password="test") - user_a = User.objects.create_user(username="aaa", password="test") - - users = list(User.objects.all()) - - # First user should be alphabetically first - self.assertEqual(users[0].username, "aaa") - # Last user should be alphabetically last - self.assertEqual(users[-1].username, "zzz") - - def test_user_password_hashing(self): - """Test that passwords are properly hashed.""" - # Password should not be stored in plain text - self.assertNotEqual(self.user.password, "testpass123") - - # But check_password should work - self.assertTrue(self.user.check_password("testpass123")) - self.assertFalse(self.user.check_password("wrongpassword")) - - def test_user_authentication_fields(self): - """Test that inherited authentication fields work correctly.""" - self.assertTrue(self.user.is_active) - self.assertFalse(self.user.is_staff) - self.assertFalse(self.user.is_superuser) - - def test_create_superuser(self): - """Test creating a superuser.""" - admin = User.objects.create_superuser( - username="admin", - email="admin@example.com", - password="admin123" - ) - - self.assertTrue(admin.is_staff) - self.assertTrue(admin.is_superuser) - self.assertTrue(admin.is_active) - - def test_user_email_optional(self): - """Test that email is optional for users.""" - user_no_email = User.objects.create_user( - username="noemail", - password="test123" - ) - - self.assertEqual(user_no_email.email, "") - - def test_avatar_url_validation(self): - """Test that avatar_url accepts valid URLs.""" - self.user.avatar_url = "https://cdn.example.com/avatars/user123.png" - self.user.save() - - self.assertEqual(self.user.avatar_url, "https://cdn.example.com/avatars/user123.png") - - def test_user_related_objects_accessible(self): - """Test that related objects are accessible through reverse relations.""" - # These should not raise AttributeError - self.assertIsNotNone(self.user.sent_messages) - self.assertIsNotNone(self.user.received_messages) - self.assertIsNotNone(self.user.lobby_set) - self.assertIsNotNone(self.user.lobbyplayer_set) diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py new file mode 100644 index 0000000..2c59631 --- /dev/null +++ b/accounts/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test suite for game app. + +This package contains comprehensive tests for all accounts-related models, +""" diff --git a/accounts/tests/test_models.py b/accounts/tests/test_models.py new file mode 100644 index 0000000..ccea592 --- /dev/null +++ b/accounts/tests/test_models.py @@ -0,0 +1,83 @@ +"""Tests for the User model in the accounts app.""" + +import pytest +from django.contrib.auth import get_user_model + +User = get_user_model() + + +@pytest.mark.django_db +class TestUserModel: + """Test suite for the User model.""" + + def test_user_creation(self, test_user): + """Tests that User instances are created correctly.""" + assert test_user.username == "player1" + assert test_user.email == "player1@example.com" + assert test_user.check_password("test123") + + def test_user_uuid_generation(self, test_user): + """Tests that UUID is automatically generated for users.""" + assert test_user.id is not None + assert len(str(test_user.id)) == 36 + + def test_user_str_representation(self, test_user): + """Tests string representation of User.""" + assert str(test_user) == "player1" + + def test_user_created_at_auto_generation(self, test_user): + """Tests that created_at timestamp is automatically set.""" + assert test_user.created_at is not None + + def test_get_full_display_name_with_full_name(self, test_user): + """Tests get_full_display_name() returns full name when available.""" + test_user.first_name = "John" + test_user.last_name = "Doe" + test_user.save() + assert test_user.get_full_display_name() == "John Doe" + + def test_get_full_display_name_without_full_name(self, test_user): + """Tests get_full_display_name() falls back to username.""" + assert test_user.get_full_display_name() == "player1" + + def test_has_avatar_true(self, user_factory): + """Tests has_avatar() returns True when avatar is set.""" + user_with_avatar = user_factory( + username="avataruser", + avatar_url="https://example.com/avatar.jpg" + ) + assert user_with_avatar.has_avatar() is True + + def test_has_avatar_false(self, test_user): + """Tests has_avatar() returns False when avatar is not set.""" + assert test_user.has_avatar() is False + + def test_user_ordering(self, user_factory): + """Tests that users are ordered by username.""" + user_factory(username="zzz") + user_factory(username="aaa") + users = list(User.objects.all()) + assert users[0].username == "aaa" + assert users[-1].username == "zzz" + + def test_create_superuser(self): + """Tests creating a superuser.""" + admin = User.objects.create_superuser( + username="admin", + email="admin@example.com", + password="admin123" + ) + assert admin.is_staff is True + assert admin.is_superuser is True + + def test_password_hashing(self, test_user): + """Tests that passwords are properly hashed.""" + assert test_user.password != "test123" + assert test_user.check_password("test123") + assert not test_user.check_password("wrongpassword") + + def test_authentication_fields(self, test_user): + """Tests that inherited authentication fields work correctly.""" + assert test_user.is_active is True + assert test_user.is_staff is False + assert test_user.is_superuser is False diff --git a/accounts/tests/test_user_game.py b/accounts/tests/test_user_game.py new file mode 100644 index 0000000..7284390 --- /dev/null +++ b/accounts/tests/test_user_game.py @@ -0,0 +1,52 @@ +"""Tests for game-related methods on the User model.""" + +import pytest +from game.models import GamePlayer + + +@pytest.mark.django_db +class TestUserGameMethods: + """Test suite for user methods related to game interactions.""" + + def test_get_current_game_no_game(self, test_user): + """ + Tests get_current_game() returns None when user is not in a game. + + Args: + test_user: A fixture for a test user. + """ + assert test_user.get_current_game() is None + + def test_get_current_game_active_game(self, basic_game, test_user): + """ + Tests get_current_game() returns game when user is playing. + + Args: + basic_game: A fixture for a basic game instance. + test_user: A fixture for a test user. + """ + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + assert test_user.get_current_game() == basic_game + + def test_get_current_game_finished_game(self, basic_game, test_user): + """ + Tests get_current_game() returns None for finished games. + + Args: + basic_game: A fixture for a basic game instance. + test_user: A fixture for a test user. + """ + basic_game.status = 'finished' + basic_game.save() + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=0 + ) + assert test_user.get_current_game() is None diff --git a/accounts/tests/test_user_lobby.py b/accounts/tests/test_user_lobby.py new file mode 100644 index 0000000..a2551dc --- /dev/null +++ b/accounts/tests/test_user_lobby.py @@ -0,0 +1,116 @@ +"""Tests for lobby-related methods on the User model.""" + +import pytest +from game.models import LobbyPlayer + + +@pytest.mark.django_db +class TestUserLobbyMethods: + """Test suite for user methods related to lobby interactions.""" + + def test_get_active_lobby_no_lobby(self, test_user): + """ + Tests get_active_lobby() returns None when user is not in a lobby. + + Args: + test_user: A fixture for a test user. + """ + assert test_user.get_active_lobby() is None + + @pytest.mark.parametrize("status", ["waiting", "ready", "playing"]) + def test_get_active_lobby_active_statuses(self, basic_lobby, test_user, status): + """ + Tests get_active_lobby() returns the lobby for active player statuses. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + status: The status to test. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status=status) + assert test_user.get_active_lobby() == basic_lobby + + def test_get_active_lobby_left_status(self, basic_lobby, test_user): + """ + Tests get_active_lobby() returns None when user has left the lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='left') + assert test_user.get_active_lobby() is None + + def test_can_join_lobby_success(self, lobby_factory, test_user, second_user): + """ + Tests can_join_lobby() returns True for a valid join scenario. + + Args: + lobby_factory: A fixture to create lobbies. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + lobby = lobby_factory(owner=second_user, name="Open Lobby") + assert test_user.can_join_lobby(lobby) is True + + def test_can_join_lobby_already_in_lobby(self, basic_lobby, test_user, lobby_factory, second_user): + """ + Tests can_join_lobby() returns False when user is already in a lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + lobby_factory: A fixture to create lobbies. + second_user: A fixture for a second test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + other_lobby = lobby_factory(owner=second_user) + assert test_user.can_join_lobby(other_lobby) is False + + def test_can_join_lobby_full(self, lobby_factory, user_factory, test_user): + """ + Tests can_join_lobby() returns False when the lobby is full. + + Args: + lobby_factory: A fixture to create lobbies. + user_factory: A fixture to create users. + test_user: A fixture for a test user. + """ + owner = user_factory(username='owner') + full_lobby = lobby_factory(owner=owner, max_players=1) + LobbyPlayer.objects.create(lobby=full_lobby, user=owner, status='waiting') + assert test_user.can_join_lobby(full_lobby) is False + + def test_can_join_lobby_closed(self, lobby_factory, test_user, second_user): + """ + Tests can_join_lobby() returns False for closed lobbies. + + Args: + lobby_factory: A fixture to create lobbies. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + closed_lobby = lobby_factory(owner=second_user, status='closed') + assert test_user.can_join_lobby(closed_lobby) is False + + def test_leave_current_lobby_success(self, basic_lobby, test_user): + """ + Tests leave_current_lobby() successfully removes user from lobby. + + Args: + basic_lobby: A fixture for a basic lobby instance. + test_user: A fixture for a test user. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + assert test_user.leave_current_lobby() is True + assert test_user.get_active_lobby() is None + + def test_leave_current_lobby_not_in_lobby(self, test_user): + """ + Tests leave_current_lobby() returns False when not in a lobby. + + Args: + test_user: A fixture for a test user. + """ + + assert test_user.leave_current_lobby() is False diff --git a/accounts/tests/test_user_statistics.py b/accounts/tests/test_user_statistics.py new file mode 100644 index 0000000..4e8dd38 --- /dev/null +++ b/accounts/tests/test_user_statistics.py @@ -0,0 +1,91 @@ +"""Tests for statistics-related methods on the User model.""" + +import pytest +from game.models import GamePlayer + + +@pytest.mark.django_db +class TestUserStatisticsMethods: + """Test suite for user methods related to game statistics.""" + + def test_get_game_statistics_no_games(self, test_user): + """ + Tests get_game_statistics() with no games played. + + Args: + test_user: A fixture for a test user. + """ + stats = test_user.get_game_statistics() + assert stats['total_games'] == 0 + assert stats['games_won'] == 0 + assert stats['games_lost'] == 0 + assert stats['win_rate'] == 0.0 + + def test_get_game_statistics_with_wins_and_losses( + self, game_factory, basic_lobby, basic_cards, test_user, second_user + ): + """ + Tests get_game_statistics() with mixed results. + + Args: + game_factory: A fixture to create games. + basic_lobby: A fixture for a basic lobby instance. + basic_cards: A fixture for basic card instances. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + # Create 3 finished games: 2 wins, 1 loss for test_user + for i in range(3): + game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='finished', + loser=test_user if i == 0 else second_user + ) + GamePlayer.objects.create(game=game, user=test_user, seat_position=1, cards_remaining=6) + GamePlayer.objects.create(game=game, user=second_user, seat_position=2, cards_remaining=6) + + stats = test_user.get_game_statistics() + assert stats['total_games'] == 3 + assert stats['games_won'] == 2 + assert stats['games_lost'] == 1 + assert stats['win_rate'] == 66.7 + + def test_get_game_statistics_ignores_active_games( + self, game_factory, basic_lobby, basic_cards, test_user, second_user + ): + """ + Tests get_game_statistics() only counts finished games. + + Args: + game_factory: A fixture to create games. + basic_lobby: A fixture for a basic lobby instance. + basic_cards: A fixture for basic card instances. + test_user: A fixture for a test user. + second_user: A fixture for a second test user. + """ + # Create an active game (should be ignored) + active_game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + GamePlayer.objects.create(game=active_game, user=test_user, seat_position=1, cards_remaining=6) + + # Create a finished game (should be counted) + finished_game = game_factory( + lobby=basic_lobby, + trump_card=basic_cards['king_spades'], + status='finished', + loser=second_user # test_user wins + ) + GamePlayer.objects.create(game=finished_game, user=test_user, seat_position=1, cards_remaining=6) + GamePlayer.objects.create( game=finished_game, user=second_user, seat_position=2, cards_remaining=6) + + stats = test_user.get_game_statistics() + + # Should only count the finished game + assert stats['total_games'] == 1 + assert stats['games_won'] == 1 + assert stats['games_lost'] == 0 + assert stats['win_rate'] == 100.0 diff --git a/chat/tests.py b/chat/tests.py deleted file mode 100644 index 2769c13..0000000 --- a/chat/tests.py +++ /dev/null @@ -1,574 +0,0 @@ -from django.test import TestCase -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from chat.models import Message -from game.models import Lobby - -User = get_user_model() - - -class MessageModelTest(TestCase): - """Test suite for Message model.""" - - def setUp(self): - """Set up test data for Message tests.""" - self.user1 = User.objects.create_user( - username="sender", - password="test123" - ) - - self.user2 = User.objects.create_user( - username="receiver", - password="test123" - ) - - self.lobby = Lobby.objects.create( - owner=self.user1, - name="Test Lobby", - status='waiting' - ) - - def test_private_message_creation(self): - """Test that private Message instances are created correctly.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Hello, this is a private message!" - ) - - self.assertEqual(message.sender, self.user1) - self.assertEqual(message.receiver, self.user2) - self.assertIsNone(message.lobby) - self.assertEqual(message.content, "Hello, this is a private message!") - - def test_lobby_message_creation(self): - """Test that lobby Message instances are created correctly.""" - message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Hello everyone in the lobby!" - ) - - self.assertEqual(message.sender, self.user1) - self.assertEqual(message.lobby, self.lobby) - self.assertIsNone(message.receiver) - self.assertEqual(message.content, "Hello everyone in the lobby!") - - def test_message_uuid_generation(self): - """Test that UUID is automatically generated for messages.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - self.assertIsNotNone(message.id) - # UUID should be a valid UUID4 - self.assertEqual(len(str(message.id)), 36) - - def test_message_sent_at_auto_generation(self): - """Test that sent_at timestamp is automatically set.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - self.assertIsNotNone(message.sent_at) - - def test_message_str_representation_short(self): - """Test string representation of Message with short content.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Short message" - ) - - self.assertEqual(str(message), "sender: Short message") - - def test_message_str_representation_long(self): - """Test string representation of Message with long content.""" - long_content = "This is a very long message " * 10 - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content=long_content - ) - - str_repr = str(message) - self.assertIn("sender:", str_repr) - self.assertIn("...", str_repr) - self.assertEqual(len(str_repr.split(": ", 1)[1]), 53) # 50 chars + "..." - - def test_is_private_method_private_message(self): - """Test is_private() returns True for private messages.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Private" - ) - - self.assertTrue(message.is_private()) - - def test_is_private_method_lobby_message(self): - """Test is_private() returns False for lobby messages.""" - message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Public" - ) - - self.assertFalse(message.is_private()) - - def test_is_lobby_message_method_lobby_message(self): - """Test is_lobby_message() returns True for lobby messages.""" - message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Lobby message" - ) - - self.assertTrue(message.is_lobby_message()) - - def test_is_lobby_message_method_private_message(self): - """Test is_lobby_message() returns False for private messages.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Private" - ) - - self.assertFalse(message.is_lobby_message()) - - def test_get_chat_context_lobby_message(self): - """Test get_chat_context() for lobby messages.""" - message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Test" - ) - - context = message.get_chat_context() - - self.assertEqual(context['type'], 'lobby') - self.assertEqual(context['context'], self.lobby) - self.assertEqual(context['context_name'], 'Test Lobby') - - def test_get_chat_context_private_message(self): - """Test get_chat_context() for private messages.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - context = message.get_chat_context() - - self.assertEqual(context['type'], 'private') - self.assertEqual(context['context'], self.user2) - self.assertEqual(context['context_name'], 'Private chat with receiver') - - def test_get_chat_context_invalid_message(self): - """Test get_chat_context() for message without lobby or receiver. - - Note: This should not happen in practice due to validation, - but we test the fallback behavior. - """ - # Create message without validation - message = Message( - sender=self.user1, - content="Invalid" - ) - - context = message.get_chat_context() - - self.assertEqual(context['type'], 'unknown') - self.assertIsNone(context['context']) - self.assertEqual(context['context_name'], 'Unknown') - - def test_get_lobby_messages_method(self): - """Test get_lobby_messages() retrieves lobby messages.""" - # Create multiple messages - message1 = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="First message" - ) - - message2 = Message.objects.create( - sender=self.user2, - lobby=self.lobby, - content="Second message" - ) - - # Create a private message that should not be included - Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Private" - ) - - messages = list(Message.get_lobby_messages(self.lobby)) - - self.assertEqual(len(messages), 2) - # Should be ordered by sent_at descending (newest first) - self.assertEqual(messages[0], message2) - self.assertEqual(messages[1], message1) - - def test_get_lobby_messages_limit(self): - """Test get_lobby_messages() respects limit parameter.""" - # Create 10 messages - for i in range(10): - Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content=f"Message {i}" - ) - - messages = list(Message.get_lobby_messages(self.lobby, limit=5)) - - self.assertEqual(len(messages), 5) - - def test_get_lobby_messages_empty_lobby(self): - """Test get_lobby_messages() returns empty queryset for lobby with no messages.""" - empty_lobby = Lobby.objects.create( - owner=self.user2, - name="Empty Lobby", - status='waiting' - ) - - messages = list(Message.get_lobby_messages(empty_lobby)) - - self.assertEqual(len(messages), 0) - - def test_get_private_conversation_method(self): - """Test get_private_conversation() retrieves conversation between two users.""" - # Create messages in both directions - message1 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Hello from user1" - ) - - message2 = Message.objects.create( - sender=self.user2, - receiver=self.user1, - content="Reply from user2" - ) - - message3 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Another message from user1" - ) - - # Create a lobby message that should not be included - Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Lobby message" - ) - - # Create a message to another user that should not be included - user3 = User.objects.create_user(username="user3", password="test") - Message.objects.create( - sender=self.user1, - receiver=user3, - content="Message to user3" - ) - - messages = list(Message.get_private_conversation(self.user1, self.user2)) - - self.assertEqual(len(messages), 3) - - def test_get_private_conversation_order(self): - """Test get_private_conversation() returns messages in descending order.""" - message1 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="First" - ) - - message2 = Message.objects.create( - sender=self.user2, - receiver=self.user1, - content="Second" - ) - - messages = list(Message.get_private_conversation(self.user1, self.user2)) - - # Should be ordered by sent_at descending (newest first) - self.assertEqual(messages[0], message2) - self.assertEqual(messages[1], message1) - - def test_get_private_conversation_limit(self): - """Test get_private_conversation() respects limit parameter.""" - # Create 10 messages - for i in range(10): - Message.objects.create( - sender=self.user1, - receiver=self.user2, - content=f"Message {i}" - ) - - messages = list(Message.get_private_conversation(self.user1, self.user2, limit=5)) - - self.assertEqual(len(messages), 5) - - def test_get_private_conversation_no_messages(self): - """Test get_private_conversation() returns empty queryset when no conversation exists.""" - messages = list(Message.get_private_conversation(self.user1, self.user2)) - - self.assertEqual(len(messages), 0) - - def test_get_private_conversation_symmetry(self): - """Test get_private_conversation() works regardless of parameter order.""" - Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - messages1 = list(Message.get_private_conversation(self.user1, self.user2)) - messages2 = list(Message.get_private_conversation(self.user2, self.user1)) - - self.assertEqual(len(messages1), len(messages2)) - self.assertEqual(messages1, messages2) - - def test_clean_validation_both_lobby_and_receiver(self): - """Test clean() raises ValidationError when both lobby and receiver are set.""" - message = Message( - sender=self.user1, - receiver=self.user2, - lobby=self.lobby, - content="Invalid message" - ) - - with self.assertRaises(ValidationError) as context: - message.clean() - - self.assertIn("both lobby and receiver", str(context.exception)) - - def test_clean_validation_neither_lobby_nor_receiver(self): - """Test clean() raises ValidationError when neither lobby nor receiver is set.""" - message = Message( - sender=self.user1, - content="Invalid message" - ) - - with self.assertRaises(ValidationError) as context: - message.clean() - - self.assertIn("either lobby or receiver", str(context.exception)) - - def test_clean_validation_passes_with_receiver(self): - """Test clean() passes validation with receiver set.""" - message = Message( - sender=self.user1, - receiver=self.user2, - content="Valid private message" - ) - - # Should not raise ValidationError - message.clean() - - def test_clean_validation_passes_with_lobby(self): - """Test clean() passes validation with lobby set.""" - message = Message( - sender=self.user1, - lobby=self.lobby, - content="Valid lobby message" - ) - - # Should not raise ValidationError - message.clean() - - def test_save_calls_clean(self): - """Test that save() method calls clean() for validation.""" - message = Message( - sender=self.user1, - receiver=self.user2, - lobby=self.lobby, - content="Invalid" - ) - - with self.assertRaises(ValidationError): - message.save() - - def test_save_valid_message(self): - """Test that save() works for valid messages.""" - message = Message( - sender=self.user1, - receiver=self.user2, - content="Valid message" - ) - - # Should not raise any exception - message.save() - - self.assertIsNotNone(message.id) - - def test_message_ordering(self): - """Test that messages are ordered by sent_at descending.""" - message1 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="First" - ) - - message2 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Second" - ) - - message3 = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Third" - ) - - messages = list(Message.objects.all()) - - # Should be ordered newest first - self.assertEqual(messages[0], message3) - self.assertEqual(messages[1], message2) - self.assertEqual(messages[2], message1) - - def test_message_indexes_exist(self): - """Test that database indexes are properly configured. - - This test ensures the model has the expected index definitions. - Actual index creation is verified by migrations. - """ - # Check that indexes are defined in Meta - self.assertEqual(len(Message._meta.indexes), 3) - - def test_message_content_can_be_long(self): - """Test that message content can store long text.""" - long_content = "A" * 10000 # 10,000 characters - - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content=long_content - ) - - message.refresh_from_db() - self.assertEqual(len(message.content), 10000) - - def test_message_related_names(self): - """Test that related names work correctly.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - # Test sender's sent_messages - self.assertIn(message, self.user1.sent_messages.all()) - - # Test receiver's received_messages - self.assertIn(message, self.user2.received_messages.all()) - - # Test lobby's messages (for lobby message) - lobby_message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Lobby test" - ) - - self.assertIn(lobby_message, self.lobby.messages.all()) - - def test_message_cascade_delete_sender(self): - """Test that messages are deleted when sender is deleted.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - message_id = message.id - self.user1.delete() - - # Message should be deleted - self.assertFalse(Message.objects.filter(id=message_id).exists()) - - def test_message_cascade_delete_receiver(self): - """Test that private messages are deleted when receiver is deleted.""" - message = Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="Test" - ) - - message_id = message.id - self.user2.delete() - - # Message should be deleted - self.assertFalse(Message.objects.filter(id=message_id).exists()) - - def test_message_cascade_delete_lobby(self): - """Test that lobby messages are deleted when lobby is deleted.""" - message = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="Test" - ) - - message_id = message.id - self.lobby.delete() - - # Message should be deleted - self.assertFalse(Message.objects.filter(id=message_id).exists()) - - def test_multiple_users_can_message_same_lobby(self): - """Test that multiple users can send messages to the same lobby.""" - user3 = User.objects.create_user(username="user3", password="test") - - message1 = Message.objects.create( - sender=self.user1, - lobby=self.lobby, - content="From user1" - ) - - message2 = Message.objects.create( - sender=self.user2, - lobby=self.lobby, - content="From user2" - ) - - message3 = Message.objects.create( - sender=user3, - lobby=self.lobby, - content="From user3" - ) - - lobby_messages = list(Message.get_lobby_messages(self.lobby)) - self.assertEqual(len(lobby_messages), 3) - - def test_user_can_have_conversations_with_multiple_users(self): - """Test that a user can have separate conversations with multiple users.""" - user3 = User.objects.create_user(username="user3", password="test") - - # Conversation with user2 - Message.objects.create( - sender=self.user1, - receiver=self.user2, - content="To user2" - ) - - # Conversation with user3 - Message.objects.create( - sender=self.user1, - receiver=user3, - content="To user3" - ) - - conv_user2 = list(Message.get_private_conversation(self.user1, self.user2)) - conv_user3 = list(Message.get_private_conversation(self.user1, user3)) - - self.assertEqual(len(conv_user2), 1) - self.assertEqual(len(conv_user3), 1) - self.assertEqual(conv_user2[0].receiver, self.user2) - self.assertEqual(conv_user3[0].receiver, user3) \ No newline at end of file diff --git a/chat/tests/__init__.py b/chat/tests/__init__.py new file mode 100644 index 0000000..bdb872f --- /dev/null +++ b/chat/tests/__init__.py @@ -0,0 +1,4 @@ +"""Test suite for game app. + +This package contains comprehensive tests for all chat-related models, +""" diff --git a/chat/tests/test_models.py b/chat/tests/test_models.py new file mode 100644 index 0000000..2d9d2f1 --- /dev/null +++ b/chat/tests/test_models.py @@ -0,0 +1,115 @@ +"""Tests for the Message model in the chat app.""" + +import pytest +from django.core.exceptions import ValidationError +from chat.models import Message + + +@pytest.mark.django_db +class TestMessageModel: + """Test suite for Message model.""" + + def test_private_message_creation(self, test_user, second_user): + """Tests that private Message instances are created correctly.""" + message = Message.objects.create( + sender=test_user, + receiver=second_user, + content="Hello, this is a private message!" + ) + assert message.sender == test_user + assert message.receiver == second_user + assert message.lobby is None + assert message.content == "Hello, this is a private message!" + + def test_lobby_message_creation(self, test_user, basic_lobby): + """Tests that lobby Message instances are created correctly.""" + message = Message.objects.create( + sender=test_user, + lobby=basic_lobby, + content="Hello everyone in the lobby!" + ) + assert message.sender == test_user + assert message.lobby == basic_lobby + assert message.receiver is None + assert message.content == "Hello everyone in the lobby!" + + def test_message_uuid_generation(self, test_user, second_user): + """Tests that UUID is automatically generated for messages.""" + message = Message.objects.create( + sender=test_user, receiver=second_user, content="Test" + ) + assert message.id is not None + assert len(str(message.id)) == 36 + + def test_message_sent_at_auto_generation(self, test_user, second_user): + """Tests that sent_at timestamp is automatically set.""" + message = Message.objects.create( + sender=test_user, receiver=second_user, content="Test" + ) + assert message.sent_at is not None + + def test_message_str_representation(self, test_user, second_user): + """Tests string representation of Message.""" + short_content = "Short message" + message = Message.objects.create( + sender=test_user, receiver=second_user, content=short_content + ) + assert str(message) == f"{test_user.username}: {short_content}" + + def test_is_private_method(self, test_user, second_user, basic_lobby): + """Tests is_private() method for private and lobby messages.""" + private_message = Message.objects.create( + sender=test_user, receiver=second_user, content="Private" + ) + lobby_message = Message.objects.create( + sender=test_user, lobby=basic_lobby, content="Public" + ) + assert private_message.is_private() is True + assert lobby_message.is_private() is False + + def test_is_lobby_message_method(self, test_user, second_user, basic_lobby): + """Tests is_lobby_message() method for private and lobby messages.""" + private_message = Message.objects.create( + sender=test_user, receiver=second_user, content="Private" + ) + lobby_message = Message.objects.create( + sender=test_user, lobby=basic_lobby, content="Public" + ) + assert lobby_message.is_lobby_message() is True + assert private_message.is_lobby_message() is False + + def test_get_chat_context(self, test_user, second_user, basic_lobby): + """Tests get_chat_context() for lobby and private messages.""" + private_message = Message.objects.create( + sender=test_user, receiver=second_user, content="Test" + ) + lobby_message = Message.objects.create( + sender=test_user, lobby=basic_lobby, content="Test" + ) + + private_context = private_message.get_chat_context() + assert private_context['type'] == 'private' + assert private_context['context'] == second_user + + lobby_context = lobby_message.get_chat_context() + assert lobby_context['type'] == 'lobby' + assert lobby_context['context'] == basic_lobby + + def test_clean_validation_both_lobby_and_receiver( + self, test_user, second_user, basic_lobby + ): + """Tests clean() raises ValidationError when both lobby and receiver are set.""" + message = Message( + sender=test_user, + receiver=second_user, + lobby=basic_lobby, + content="Invalid" + ) + with pytest.raises(ValidationError, match="both lobby and receiver"): + message.clean() + + def test_clean_validation_neither_lobby_nor_receiver(self, test_user): + """Tests clean() raises ValidationError when neither lobby nor receiver is set.""" + message = Message(sender=test_user, content="Invalid") + with pytest.raises(ValidationError, match="either lobby or receiver"): + message.clean() diff --git a/chat/tests/test_queries.py b/chat/tests/test_queries.py new file mode 100644 index 0000000..7d21aa4 --- /dev/null +++ b/chat/tests/test_queries.py @@ -0,0 +1,90 @@ +"""Tests for query methods on the Message model.""" + +import pytest +from chat.models import Message +from game.models import Lobby + + +@pytest.mark.django_db +class TestMessageQueries: + """Test suite for class methods on Message that perform queries.""" + + @pytest.fixture(autouse=True) + def set_up(self, test_user, second_user, basic_lobby): + """Sets up users and a lobby for the tests.""" + self.user1 = test_user + self.user2 = second_user + self.lobby = basic_lobby + + def test_get_lobby_messages(self): + """Tests that get_lobby_messages() retrieves only relevant lobby messages.""" + msg1 = Message.objects.create(sender=self.user1, lobby=self.lobby, content="1") + msg2 = Message.objects.create(sender=self.user2, lobby=self.lobby, content="2") + # Private message, should not be included + Message.objects.create(sender=self.user1, receiver=self.user2, content="private") + + messages = list(Message.get_lobby_messages(self.lobby)) + assert len(messages) == 2 + # Should be ordered by sent_at descending (newest first) + assert messages[0] == msg2 + assert messages[1] == msg1 + + def test_get_lobby_messages_limit(self): + """Tests that get_lobby_messages() respects the limit parameter.""" + for i in range(5): + Message.objects.create( + sender=self.user1, lobby=self.lobby, content=f"Msg {i}" + ) + + messages = list(Message.get_lobby_messages(self.lobby, limit=3)) + assert len(messages) == 3 + + def test_get_lobby_messages_empty(self, lobby_factory): + """Tests get_lobby_messages() for a lobby with no messages.""" + empty_lobby = lobby_factory(owner=self.user1, name="Empty") + messages = list(Message.get_lobby_messages(empty_lobby)) + assert len(messages) == 0 + + def test_get_private_conversation(self, user_factory): + """Tests get_private_conversation() retrieves a full conversation.""" + user3 = user_factory(username='user3') + # Conversation between user1 and user2 + msg1 = Message.objects.create(sender=self.user1, receiver=self.user2, content="Hi") + msg2 = Message.objects.create(sender=self.user2, receiver=self.user1, content="Hello") + # Other messages that should be ignored + Message.objects.create(sender=self.user1, lobby=self.lobby, content="Lobby msg") + Message.objects.create(sender=self.user1, receiver=user3, content="To user3") + + messages = list(Message.get_private_conversation(self.user1, self.user2)) + assert len(messages) == 2 + assert msg1 in messages + assert msg2 in messages + + def test_get_private_conversation_order(self): + """Tests get_private_conversation() returns messages in descending order.""" + msg1 = Message.objects.create(sender=self.user1, receiver=self.user2, content="First") + msg2 = Message.objects.create(sender=self.user2, receiver=self.user1, content="Second") + + messages = list(Message.get_private_conversation(self.user1, self.user2)) + assert messages[0] == msg2 + assert messages[1] == msg1 + + def test_get_private_conversation_limit(self): + """Tests get_private_conversation() respects the limit parameter.""" + for i in range(5): + Message.objects.create( + sender=self.user1, receiver=self.user2, content=f"Msg {i}" + ) + + messages = list(Message.get_private_conversation(self.user1, self.user2, limit=3)) + assert len(messages) == 3 + + def test_get_private_conversation_symmetry(self): + """Tests get_private_conversation() works regardless of parameter order.""" + Message.objects.create(sender=self.user1, receiver=self.user2, content="Test") + + messages1 = list(Message.get_private_conversation(self.user1, self.user2)) + messages2 = list(Message.get_private_conversation(self.user2, self.user1)) + + assert len(messages1) == 1 + assert messages1 == messages2 diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..23d949f --- /dev/null +++ b/conftest.py @@ -0,0 +1,359 @@ +""" +Fixtures for testing the Durak card game Django application. + +This module provides reusable pytest fixtures for creating users, cards, +lobbies, games, and special cards/rule sets. The fixtures include both +factory-style functions (for flexible object creation) and pre-defined +instances for common test scenarios. + +Usage: + - Import the fixtures in your test modules. + - Use factory fixtures to create objects with custom attributes. + - Use pre-defined fixtures for simple, ready-to-use test objects. + +Examples: + def test_user_has_avatar(test_user): + assert not test_user.has_avatar() + + def test_basic_game_has_trump(basic_game, basic_cards): + assert basic_game.trump_card == basic_cards['ace_hearts'] +""" + +import os +import pytest + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "Fools_Arena.settings") + +import django + +django.setup() + +from django.contrib.auth import get_user_model +from game.models import ( + CardSuit, CardRank, Card, Lobby, LobbySettings, + Game, GamePlayer, SpecialCard, SpecialRuleSet +) + +User = get_user_model() + + +@pytest.fixture +def user_factory(db): + """Factory fixture for creating users with custom attributes safely (no IntegrityError). + + Returns: + callable: Function that creates and returns a User instance. + Args: + username (str): Username for the user. Defaults to 'testuser'. + password (str): Password for the user. Defaults to 'test123'. + email (str, optional): Email for the user. + **kwargs: Additional fields to set on the user. + + Example: + user = user_factory(username='player1', email='player1@test.com') + """ + + counter = {'i': 0} + + def create_user(username=None, password="test123", **kwargs): + counter['i'] += 1 + if username is None: + username = f"player{counter['i']}" + kwargs.setdefault('email', f'{username}@example.com') + + # if user already exists, return it + User = get_user_model() + existing_user = User.objects.filter(username=username).first() + if existing_user: + return existing_user + + # else create a new user + return User.objects.create_user(username=username, password=password, **kwargs) + + return create_user + + +@pytest.fixture +def test_user(user_factory): + """Create a default test user. + + Returns: + User: A user instance with username 'player1'. + """ + return user_factory(username="player1", email="player1@example.com") + + +@pytest.fixture +def second_user(user_factory): + """Create a second test user. + + Returns: + User: A user instance with username 'player2'. + """ + return user_factory(username="player2", email="player2@example.com") + + +@pytest.fixture +def card_suits(): + """Create basic card suits for testing.""" + return { + 'hearts': CardSuit.objects.create(name="Hearts", color="red"), + 'spades': CardSuit.objects.create(name="Spades", color="black"), + 'diamonds': CardSuit.objects.create(name="Diamonds", color="red"), + 'clubs': CardSuit.objects.create(name="Clubs", color="black"), + } + + +@pytest.fixture +def card_ranks(): + """Create basic card ranks for testing.""" + return { + 'ace': CardRank.objects.create(name="Ace", value=14), + 'king': CardRank.objects.create(name="King", value=13), + 'queen': CardRank.objects.create(name="Queen", value=12), + 'jack': CardRank.objects.create(name="Jack", value=11), + 'ten': CardRank.objects.create(name="Ten", value=10), + 'seven': CardRank.objects.create(name="Seven", value=7), + 'six': CardRank.objects.create(name="Six", value=6), + } + + +@pytest.fixture +def basic_cards(card_suits, card_ranks): + """Create basic cards for testing.""" + return { + 'ace_hearts': Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ), + 'seven_hearts': Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ), + 'king_spades': Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ), + 'six_diamonds': Card.objects.create( + suit=card_suits['diamonds'], + rank=card_ranks['six'] + ), + } + + +@pytest.fixture +def lobby_factory(): + """Factory fixture for creating lobbies with customizable settings. + + Returns: + callable: Function that creates and returns a Lobby instance with settings. + Args: + owner (User): The user who owns the lobby. + name (str): Name of the lobby. Defaults to 'Test Lobby'. + status (str): Lobby status. Defaults to 'waiting'. + **kwargs: Additional lobby and settings parameters: + - is_private (bool): Whether lobby is private. + - password_hash (str): Hashed password for private lobbies. + - max_players (int): Maximum number of players. + - card_count (int): Number of cards in deck (24, 36, or 52). + - is_transferable (bool): Allow card transfers. + - neighbor_throw_only (bool): Restrict throws to neighbors. + - allow_jokers (bool): Include jokers in deck. + - turn_time_limit (int): Time limit per turn in seconds. + - special_rule_set (SpecialRuleSet): Special rules to apply. + + Example: + lobby = lobby_factory( + owner=user, + name='Pro Game', + max_players=6, + is_transferable=True + ) + """ + + def create_lobby(owner, name="Test Lobby", status='waiting', **kwargs): + lobby = Lobby.objects.create( + owner=owner, + name=name, + status=status, + is_private=kwargs.get('is_private', False), + password_hash=kwargs.get('password_hash', None) + ) + + # Automatically create lobby settings with provided or default values + LobbySettings.objects.create( + lobby=lobby, + max_players=kwargs.get('max_players', 4), + card_count=kwargs.get('card_count', 36), + is_transferable=kwargs.get('is_transferable', False), + neighbor_throw_only=kwargs.get('neighbor_throw_only', False), + allow_jokers=kwargs.get('allow_jokers', False), + turn_time_limit=kwargs.get('turn_time_limit', None), + special_rule_set=kwargs.get('special_rule_set', None) + ) + + return lobby + + return create_lobby + + +@pytest.fixture +def basic_lobby(test_user, lobby_factory): + """Create a basic lobby for testing. + + Args: + test_user: Fixture providing a default test user. + lobby_factory: Fixture providing lobby creation function. + + Returns: + Lobby: A lobby instance with default settings. + """ + return lobby_factory(owner=test_user) + + +@pytest.fixture +def game_factory(db): + """Factory fixture for creating game instances. + + Returns: + callable: Function that creates and returns a Game instance. + Args: + lobby (Lobby): The lobby associated with this game. + trump_card (Card): The trump card for this game. + status (str): Game status. Defaults to 'in_progress'. + **kwargs: Additional game parameters: + - loser (User): The losing player (for finished games). + - finished_at (datetime): When the game finished. + + Example: + game = game_factory( + lobby=lobby, + trump_card=ace_of_hearts, + status='in_progress' + ) + """ + + def create_game(lobby, trump_card, status='in_progress', **kwargs): + return Game.objects.create( + lobby=lobby, + trump_card=trump_card, + status=status, + loser=kwargs.get('loser', None), + finished_at=kwargs.get('finished_at', None) + ) + + return create_game + + +@pytest.fixture +def basic_game(basic_lobby, basic_cards, game_factory): + """Create a basic game ready for testing. + + Args: + basic_lobby: Fixture providing a basic lobby. + basic_cards: Fixture providing basic card instances. + game_factory: Fixture providing game creation function. + + Returns: + Game: A game instance in 'in_progress' status with ace of hearts as trump. + + Note: + The associated lobby's status is changed to 'playing'. + """ + basic_lobby.status = 'playing' + basic_lobby.save() + return game_factory( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'] + ) + + +@pytest.fixture +def game_player_factory(db): + """Factory fixture for creating game player instances. + + Returns: + callable: Function that creates and returns a GamePlayer instance. + Args: + game (Game): The game instance. + user (User): The player's user instance. + seat_position (int): Player's seat position (1-based). + cards_remaining (int): Number of cards in player's hand. + + Example: + player = game_player_factory( + game=game, + user=user, + seat_position=1, + cards_remaining=6 + ) + """ + + def create_game_player(game, user, seat_position, cards_remaining=6): + return GamePlayer.objects.create( + game=game, + user=user, + seat_position=seat_position, + cards_remaining=cards_remaining + ) + + return create_game_player + + +@pytest.fixture +def special_card_skip(db): + """Create a special card with skip effect. + + Returns: + SpecialCard: A special card that skips the next player's turn. + """ + return SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + effect_value={}, + description="Next player loses their turn" + ) + + +@pytest.fixture +def special_card_draw(db): + """Create a special card with draw effect. + + Returns: + SpecialCard: A special card that makes target draw 2 cards. + """ + return SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + effect_value={"card_count": 2}, + description="Target draws cards" + ) + + +@pytest.fixture +def special_card_reverse(db): + """Create a special card with reverse effect. + + Returns: + SpecialCard: A special card that reverses turn order. + """ + return SpecialCard.objects.create( + name="Reverse", + effect_type="reverse", + effect_value={}, + description="Reverse turn order" + ) + + +@pytest.fixture +def basic_rule_set(db): + """Create a basic special rule set for testing. + + Returns: + SpecialRuleSet: A rule set with minimum 2 players requirement. + """ + return SpecialRuleSet.objects.create( + name="Beginner Special", + description="Simple special cards for new players", + min_players=2 + ) diff --git a/game/models.py b/game/models.py index bc9b9c5..5d6d321 100644 --- a/game/models.py +++ b/game/models.py @@ -248,9 +248,12 @@ def has_time_limit(self): """Check if the lobby has a turn time limit enabled. Returns: - bool: True if turn_time_limit is set, False otherwise. + bool: True if turn_time_limit is set or not equals 0, False otherwise. """ - return self.turn_time_limit is not None + if self.turn_time_limit is None: + return False + else: + return self.turn_time_limit > 0 def is_beginner_friendly(self): """Check if settings are suitable for beginner players. diff --git a/game/tests.py b/game/tests.py deleted file mode 100644 index 6b9601e..0000000 --- a/game/tests.py +++ /dev/null @@ -1,1859 +0,0 @@ -from django.test import TestCase -from django.contrib.auth import get_user_model -from django.core.exceptions import ValidationError -from django.db import IntegrityError -from django.utils import timezone -from game.models import ( - CardSuit, CardRank, Card, Lobby, LobbySettings, LobbyPlayer, - Game, GamePlayer, GameDeck, PlayerHand, TableCard, DiscardPile, - Turn, Move, SpecialCard, SpecialRuleSet, SpecialRuleSetCard -) - -User = get_user_model() - - -class CardSuitModelTest(TestCase): - """Test suite for CardSuit model.""" - - def setUp(self): - """Set up test data for CardSuit tests.""" - self.hearts = CardSuit.objects.create(name="Hearts", color="red") - self.spades = CardSuit.objects.create(name="Spades", color="black") - - def test_card_suit_creation(self): - """Test that CardSuit instances are created correctly.""" - self.assertEqual(self.hearts.name, "Hearts") - self.assertEqual(self.hearts.color, "red") - self.assertEqual(self.spades.color, "black") - - def test_card_suit_str_representation(self): - """Test string representation of CardSuit.""" - self.assertEqual(str(self.hearts), "Hearts") - self.assertEqual(str(self.spades), "Spades") - - def test_is_red_method(self): - """Test is_red() method returns correct boolean.""" - self.assertTrue(self.hearts.is_red()) - self.assertFalse(self.spades.is_red()) - - def test_card_suit_ordering(self): - """Test that CardSuit instances are ordered by name.""" - diamonds = CardSuit.objects.create(name="Diamonds", color="red") - clubs = CardSuit.objects.create(name="Clubs", color="black") - - suits = list(CardSuit.objects.all()) - self.assertEqual(suits[0].name, "Clubs") - self.assertEqual(suits[1].name, "Diamonds") - self.assertEqual(suits[2].name, "Hearts") - self.assertEqual(suits[3].name, "Spades") - - def test_card_suit_color_choices(self): - """Test that only valid color choices are accepted.""" - # Valid colors should work - valid_suit = CardSuit.objects.create(name="Test", color="red") - self.assertEqual(valid_suit.color, "red") - - -class CardRankModelTest(TestCase): - """Test suite for CardRank model.""" - - def setUp(self): - """Set up test data for CardRank tests.""" - self.ace = CardRank.objects.create(name="Ace", value=14) - self.king = CardRank.objects.create(name="King", value=13) - self.jack = CardRank.objects.create(name="Jack", value=11) - self.six = CardRank.objects.create(name="Six", value=6) - - def test_card_rank_creation(self): - """Test that CardRank instances are created correctly.""" - self.assertEqual(self.ace.name, "Ace") - self.assertEqual(self.ace.value, 14) - - def test_card_rank_str_representation(self): - """Test string representation of CardRank.""" - self.assertEqual(str(self.ace), "Ace") - self.assertEqual(str(self.king), "King") - - def test_is_face_card_method(self): - """Test is_face_card() method for various ranks.""" - self.assertTrue(self.king.is_face_card()) - self.assertTrue(self.jack.is_face_card()) - self.assertFalse(self.ace.is_face_card()) - self.assertFalse(self.six.is_face_card()) - - queen = CardRank.objects.create(name="Queen", value=12) - self.assertTrue(queen.is_face_card()) - - def test_card_rank_ordering(self): - """Test that CardRank instances are ordered by value.""" - ranks = list(CardRank.objects.all()) - self.assertEqual(ranks[0].value, 6) - self.assertEqual(ranks[1].value, 11) - self.assertEqual(ranks[2].value, 13) - self.assertEqual(ranks[3].value, 14) - - -class CardModelTest(TestCase): - """Test suite for Card model.""" - - def setUp(self): - """Set up test data for Card tests.""" - self.hearts = CardSuit.objects.create(name="Hearts", color="red") - self.spades = CardSuit.objects.create(name="Spades", color="black") - self.diamonds = CardSuit.objects.create(name="Diamonds", color="red") - - self.ace = CardRank.objects.create(name="Ace", value=14) - self.king = CardRank.objects.create(name="King", value=13) - self.seven = CardRank.objects.create(name="Seven", value=7) - self.six = CardRank.objects.create(name="Six", value=6) - - self.ace_of_hearts = Card.objects.create(suit=self.hearts, rank=self.ace) - self.king_of_spades = Card.objects.create(suit=self.spades, rank=self.king) - self.seven_of_hearts = Card.objects.create(suit=self.hearts, rank=self.seven) - self.six_of_diamonds = Card.objects.create(suit=self.diamonds, rank=self.six) - - def test_card_creation(self): - """Test that Card instances are created correctly.""" - self.assertEqual(self.ace_of_hearts.suit, self.hearts) - self.assertEqual(self.ace_of_hearts.rank, self.ace) - - def test_card_str_representation(self): - """Test string representation of Card.""" - self.assertEqual(str(self.ace_of_hearts), "Ace of Hearts") - self.assertEqual(str(self.king_of_spades), "King of Spades") - - def test_card_str_with_special_card(self): - """Test string representation includes special card name.""" - special = SpecialCard.objects.create( - name="Skip Turn", - effect_type="skip", - description="Skip next player's turn" - ) - special_card = Card.objects.create( - suit=self.spades, - rank=self.ace, - special_card=special - ) - self.assertEqual(str(special_card), "Ace of Spades (Skip Turn)") - - def test_is_trump_method(self): - """Test is_trump() method with various trump suits.""" - self.assertTrue(self.ace_of_hearts.is_trump(self.hearts)) - self.assertFalse(self.ace_of_hearts.is_trump(self.spades)) - self.assertTrue(self.king_of_spades.is_trump(self.spades)) - - def test_is_special_method(self): - """Test is_special() method.""" - self.assertFalse(self.ace_of_hearts.is_special()) - - special = SpecialCard.objects.create( - name="Draw Two", - effect_type="draw", - description="Draw 2 cards" - ) - special_card = Card.objects.create( - suit=self.hearts, - rank=self.seven, - special_card=special - ) - self.assertTrue(special_card.is_special()) - - def test_can_beat_trump_vs_non_trump(self): - """Test that trump cards beat non-trump cards.""" - # Hearts is trump - trump_suit = self.hearts - - # Low trump beats high non-trump - self.assertTrue(self.seven_of_hearts.can_beat(self.king_of_spades, trump_suit)) - - # Non-trump cannot beat trump - self.assertFalse(self.king_of_spades.can_beat(self.seven_of_hearts, trump_suit)) - - def test_can_beat_same_suit(self): - """Test card comparison for same suit.""" - trump_suit = self.spades - - # Higher rank beats lower rank in same suit - self.assertTrue(self.ace_of_hearts.can_beat(self.seven_of_hearts, trump_suit)) - - # Lower rank cannot beat higher rank - self.assertFalse(self.seven_of_hearts.can_beat(self.ace_of_hearts, trump_suit)) - - def test_can_beat_different_non_trump_suits(self): - """Test that different non-trump suits cannot beat each other.""" - trump_suit = self.spades - - # Hearts and Diamonds are both non-trump - self.assertFalse(self.ace_of_hearts.can_beat(self.six_of_diamonds, trump_suit)) - self.assertFalse(self.six_of_diamonds.can_beat(self.ace_of_hearts, trump_suit)) - - def test_can_beat_trump_vs_trump(self): - """Test trump card comparison.""" - trump_suit = self.hearts - - # Higher trump beats lower trump - self.assertTrue(self.ace_of_hearts.can_beat(self.seven_of_hearts, trump_suit)) - self.assertFalse(self.seven_of_hearts.can_beat(self.ace_of_hearts, trump_suit)) - - def test_card_unique_together_constraint(self): - """Test that cards with same suit, rank, and special_card are unique.""" - # Create a special card first - special = SpecialCard.objects.create( - name="Test Special", - effect_type="skip", - description="Test" - ) - - # Create first card with special_card - Card.objects.create( - suit=self.hearts, - rank=self.ace, - special_card=special - ) - - # Creating duplicate with same suit, rank, and special_card should fail - with self.assertRaises(IntegrityError): - Card.objects.create( - suit=self.hearts, - rank=self.ace, - special_card=special - ) - - -class LobbyModelTest(TestCase): - """Test suite for Lobby model.""" - - def setUp(self): - """Set up test data for Lobby tests.""" - self.user1 = User.objects.create_user(username="player1", password="test123") - self.user2 = User.objects.create_user(username="player2", password="test123") - - self.lobby = Lobby.objects.create( - owner=self.user1, - name="Test Lobby", - is_private=False, - status='waiting' - ) - - # Create lobby settings - self.settings = LobbySettings.objects.create( - lobby=self.lobby, - max_players=4, - card_count=36, - is_transferable=False, - neighbor_throw_only=False, - allow_jokers=False - ) - - def test_lobby_creation(self): - """Test that Lobby instances are created correctly.""" - self.assertEqual(self.lobby.name, "Test Lobby") - self.assertEqual(self.lobby.owner, self.user1) - self.assertFalse(self.lobby.is_private) - self.assertEqual(self.lobby.status, 'waiting') - - def test_lobby_str_representation(self): - """Test string representation of Lobby.""" - self.assertEqual(str(self.lobby), "Test Lobby") - - def test_lobby_uuid_generation(self): - """Test that UUID is automatically generated.""" - self.assertIsNotNone(self.lobby.id) - - def test_is_full_method_empty_lobby(self): - """Test is_full() returns False for empty lobby.""" - self.assertFalse(self.lobby.is_full()) - - def test_is_full_method_with_players(self): - """Test is_full() with various player counts.""" - # Add players up to max - for i in range(4): - user = User.objects.create_user(username=f"player{i + 10}", password="test") - LobbyPlayer.objects.create( - lobby=self.lobby, - user=user, - status='waiting' - ) - - self.assertTrue(self.lobby.is_full()) - - def test_is_full_excludes_left_players(self): - """Test that is_full() doesn't count players who left.""" - # Add 3 active players - for i in range(3): - user = User.objects.create_user(username=f"player{i + 10}", password="test") - LobbyPlayer.objects.create( - lobby=self.lobby, - user=user, - status='waiting' - ) - - # Add 1 player who left - left_user = User.objects.create_user(username="left_player", password="test") - LobbyPlayer.objects.create( - lobby=self.lobby, - user=left_user, - status='left' - ) - - self.assertFalse(self.lobby.is_full()) - - def test_can_start_game_method_not_enough_players(self): - """Test can_start_game() returns False with insufficient ready players.""" - # Add only 1 ready player - LobbyPlayer.objects.create( - lobby=self.lobby, - user=self.user1, - status='ready' - ) - - self.assertFalse(self.lobby.can_start_game()) - - def test_can_start_game_method_enough_ready_players(self): - """Test can_start_game() returns True with enough ready players.""" - # Add 2 ready players - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='ready') - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') - - self.assertTrue(self.lobby.can_start_game()) - - def test_can_start_game_method_wrong_status(self): - """Test can_start_game() returns False if lobby status is not 'waiting'.""" - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='ready') - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') - - self.lobby.status = 'playing' - self.lobby.save() - - self.assertFalse(self.lobby.can_start_game()) - - def test_get_active_players_method(self): - """Test get_active_players() returns only active players.""" - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user1, status='waiting') - LobbyPlayer.objects.create(lobby=self.lobby, user=self.user2, status='ready') - - left_user = User.objects.create_user(username="left", password="test") - LobbyPlayer.objects.create(lobby=self.lobby, user=left_user, status='left') - - active_players = self.lobby.get_active_players() - self.assertEqual(active_players.count(), 2) - - def test_lobby_ordering(self): - """Test that lobbies are ordered by creation date (newest first).""" - lobby2 = Lobby.objects.create( - owner=self.user2, - name="Newer Lobby", - status='waiting' - ) - - lobbies = list(Lobby.objects.all()) - self.assertEqual(lobbies[0], lobby2) - self.assertEqual(lobbies[1], self.lobby) - - def test_private_lobby_with_password(self): - """Test creating a private lobby with password hash.""" - private_lobby = Lobby.objects.create( - owner=self.user1, - name="Private Game", - is_private=True, - password_hash="hashed_password_here", - status='waiting' - ) - - self.assertTrue(private_lobby.is_private) - self.assertEqual(private_lobby.password_hash, "hashed_password_here") - - -class LobbySettingsModelTest(TestCase): - """Test suite for LobbySettings model.""" - - def setUp(self): - """Set up test data for LobbySettings tests.""" - self.user = User.objects.create_user(username="player1", password="test123") - self.lobby = Lobby.objects.create( - owner=self.user, - name="Test Lobby", - status='waiting' - ) - - self.settings = LobbySettings.objects.create( - lobby=self.lobby, - max_players=4, - card_count=36, - is_transferable=True, - neighbor_throw_only=False, - allow_jokers=False, - turn_time_limit=60 - ) - - def test_lobby_settings_creation(self): - """Test that LobbySettings instances are created correctly.""" - self.assertEqual(self.settings.max_players, 4) - self.assertEqual(self.settings.card_count, 36) - self.assertTrue(self.settings.is_transferable) - - def test_lobby_settings_str_representation(self): - """Test string representation of LobbySettings.""" - expected = "Test Lobby Settings (36 cards, 4 players)" - self.assertEqual(str(self.settings), expected) - - def test_has_time_limit_method(self): - """Test has_time_limit() method.""" - self.assertTrue(self.settings.has_time_limit()) - - # Test without time limit - settings_no_limit = LobbySettings.objects.create( - lobby=Lobby.objects.create(owner=self.user, name="No Limit", status='waiting'), - max_players=2, - card_count=24, - turn_time_limit=None - ) - self.assertFalse(settings_no_limit.has_time_limit()) - - def test_is_beginner_friendly_method_true(self): - """Test is_beginner_friendly() returns True for simple settings.""" - beginner_settings = LobbySettings.objects.create( - lobby=Lobby.objects.create(owner=self.user, name="Beginner", status='waiting'), - max_players=2, - card_count=24, - is_transferable=False, - neighbor_throw_only=False, - allow_jokers=False, - special_rule_set=None - ) - - self.assertTrue(beginner_settings.is_beginner_friendly()) - - def test_is_beginner_friendly_method_false_transferable(self): - """Test is_beginner_friendly() returns False with transferable cards.""" - self.assertFalse(self.settings.is_beginner_friendly()) - - def test_is_beginner_friendly_method_false_jokers(self): - """Test is_beginner_friendly() returns False with jokers.""" - settings = LobbySettings.objects.create( - lobby=Lobby.objects.create(owner=self.user, name="Jokers", status='waiting'), - max_players=2, - card_count=24, - is_transferable=False, - allow_jokers=True - ) - - self.assertFalse(settings.is_beginner_friendly()) - - def test_is_beginner_friendly_method_false_special_rules(self): - """Test is_beginner_friendly() returns False with special rule set.""" - special_rules = SpecialRuleSet.objects.create( - name="Advanced Rules", - description="Complex rules", - min_players=2 - ) - - settings = LobbySettings.objects.create( - lobby=Lobby.objects.create(owner=self.user, name="Special", status='waiting'), - max_players=2, - card_count=24, - is_transferable=False, - allow_jokers=False, - special_rule_set=special_rules - ) - - self.assertFalse(settings.is_beginner_friendly()) - - def test_card_count_choices(self): - """Test that only valid card counts are accepted.""" - for count in [24, 36, 52]: - settings = LobbySettings.objects.create( - lobby=Lobby.objects.create(owner=self.user, name=f"Lobby{count}", status='waiting'), - max_players=2, - card_count=count - ) - self.assertEqual(settings.card_count, count) - - -class LobbyPlayerModelTest(TestCase): - """Test suite for LobbyPlayer model.""" - - def setUp(self): - """Set up test data for LobbyPlayer tests.""" - self.user1 = User.objects.create_user(username="player1", password="test123") - self.user2 = User.objects.create_user(username="player2", password="test123") - - self.lobby = Lobby.objects.create( - owner=self.user1, - name="Test Lobby", - status='waiting' - ) - - self.lobby_player = LobbyPlayer.objects.create( - lobby=self.lobby, - user=self.user1, - status='waiting' - ) - - def test_lobby_player_creation(self): - """Test that LobbyPlayer instances are created correctly.""" - self.assertEqual(self.lobby_player.lobby, self.lobby) - self.assertEqual(self.lobby_player.user, self.user1) - self.assertEqual(self.lobby_player.status, 'waiting') - - def test_lobby_player_str_representation(self): - """Test string representation of LobbyPlayer.""" - expected = "player1 (waiting) in Test Lobby" - self.assertEqual(str(self.lobby_player), expected) - - def test_is_active_method(self): - """Test is_active() method for various statuses.""" - self.assertTrue(self.lobby_player.is_active()) - - self.lobby_player.status = 'ready' - self.assertTrue(self.lobby_player.is_active()) - - self.lobby_player.status = 'playing' - self.assertTrue(self.lobby_player.is_active()) - - self.lobby_player.status = 'left' - self.assertFalse(self.lobby_player.is_active()) - - def test_can_start_game_method(self): - """Test can_start_game() method.""" - self.assertFalse(self.lobby_player.can_start_game()) - - self.lobby_player.status = 'ready' - self.assertTrue(self.lobby_player.can_start_game()) - - self.lobby_player.status = 'playing' - self.assertFalse(self.lobby_player.can_start_game()) - - def test_leave_lobby_method(self): - """Test leave_lobby() method updates status.""" - self.lobby_player.leave_lobby() - - self.lobby_player.refresh_from_db() - self.assertEqual(self.lobby_player.status, 'left') - - def test_unique_together_constraint(self): - """Test that a user cannot join the same lobby twice.""" - with self.assertRaises(IntegrityError): - LobbyPlayer.objects.create( - lobby=self.lobby, - user=self.user1, - status='waiting' - ) - - def test_lobby_player_ordering(self): - """Test that lobby players are ordered by lobby and username.""" - user3 = User.objects.create_user(username="aaa_first", password="test") - - player2 = LobbyPlayer.objects.create( - lobby=self.lobby, - user=self.user2, - status='waiting' - ) - - player3 = LobbyPlayer.objects.create( - lobby=self.lobby, - user=user3, - status='waiting' - ) - - players = list(LobbyPlayer.objects.filter(lobby=self.lobby)) - self.assertEqual(players[0].user.username, "aaa_first") - self.assertEqual(players[1].user.username, "player1") - self.assertEqual(players[2].user.username, "player2") - - -class GameModelTest(TestCase): - """Test suite for Game model.""" - - def setUp(self): - """Set up test data for Game tests.""" - self.user1 = User.objects.create_user(username="player1", password="test123") - self.user2 = User.objects.create_user(username="player2", password="test123") - - self.lobby = Lobby.objects.create( - owner=self.user1, - name="Game Lobby", - status='playing' - ) - - # Create cards for trump - self.hearts = CardSuit.objects.create(name="Hearts", color="red") - self.ace = CardRank.objects.create(name="Ace", value=14) - self.trump_card = Card.objects.create(suit=self.hearts, rank=self.ace) - - self.game = Game.objects.create( - lobby=self.lobby, - trump_card=self.trump_card, - status='in_progress' - ) - - def test_game_creation(self): - """Test that Game instances are created correctly.""" - self.assertEqual(self.game.lobby, self.lobby) - self.assertEqual(self.game.trump_card, self.trump_card) - self.assertEqual(self.game.status, 'in_progress') - self.assertIsNone(self.game.finished_at) - self.assertIsNone(self.game.loser) - - def test_game_str_representation(self): - """Test string representation of Game.""" - expected = "Game in Game Lobby (in_progress)" - self.assertEqual(str(self.game), expected) - - def test_is_active_method(self): - """Test is_active() method.""" - self.assertTrue(self.game.is_active()) - - self.game.status = 'finished' - self.assertFalse(self.game.is_active()) - - def test_get_trump_suit_method(self): - """Test get_trump_suit() returns correct suit.""" - trump_suit = self.game.get_trump_suit() - self.assertEqual(trump_suit, self.hearts) - - def test_get_player_count_method(self): - """Test get_player_count() returns correct count.""" - self.assertEqual(self.game.get_player_count(), 0) - - GamePlayer.objects.create( - game=self.game, - user=self.user1, - seat_position=1, - cards_remaining=6 - ) - - GamePlayer.objects.create( - game=self.game, - user=self.user2, - seat_position=2, - cards_remaining=6 - ) - - self.assertEqual(self.game.get_player_count(), 2) - - def test_get_winner_method_active_game(self): - """Test get_winner() returns None for active games.""" - self.assertIsNone(self.game.get_winner()) - - def test_get_winner_method_finished_game(self): - """Test get_winner() returns winners after game finishes.""" - player1 = GamePlayer.objects.create( - game=self.game, - user=self.user1, - seat_position=1, - cards_remaining=0 - ) - - player2 = GamePlayer.objects.create( - game=self.game, - user=self.user2, - seat_position=2, - cards_remaining=3 - ) - - self.game.status = 'finished' - self.game.loser = self.user2 - self.game.finished_at = timezone.now() - self.game.save() - - winners = self.game.get_winner() - self.assertIsNotNone(winners) - self.assertEqual(winners.count(), 1) - self.assertEqual(winners.first().user, self.user1) - - def test_game_ordering(self): - """Test that games are ordered by start time (newest first).""" - game2 = Game.objects.create( - lobby=self.lobby, - trump_card=self.trump_card, - status='in_progress' - ) - - games = list(Game.objects.all()) - self.assertEqual(games[0], game2) - self.assertEqual(games[1], self.game) - - -class GamePlayerModelTest(TestCase): - """Test suite for GamePlayer model.""" - - def setUp(self): - """Set up test data for GamePlayer tests.""" - self.user = User.objects.create_user(username="player1", password="test123") - - lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - self.game_player = GamePlayer.objects.create( - game=self.game, - user=self.user, - seat_position=1, - cards_remaining=6 - ) - - def test_game_player_creation(self): - """Test that GamePlayer instances are created correctly.""" - self.assertEqual(self.game_player.game, self.game) - self.assertEqual(self.game_player.user, self.user) - self.assertEqual(self.game_player.seat_position, 1) - self.assertEqual(self.game_player.cards_remaining, 6) - - def test_game_player_str_representation(self): - """Test string representation of GamePlayer.""" - expected = "player1 (6 cards) - Position 1" - self.assertEqual(str(self.game_player), expected) - - def test_has_cards_method(self): - """Test has_cards() method.""" - self.assertTrue(self.game_player.has_cards()) - - self.game_player.cards_remaining = 0 - self.assertFalse(self.game_player.has_cards()) - - def test_is_eliminated_method(self): - """Test is_eliminated() method.""" - self.assertFalse(self.game_player.is_eliminated()) - - self.game_player.cards_remaining = 0 - self.assertTrue(self.game_player.is_eliminated()) - - def test_get_hand_cards_method(self): - """Test get_hand_cards() returns correct queryset.""" - # Create some cards - hearts = CardSuit.objects.create(name="Hearts", color="red") - seven = CardRank.objects.create(name="Seven", value=7) - card = Card.objects.create(suit=hearts, rank=seven) - - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=card, - order_in_hand=1 - ) - - hand_cards = self.game_player.get_hand_cards() - self.assertEqual(hand_cards.count(), 1) - self.assertEqual(hand_cards.first().card, card) - - def test_unique_together_constraint(self): - """Test that a user cannot be added to same game twice.""" - with self.assertRaises(IntegrityError): - GamePlayer.objects.create( - game=self.game, - user=self.user, - seat_position=2, - cards_remaining=6 - ) - - def test_game_player_ordering(self): - """Test that game players are ordered by seat position.""" - user2 = User.objects.create_user(username="player2", password="test") - user3 = User.objects.create_user(username="player3", password="test") - - player2 = GamePlayer.objects.create( - game=self.game, - user=user2, - seat_position=3, - cards_remaining=6 - ) - - player3 = GamePlayer.objects.create( - game=self.game, - user=user3, - seat_position=2, - cards_remaining=6 - ) - - players = list(GamePlayer.objects.filter(game=self.game)) - self.assertEqual(players[0].seat_position, 1) - self.assertEqual(players[1].seat_position, 2) - self.assertEqual(players[2].seat_position, 3) - - -class SpecialCardModelTest(TestCase): - """Test suite for SpecialCard model.""" - - def setUp(self): - """Set up test data for SpecialCard tests.""" - self.skip_card = SpecialCard.objects.create( - name="Skip Turn", - effect_type="skip", - effect_value={}, - description="Next player loses their turn" - ) - - self.draw_card = SpecialCard.objects.create( - name="Draw Two", - effect_type="draw", - effect_value={"card_count": 2}, - description="Target draws cards" - ) - - def test_special_card_creation(self): - """Test that SpecialCard instances are created correctly.""" - self.assertEqual(self.skip_card.name, "Skip Turn") - self.assertEqual(self.skip_card.effect_type, "skip") - self.assertEqual(self.skip_card.effect_value, {}) - - def test_special_card_str_representation(self): - """Test string representation of SpecialCard.""" - self.assertEqual(str(self.skip_card), "Skip Turn") - - def test_get_effect_description_with_card_count(self): - """Test get_effect_description() includes card count for draw effects.""" - description = self.draw_card.get_effect_description() - self.assertIn("2 cards", description) - - def test_get_effect_description_without_card_count(self): - """Test get_effect_description() returns base description.""" - description = self.skip_card.get_effect_description() - self.assertEqual(description, "Next player loses their turn") - - def test_is_targetable_method(self): - """Test is_targetable() for different effect types.""" - self.assertTrue(self.skip_card.is_targetable()) - self.assertTrue(self.draw_card.is_targetable()) - - reverse_card = SpecialCard.objects.create( - name="Reverse", - effect_type="reverse", - description="Reverse turn order" - ) - self.assertFalse(reverse_card.is_targetable()) - - def test_can_be_countered_default(self): - """Test can_be_countered() returns True by default.""" - self.assertTrue(self.skip_card.can_be_countered()) - - def test_can_be_countered_explicit(self): - """Test can_be_countered() respects effect_value setting.""" - uncounterable = SpecialCard.objects.create( - name="Unstoppable", - effect_type="custom", - effect_value={"counterable": False}, - description="Cannot be countered" - ) - - self.assertFalse(uncounterable.can_be_countered()) - - def test_special_card_ordering(self): - """Test that special cards are ordered by name.""" - cards = list(SpecialCard.objects.all()) - self.assertEqual(cards[0].name, "Draw Two") - self.assertEqual(cards[1].name, "Skip Turn") - - -class SpecialRuleSetModelTest(TestCase): - """Test suite for SpecialRuleSet model.""" - - def setUp(self): - """Set up test data for SpecialRuleSet tests.""" - self.rule_set = SpecialRuleSet.objects.create( - name="Beginner Special", - description="Simple special cards for new players", - min_players=2 - ) - - self.special_card = SpecialCard.objects.create( - name="Skip Turn", - effect_type="skip", - description="Skip next turn" - ) - - def test_special_rule_set_creation(self): - """Test that SpecialRuleSet instances are created correctly.""" - self.assertEqual(self.rule_set.name, "Beginner Special") - self.assertEqual(self.rule_set.min_players, 2) - - def test_special_rule_set_str_representation(self): - """Test string representation of SpecialRuleSet.""" - self.assertEqual(str(self.rule_set), "Beginner Special") - - def test_get_special_card_count_method(self): - """Test get_special_card_count() returns correct count.""" - self.assertEqual(self.rule_set.get_special_card_count(), 0) - - SpecialRuleSetCard.objects.create( - rule_set=self.rule_set, - card=self.special_card, - is_enabled=True - ) - - self.assertEqual(self.rule_set.get_special_card_count(), 1) - - def test_is_compatible_with_player_count_method(self): - """Test is_compatible_with_player_count() validation.""" - self.assertTrue(self.rule_set.is_compatible_with_player_count(2)) - self.assertTrue(self.rule_set.is_compatible_with_player_count(4)) - self.assertFalse(self.rule_set.is_compatible_with_player_count(1)) - - def test_get_enabled_special_cards_method(self): - """Test get_enabled_special_cards() returns only enabled cards.""" - enabled_card = SpecialCard.objects.create( - name="Enabled", - effect_type="skip", - description="Enabled card" - ) - - disabled_card = SpecialCard.objects.create( - name="Disabled", - effect_type="skip", - description="Disabled card" - ) - - SpecialRuleSetCard.objects.create( - rule_set=self.rule_set, - card=enabled_card, - is_enabled=True - ) - - SpecialRuleSetCard.objects.create( - rule_set=self.rule_set, - card=disabled_card, - is_enabled=False - ) - - enabled_cards = self.rule_set.get_enabled_special_cards() - self.assertEqual(enabled_cards.count(), 1) - self.assertEqual(enabled_cards.first(), enabled_card) - - def test_can_be_used_in_lobby_method_compatible(self): - """Test can_be_used_in_lobby() with compatible settings.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - allow_jokers=True - ) - - self.assertTrue(self.rule_set.can_be_used_in_lobby(settings)) - - def test_can_be_used_in_lobby_method_no_jokers(self): - """Test can_be_used_in_lobby() fails without jokers enabled.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - allow_jokers=False - ) - - self.assertFalse(self.rule_set.can_be_used_in_lobby(settings)) - - def test_can_be_used_in_lobby_method_insufficient_players(self): - """Test can_be_used_in_lobby() fails with too few players.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='waiting') - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=1, # Less than min_players - card_count=36, - allow_jokers=True - ) - - self.assertFalse(self.rule_set.can_be_used_in_lobby(settings)) - - -class SpecialRuleSetCardModelTest(TestCase): - """Test suite for SpecialRuleSetCard model.""" - - def setUp(self): - """Set up test data for SpecialRuleSetCard tests.""" - self.rule_set = SpecialRuleSet.objects.create( - name="Test Rules", - description="Test rule set", - min_players=2 - ) - - self.special_card = SpecialCard.objects.create( - name="Skip Turn", - effect_type="skip", - description="Skip turn" - ) - - self.rule_set_card = SpecialRuleSetCard.objects.create( - rule_set=self.rule_set, - card=self.special_card, - is_enabled=True - ) - - def test_special_rule_set_card_creation(self): - """Test that SpecialRuleSetCard instances are created correctly.""" - self.assertEqual(self.rule_set_card.rule_set, self.rule_set) - self.assertEqual(self.rule_set_card.card, self.special_card) - self.assertTrue(self.rule_set_card.is_enabled) - - def test_special_rule_set_card_str_representation(self): - """Test string representation of SpecialRuleSetCard.""" - expected = "Skip Turn in Test Rules (enabled)" - self.assertEqual(str(self.rule_set_card), expected) - - self.rule_set_card.is_enabled = False - expected_disabled = "Skip Turn in Test Rules (disabled)" - self.assertEqual(str(self.rule_set_card), expected_disabled) - - def test_toggle_enabled_method(self): - """Test toggle_enabled() switches enabled status.""" - self.assertTrue(self.rule_set_card.is_enabled) - - result = self.rule_set_card.toggle_enabled() - self.assertFalse(result) - self.rule_set_card.refresh_from_db() - self.assertFalse(self.rule_set_card.is_enabled) - - result = self.rule_set_card.toggle_enabled() - self.assertTrue(result) - self.rule_set_card.refresh_from_db() - self.assertTrue(self.rule_set_card.is_enabled) - - def test_can_be_used_in_game_enabled(self): - """Test can_be_used_in_game() with enabled card.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - allow_jokers=True, - special_rule_set=self.rule_set - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump = Card.objects.create(suit=hearts, rank=ace) - - game = Game.objects.create( - lobby=lobby, - trump_card=trump, - status='in_progress' - ) - - self.assertTrue(self.rule_set_card.can_be_used_in_game(game)) - - def test_can_be_used_in_game_disabled(self): - """Test can_be_used_in_game() with disabled card.""" - self.rule_set_card.is_enabled = False - self.rule_set_card.save() - - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - allow_jokers=True, - special_rule_set=self.rule_set - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump = Card.objects.create(suit=hearts, rank=ace) - - game = Game.objects.create( - lobby=lobby, - trump_card=trump, - status='in_progress' - ) - - self.assertFalse(self.rule_set_card.can_be_used_in_game(game)) - - def test_can_be_used_in_game_no_jokers(self): - """Test can_be_used_in_game() fails without jokers.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - settings = LobbySettings.objects.create( - lobby=lobby, - max_players=4, - card_count=36, - allow_jokers=False, - special_rule_set=self.rule_set - ) - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump = Card.objects.create(suit=hearts, rank=ace) - - game = Game.objects.create( - lobby=lobby, - trump_card=trump, - status='in_progress' - ) - - self.assertFalse(self.rule_set_card.can_be_used_in_game(game)) - - def test_unique_together_constraint(self): - """Test that card can only be added to rule set once.""" - with self.assertRaises(IntegrityError): - SpecialRuleSetCard.objects.create( - rule_set=self.rule_set, - card=self.special_card, - is_enabled=False - ) - - -class GameDeckModelTest(TestCase): - """Test suite for GameDeck model.""" - - def setUp(self): - """Set up test data for GameDeck tests.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - self.ace = CardRank.objects.create(name="Ace", value=14) - self.seven = CardRank.objects.create(name="Seven", value=7) - - self.trump_card = Card.objects.create(suit=hearts, rank=self.ace) - self.deck_card = Card.objects.create(suit=hearts, rank=self.seven) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=self.trump_card, - status='in_progress' - ) - - self.game_deck = GameDeck.objects.create( - game=self.game, - card=self.deck_card, - position=1 - ) - - def test_game_deck_creation(self): - """Test that GameDeck instances are created correctly.""" - self.assertEqual(self.game_deck.game, self.game) - self.assertEqual(self.game_deck.card, self.deck_card) - self.assertEqual(self.game_deck.position, 1) - - def test_game_deck_str_representation(self): - """Test string representation of GameDeck.""" - self.assertIn("Seven of Hearts", str(self.game_deck)) - self.assertIn("position 1", str(self.game_deck)) - - def test_get_top_card_method(self): - """Test get_top_card() returns lowest position card.""" - spades = CardSuit.objects.create(name="Spades", color="black") - card2 = Card.objects.create(suit=spades, rank=self.ace) - - GameDeck.objects.create( - game=self.game, - card=card2, - position=2 - ) - - top_card = GameDeck.get_top_card(self.game) - self.assertEqual(top_card.position, 1) - self.assertEqual(top_card.card, self.deck_card) - - def test_get_top_card_empty_deck(self): - """Test get_top_card() returns None for empty deck.""" - self.game_deck.delete() - - top_card = GameDeck.get_top_card(self.game) - self.assertIsNone(top_card) - - def test_draw_card_method(self): - """Test draw_card() removes and returns top card.""" - drawn_card = GameDeck.draw_card(self.game) - - self.assertEqual(drawn_card, self.deck_card) - self.assertEqual(GameDeck.objects.filter(game=self.game).count(), 0) - - def test_draw_card_empty_deck(self): - """Test draw_card() returns None for empty deck.""" - self.game_deck.delete() - - drawn_card = GameDeck.draw_card(self.game) - self.assertIsNone(drawn_card) - - def test_is_last_card_method(self): - """Test is_last_card() detection.""" - self.assertTrue(self.game_deck.is_last_card()) - - spades = CardSuit.objects.create(name="Spades", color="black") - card2 = Card.objects.create(suit=spades, rank=self.ace) - - GameDeck.objects.create( - game=self.game, - card=card2, - position=2 - ) - - self.assertFalse(self.game_deck.is_last_card()) - - def test_game_deck_ordering(self): - """Test that deck cards are ordered by position.""" - spades = CardSuit.objects.create(name="Spades", color="black") - king = CardRank.objects.create(name="King", value=13) - - card2 = Card.objects.create(suit=spades, rank=king) - card3 = Card.objects.create(suit=spades, rank=self.seven) - - GameDeck.objects.create(game=self.game, card=card3, position=3) - GameDeck.objects.create(game=self.game, card=card2, position=2) - - deck_cards = list(GameDeck.objects.filter(game=self.game)) - self.assertEqual(deck_cards[0].position, 1) - self.assertEqual(deck_cards[1].position, 2) - self.assertEqual(deck_cards[2].position, 3) - - def test_unique_together_constraint(self): - """Test that game and position combination is unique.""" - spades = CardSuit.objects.create(name="Spades", color="black") - card2 = Card.objects.create(suit=spades, rank=self.ace) - - with self.assertRaises(IntegrityError): - GameDeck.objects.create( - game=self.game, - card=card2, - position=1 # Same position as existing card - ) - - -class PlayerHandModelTest(TestCase): - """Test suite for PlayerHand model.""" - - def setUp(self): - """Set up test data for PlayerHand tests.""" - self.user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - - trump_card = Card.objects.create(suit=hearts, rank=ace) - self.hand_card = Card.objects.create( - suit=hearts, - rank=CardRank.objects.create(name="Seven", value=7) - ) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - self.game_player = GamePlayer.objects.create( - game=self.game, - user=self.user, - seat_position=1, - cards_remaining=6 - ) - - self.player_hand = PlayerHand.objects.create( - game=self.game, - player=self.user, - card=self.hand_card, - order_in_hand=1 - ) - - def test_player_hand_creation(self): - """Test that PlayerHand instances are created correctly.""" - self.assertEqual(self.player_hand.game, self.game) - self.assertEqual(self.player_hand.player, self.user) - self.assertEqual(self.player_hand.card, self.hand_card) - self.assertEqual(self.player_hand.order_in_hand, 1) - - def test_player_hand_str_representation(self): - """Test string representation of PlayerHand.""" - self.assertIn("player", str(self.player_hand)) - self.assertIn("Seven of Hearts", str(self.player_hand)) - - def test_get_player_hand_method(self): - """Test get_player_hand() returns all cards for player.""" - spades = CardSuit.objects.create(name="Spades", color="black") - king = CardRank.objects.create(name="King", value=13) - card2 = Card.objects.create(suit=spades, rank=king) - - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=card2, - order_in_hand=2 - ) - - hand = PlayerHand.get_player_hand(self.game, self.user) - self.assertEqual(hand.count(), 2) - self.assertEqual(hand.first().order_in_hand, 1) - self.assertEqual(hand.last().order_in_hand, 2) - - def test_get_hand_size_method(self): - """Test get_hand_size() returns correct count.""" - self.assertEqual(PlayerHand.get_hand_size(self.game, self.user), 1) - - spades = CardSuit.objects.create(name="Spades", color="black") - king = CardRank.objects.create(name="King", value=13) - card2 = Card.objects.create(suit=spades, rank=king) - - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=card2, - order_in_hand=2 - ) - - self.assertEqual(PlayerHand.get_hand_size(self.game, self.user), 2) - - def test_remove_from_hand_method(self): - """Test remove_from_hand() deletes card and updates counter.""" - self.player_hand.remove_from_hand() - - self.assertEqual(PlayerHand.objects.filter(game=self.game, player=self.user).count(), 0) - - self.game_player.refresh_from_db() - self.assertEqual(self.game_player.cards_remaining, 5) - - def test_remove_from_hand_prevents_negative(self): - """Test remove_from_hand() doesn't go below zero cards.""" - self.game_player.cards_remaining = 0 - self.game_player.save() - - self.player_hand.remove_from_hand() - - self.game_player.refresh_from_db() - self.assertEqual(self.game_player.cards_remaining, 0) - - def test_unique_together_constraint(self): - """Test that same card cannot be in player's hand twice.""" - with self.assertRaises(IntegrityError): - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=self.hand_card, - order_in_hand=2 - ) - - def test_player_hand_ordering(self): - """Test that hand cards are ordered by order_in_hand.""" - spades = CardSuit.objects.create(name="Spades", color="black") - king = CardRank.objects.create(name="King", value=13) - queen = CardRank.objects.create(name="Queen", value=12) - - card2 = Card.objects.create(suit=spades, rank=king) - card3 = Card.objects.create(suit=spades, rank=queen) - - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=card3, - order_in_hand=3 - ) - - PlayerHand.objects.create( - game=self.game, - player=self.user, - card=card2, - order_in_hand=2 - ) - - hand = list(PlayerHand.objects.filter(game=self.game, player=self.user)) - self.assertEqual(hand[0].order_in_hand, 1) - self.assertEqual(hand[1].order_in_hand, 2) - self.assertEqual(hand[2].order_in_hand, 3) - - -class TableCardModelTest(TestCase): - """Test suite for TableCard model.""" - - def setUp(self): - """Set up test data for TableCard tests.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - self.hearts = CardSuit.objects.create(name="Hearts", color="red") - self.spades = CardSuit.objects.create(name="Spades", color="black") - - ace = CardRank.objects.create(name="Ace", value=14) - seven = CardRank.objects.create(name="Seven", value=7) - ten = CardRank.objects.create(name="Ten", value=10) - - self.trump_card = Card.objects.create(suit=self.hearts, rank=ace) - self.attack_card = Card.objects.create(suit=self.hearts, rank=seven) - self.defense_card = Card.objects.create(suit=self.hearts, rank=ten) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=self.trump_card, - status='in_progress' - ) - - self.table_card = TableCard.objects.create( - game=self.game, - attack_card=self.attack_card - ) - - def test_table_card_creation(self): - """Test that TableCard instances are created correctly.""" - self.assertEqual(self.table_card.game, self.game) - self.assertEqual(self.table_card.attack_card, self.attack_card) - self.assertIsNone(self.table_card.defense_card) - - def test_table_card_str_undefended(self): - """Test string representation of undefended table card.""" - self.assertIn("Seven of Hearts", str(self.table_card)) - self.assertIn("undefended", str(self.table_card)) - - def test_table_card_str_defended(self): - """Test string representation of defended table card.""" - self.table_card.defense_card = self.defense_card - self.table_card.save() - - self.assertIn("Seven of Hearts", str(self.table_card)) - self.assertIn("defended by", str(self.table_card)) - self.assertIn("Ten of Hearts", str(self.table_card)) - - def test_is_defended_method(self): - """Test is_defended() method.""" - self.assertFalse(self.table_card.is_defended()) - - self.table_card.defense_card = self.defense_card - self.assertTrue(self.table_card.is_defended()) - - def test_is_valid_defense_same_suit_higher_rank(self): - """Test is_valid_defense() with valid same-suit defense.""" - trump_suit = self.spades - - is_valid = self.table_card.is_valid_defense(self.defense_card, trump_suit) - self.assertTrue(is_valid) - - def test_is_valid_defense_same_suit_lower_rank(self): - """Test is_valid_defense() fails with lower rank.""" - trump_suit = self.spades - - six = CardRank.objects.create(name="Six", value=6) - weak_defense = Card.objects.create(suit=self.hearts, rank=six) - - is_valid = self.table_card.is_valid_defense(weak_defense, trump_suit) - self.assertFalse(is_valid) - - def test_is_valid_defense_trump_vs_non_trump(self): - """Test is_valid_defense() with trump card defending non-trump.""" - trump_suit = self.spades - - six = CardRank.objects.create(name="Six", value=6) - trump_defense = Card.objects.create(suit=self.spades, rank=six) - - is_valid = self.table_card.is_valid_defense(trump_defense, trump_suit) - self.assertTrue(is_valid) - - def test_is_valid_defense_already_defended(self): - """Test is_valid_defense() returns False if already defended.""" - self.table_card.defense_card = self.defense_card - self.table_card.save() - - ace = CardRank.objects.create(name="Ace", value=14) - another_defense = Card.objects.create(suit=self.hearts, rank=ace) - - is_valid = self.table_card.is_valid_defense(another_defense, self.hearts) - self.assertFalse(is_valid) - - def test_defend_with_valid_defense(self): - """Test defend_with() successfully defends with valid card.""" - trump_suit = self.spades - - result = self.table_card.defend_with(self.defense_card, trump_suit) - - self.assertTrue(result) - self.table_card.refresh_from_db() - self.assertEqual(self.table_card.defense_card, self.defense_card) - - def test_defend_with_invalid_defense(self): - """Test defend_with() fails with invalid card.""" - trump_suit = self.spades - - six = CardRank.objects.create(name="Six", value=6) - weak_defense = Card.objects.create(suit=self.hearts, rank=six) - - result = self.table_card.defend_with(weak_defense, trump_suit) - - self.assertFalse(result) - self.table_card.refresh_from_db() - self.assertIsNone(self.table_card.defense_card) - - -class DiscardPileModelTest(TestCase): - """Test suite for DiscardPile model.""" - - def setUp(self): - """Set up test data for DiscardPile tests.""" - user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=user, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - seven = CardRank.objects.create(name="Seven", value=7) - - trump_card = Card.objects.create(suit=hearts, rank=ace) - self.discarded_card = Card.objects.create(suit=hearts, rank=seven) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - self.discard_pile = DiscardPile.objects.create( - game=self.game, - card=self.discarded_card, - position=1 - ) - - def test_discard_pile_creation(self): - """Test that DiscardPile instances are created correctly.""" - self.assertEqual(self.discard_pile.game, self.game) - self.assertEqual(self.discard_pile.card, self.discarded_card) - self.assertEqual(self.discard_pile.position, 1) - - def test_discard_pile_str_with_position(self): - """Test string representation with position.""" - self.assertIn("Discarded", str(self.discard_pile)) - self.assertIn("Seven of Hearts", str(self.discard_pile)) - self.assertIn("position 1", str(self.discard_pile)) - - def test_discard_pile_str_without_position(self): - """Test string representation without position.""" - discard = DiscardPile.objects.create( - game=self.game, - card=self.discarded_card, - position=None - ) - - result = str(discard) - self.assertIn("Discarded", result) - self.assertIn("Seven of Hearts", result) - - -class TurnModelTest(TestCase): - """Test suite for Turn model.""" - - def setUp(self): - """Set up test data for Turn tests.""" - self.user1 = User.objects.create_user(username="player1", password="test") - self.user2 = User.objects.create_user(username="player2", password="test") - - lobby = Lobby.objects.create(owner=self.user1, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - trump_card = Card.objects.create(suit=hearts, rank=ace) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - self.turn = Turn.objects.create( - game=self.game, - player=self.user1, - turn_number=1 - ) - - def test_turn_creation(self): - """Test that Turn instances are created correctly.""" - self.assertEqual(self.turn.game, self.game) - self.assertEqual(self.turn.player, self.user1) - self.assertEqual(self.turn.turn_number, 1) - - def test_turn_str_representation(self): - """Test string representation of Turn.""" - expected = "Turn 1: player1" - self.assertIn(expected, str(self.turn)) - - def test_get_moves_method(self): - """Test get_moves() returns all moves for turn.""" - # Initially no moves - self.assertEqual(self.turn.get_moves().count(), 0) - - # Create a move - seven = CardRank.objects.create(name="Seven", value=7) - attack_card = Card.objects.create( - suit=CardSuit.objects.create(name="Spades", color="black"), - rank=seven - ) - - table_card = TableCard.objects.create( - game=self.game, - attack_card=attack_card - ) - - Move.objects.create( - turn=self.turn, - table_card=table_card, - action_type='attack' - ) - - self.assertEqual(self.turn.get_moves().count(), 1) - - def test_is_complete_method(self): - """Test is_complete() method.""" - self.assertFalse(self.turn.is_complete()) - - # Add a move - seven = CardRank.objects.create(name="Seven", value=7) - attack_card = Card.objects.create( - suit=CardSuit.objects.create(name="Spades", color="black"), - rank=seven - ) - - table_card = TableCard.objects.create( - game=self.game, - attack_card=attack_card - ) - - Move.objects.create( - turn=self.turn, - table_card=table_card, - action_type='attack' - ) - - self.assertTrue(self.turn.is_complete()) - - def test_get_current_turn_method(self): - """Test get_current_turn() returns most recent turn.""" - current = Turn.get_current_turn(self.game) - self.assertEqual(current, self.turn) - - # Create a newer turn - turn2 = Turn.objects.create( - game=self.game, - player=self.user2, - turn_number=2 - ) - - current = Turn.get_current_turn(self.game) - self.assertEqual(current, turn2) - - def test_get_current_turn_no_turns(self): - """Test get_current_turn() returns None for game without turns.""" - # Create new game without turns - lobby = Lobby.objects.create(owner=self.user1, name="New", status='playing') - hearts = CardSuit.objects.create(name="Diamonds", color="red") - ace = CardRank.objects.create(name="King", value=13) - trump = Card.objects.create(suit=hearts, rank=ace) - - new_game = Game.objects.create( - lobby=lobby, - trump_card=trump, - status='in_progress' - ) - - current = Turn.get_current_turn(new_game) - self.assertIsNone(current) - - def test_create_next_turn_method(self): - """Test create_next_turn() creates sequential turns.""" - next_turn = Turn.create_next_turn(self.game, self.user2) - - self.assertEqual(next_turn.turn_number, 2) - self.assertEqual(next_turn.player, self.user2) - self.assertEqual(next_turn.game, self.game) - - def test_unique_together_constraint(self): - """Test that game and turn_number combination is unique.""" - with self.assertRaises(IntegrityError): - Turn.objects.create( - game=self.game, - player=self.user2, - turn_number=1 # Same as existing turn - ) - - def test_turn_ordering(self): - """Test that turns are ordered by turn_number.""" - turn2 = Turn.objects.create( - game=self.game, - player=self.user2, - turn_number=2 - ) - - turn3 = Turn.objects.create( - game=self.game, - player=self.user1, - turn_number=3 - ) - - turns = list(Turn.objects.filter(game=self.game)) - self.assertEqual(turns[0].turn_number, 1) - self.assertEqual(turns[1].turn_number, 2) - self.assertEqual(turns[2].turn_number, 3) - - -class MoveModelTest(TestCase): - """Test suite for Move model.""" - - def setUp(self): - """Set up test data for Move tests.""" - self.user = User.objects.create_user(username="player", password="test") - lobby = Lobby.objects.create(owner=self.user, name="Test", status='playing') - - hearts = CardSuit.objects.create(name="Hearts", color="red") - ace = CardRank.objects.create(name="Ace", value=14) - seven = CardRank.objects.create(name="Seven", value=7) - - trump_card = Card.objects.create(suit=hearts, rank=ace) - attack_card = Card.objects.create(suit=hearts, rank=seven) - - self.game = Game.objects.create( - lobby=lobby, - trump_card=trump_card, - status='in_progress' - ) - - self.turn = Turn.objects.create( - game=self.game, - player=self.user, - turn_number=1 - ) - - self.table_card = TableCard.objects.create( - game=self.game, - attack_card=attack_card - ) - - self.move = Move.objects.create( - turn=self.turn, - table_card=self.table_card, - action_type='attack' - ) - - def test_move_creation(self): - """Test that Move instances are created correctly.""" - self.assertEqual(self.move.turn, self.turn) - self.assertEqual(self.move.table_card, self.table_card) - self.assertEqual(self.move.action_type, 'attack') - - def test_move_str_representation(self): - """Test string representation of Move.""" - result = str(self.move) - self.assertIn("Attack", result) - self.assertIn("player", result) - self.assertIn("Seven of Hearts", result) - - def test_get_player_method(self): - """Test get_player() returns correct user.""" - player = self.move.get_player() - self.assertEqual(player, self.user) - - def test_is_attack_method(self): - """Test is_attack() method.""" - self.assertTrue(self.move.is_attack()) - - self.move.action_type = 'defend' - self.assertFalse(self.move.is_attack()) - - def test_is_defense_method(self): - """Test is_defense() method.""" - self.assertFalse(self.move.is_defense()) - - self.move.action_type = 'defend' - self.assertTrue(self.move.is_defense()) - - def test_is_pickup_method(self): - """Test is_pickup() method.""" - self.assertFalse(self.move.is_pickup()) - - self.move.action_type = 'pickup' - self.assertTrue(self.move.is_pickup()) - - def test_get_game_moves_method(self): - """Test get_game_moves() returns all moves for game.""" - # Create another move - ten = CardRank.objects.create(name="Ten", value=10) - defense_card = Card.objects.create( - suit=CardSuit.objects.first(), - rank=ten - ) - - self.table_card.defense_card = defense_card - self.table_card.save() - - move2 = Move.objects.create( - turn=self.turn, - table_card=self.table_card, - action_type='defend' - ) - - moves = Move.get_game_moves(self.game) - self.assertEqual(moves.count(), 2) - - def test_get_player_moves_method(self): - """Test get_player_moves() returns moves for specific player.""" - user2 = User.objects.create_user(username="player2", password="test") - - turn2 = Turn.objects.create( - game=self.game, - player=user2, - turn_number=2 - ) - - spades = CardSuit.objects.create(name="Spades", color="black") - king = CardRank.objects.create(name="King", value=13) - card2 = Card.objects.create(suit=spades, rank=king) - - table_card2 = TableCard.objects.create( - game=self.game, - attack_card=card2 - ) - - Move.objects.create( - turn=turn2, - table_card=table_card2, - action_type='attack' - ) - - # Get moves for user1 - user1_moves = Move.get_player_moves(self.game, self.user) - self.assertEqual(user1_moves.count(), 1) - self.assertEqual(user1_moves.first().get_player(), self.user) - - # Get moves for user2 - user2_moves = Move.get_player_moves(self.game, user2) - self.assertEqual(user2_moves.count(), 1) - self.assertEqual(user2_moves.first().get_player(), user2) - - def test_move_ordering(self): - """Test that moves are ordered by creation time.""" - # Create another move slightly later - ten = CardRank.objects.create(name="Ten", value=10) - defense_card = Card.objects.create( - suit=CardSuit.objects.first(), - rank=ten - ) - - self.table_card.defense_card = defense_card - self.table_card.save() - - move2 = Move.objects.create( - turn=self.turn, - table_card=self.table_card, - action_type='defend' - ) - - moves = list(Move.objects.filter(turn=self.turn)) - self.assertEqual(moves[0], self.move) - self.assertEqual(moves[1], move2) - - def test_action_type_choices(self): - """Test all action type choices are valid.""" - for action_type, _ in Move.ACTION_CHOICES: - move = Move.objects.create( - turn=self.turn, - table_card=self.table_card, - action_type=action_type - ) - self.assertEqual(move.action_type, action_type) diff --git a/game/tests/__init__.py b/game/tests/__init__.py new file mode 100644 index 0000000..b5e4f5b --- /dev/null +++ b/game/tests/__init__.py @@ -0,0 +1,5 @@ +"""Test suite for game app. + +This package contains comprehensive tests for all game-related models, +including cards, lobbies, games, players, turns, and special rules. +""" diff --git a/game/tests/test_card_models.py b/game/tests/test_card_models.py new file mode 100644 index 0000000..8cc9028 --- /dev/null +++ b/game/tests/test_card_models.py @@ -0,0 +1,315 @@ +"""Tests for card-related models: CardSuit, CardRank, and Card. + +This module tests the creation, methods, and relationships of card components +used in the game, including basic card properties, trump logic, and special cards. +""" + +import pytest +from django.db import IntegrityError +from game.models import CardSuit, CardRank, Card, SpecialCard + + +@pytest.mark.django_db +class TestCardSuitModel: + """Test suite for CardSuit model.""" + + def test_card_suit_creation(self): + """Test that CardSuit instances are created correctly with name and color.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert hearts.name == "Hearts" + assert hearts.color == "red" + assert spades.color == "black" + + def test_card_suit_str_representation(self): + """Test string representation returns suit name.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert str(hearts) == "Hearts" + assert str(spades) == "Spades" + + def test_is_red_method(self): + """Test is_red() method returns correct boolean based on color.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + + assert hearts.is_red() is True + assert spades.is_red() is False + + def test_card_suit_ordering(self): + """Test that CardSuit instances are ordered alphabetically by name.""" + hearts = CardSuit.objects.create(name="Hearts", color="red") + spades = CardSuit.objects.create(name="Spades", color="black") + diamonds = CardSuit.objects.create(name="Diamonds", color="red") + clubs = CardSuit.objects.create(name="Clubs", color="black") + + suits = list(CardSuit.objects.all()) + + assert suits[0].name == "Clubs" + assert suits[1].name == "Diamonds" + assert suits[2].name == "Hearts" + assert suits[3].name == "Spades" + + def test_card_suit_color_choices(self): + """Test that only valid color choices (red/black) are accepted.""" + # Valid colors should work + valid_suit = CardSuit.objects.create(name="Test", color="red") + assert valid_suit.color == "red" + + valid_suit_black = CardSuit.objects.create(name="Test2", color="black") + assert valid_suit_black.color == "black" + + +@pytest.mark.django_db +class TestCardRankModel: + """Test suite for CardRank model.""" + + def test_card_rank_creation(self): + """Test that CardRank instances are created correctly with name and value.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + + assert ace.name == "Ace" + assert ace.value == 14 + assert king.value == 13 + + def test_card_rank_str_representation(self): + """Test string representation returns rank name.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + + assert str(ace) == "Ace" + assert str(king) == "King" + + def test_is_face_card_method(self): + """Test is_face_card() method identifies Jack, Queen, and King. + + Face cards are defined as cards with values 11-13: + - Jack (11), Queen (12), King (13) are face cards + - Ace (14) and number cards are not face cards + """ + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + queen = CardRank.objects.create(name="Queen", value=12) + jack = CardRank.objects.create(name="Jack", value=11) + six = CardRank.objects.create(name="Six", value=6) + + assert king.is_face_card() is True + assert queen.is_face_card() is True + assert jack.is_face_card() is True + assert ace.is_face_card() is False + assert six.is_face_card() is False + + def test_card_rank_ordering(self): + """Test that CardRank instances are ordered by value ascending.""" + ace = CardRank.objects.create(name="Ace", value=14) + king = CardRank.objects.create(name="King", value=13) + jack = CardRank.objects.create(name="Jack", value=11) + six = CardRank.objects.create(name="Six", value=6) + + ranks = list(CardRank.objects.all()) + + assert ranks[0].value == 6 + assert ranks[1].value == 11 + assert ranks[2].value == 13 + assert ranks[3].value == 14 + + +@pytest.mark.django_db +class TestCardModel: + """Test suite for Card model.""" + + def test_card_creation(self, card_suits, card_ranks): + """Test that Card instances are created correctly with suit and rank.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + + assert ace_of_hearts.suit == card_suits['hearts'] + assert ace_of_hearts.rank == card_ranks['ace'] + assert ace_of_hearts.special_card is None + + def test_card_str_representation(self, card_suits, card_ranks): + """Test string representation formats as 'Rank of Suit'.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + assert str(ace_of_hearts) == "Ace of Hearts" + assert str(king_of_spades) == "King of Spades" + + def test_card_str_with_special_card(self, card_suits, card_ranks): + """Test string representation includes special card name in parentheses.""" + special = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + description="Skip next player's turn" + ) + special_card = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['ace'], + special_card=special + ) + + assert str(special_card) == "Ace of Spades (Skip Turn)" + + def test_is_trump_method(self, card_suits, card_ranks): + """Test is_trump() method correctly identifies trump suit cards.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + # Hearts is trump + assert ace_of_hearts.is_trump(card_suits['hearts']) is True + assert ace_of_hearts.is_trump(card_suits['spades']) is False + + # Spades is trump + assert king_of_spades.is_trump(card_suits['spades']) is True + assert king_of_spades.is_trump(card_suits['hearts']) is False + + def test_is_special_method(self, card_suits, card_ranks): + """Test is_special() method identifies cards with special effects.""" + normal_card = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + + special = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + description="Draw 2 cards" + ) + special_card = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'], + special_card=special + ) + + assert normal_card.is_special() is False + assert special_card.is_special() is True + + def test_can_beat_trump_vs_non_trump(self, card_suits, card_ranks): + """Test that any trump card beats any non-trump card. + + In the game, trump cards always beat non-trump cards regardless of rank. + Even a low-value trump (e.g., Seven of Hearts) beats a high-value + non-trump (e.g., King of Spades) when Hearts is trump. + """ + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + king_of_spades = Card.objects.create( + suit=card_suits['spades'], + rank=card_ranks['king'] + ) + + # Hearts is trump + trump_suit = card_suits['hearts'] + + # Low trump beats high non-trump + assert seven_of_hearts.can_beat(king_of_spades, trump_suit) is True + + # Non-trump cannot beat trump + assert king_of_spades.can_beat(seven_of_hearts, trump_suit) is False + + def test_can_beat_same_suit(self, card_suits, card_ranks): + """Test card comparison within the same suit uses rank value.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + + trump_suit = card_suits['spades'] # Neither Hearts card is trump + + # Higher rank beats lower rank in same suit + assert ace_of_hearts.can_beat(seven_of_hearts, trump_suit) is True + + # Lower rank cannot beat higher rank + assert seven_of_hearts.can_beat(ace_of_hearts, trump_suit) is False + + def test_can_beat_different_non_trump_suits(self, card_suits, card_ranks): + """Test that cards of different non-trump suits cannot beat each other. + + In the game, you can only beat a card with: + 1. A higher card of the same suit, OR + 2. Any trump card (if the original card is not trump) + + Cards of different non-trump suits cannot beat each other. + """ + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + six_of_diamonds = Card.objects.create( + suit=card_suits['diamonds'], + rank=card_ranks['six'] + ) + + trump_suit = card_suits['spades'] # Hearts and Diamonds are both non-trump + + # Different non-trump suits cannot beat each other + assert ace_of_hearts.can_beat(six_of_diamonds, trump_suit) is False + assert six_of_diamonds.can_beat(ace_of_hearts, trump_suit) is False + + def test_can_beat_trump_vs_trump(self, card_suits, card_ranks): + """Test trump card comparison uses rank value.""" + ace_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'] + ) + seven_of_hearts = Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['seven'] + ) + + trump_suit = card_suits['hearts'] # Both cards are trump + + # Higher trump beats lower trump + assert ace_of_hearts.can_beat(seven_of_hearts, trump_suit) is True + assert seven_of_hearts.can_beat(ace_of_hearts, trump_suit) is False + + def test_card_unique_together_constraint(self, card_suits, card_ranks): + """Test that cards with same suit, rank, and special_card are unique. + + The database enforces uniqueness on the combination of suit, rank, + and special_card to prevent duplicate cards in the system. + """ + # Create a special card first + special = SpecialCard.objects.create( + name="Test Special", + effect_type="skip", + description="Test" + ) + + # Create first card with special_card + Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'], + special_card=special + ) + + # Creating duplicate with same suit, rank, and special_card should fail + with pytest.raises(IntegrityError): + Card.objects.create( + suit=card_suits['hearts'], + rank=card_ranks['ace'], + special_card=special + ) diff --git a/game/tests/test_deck_models.py b/game/tests/test_deck_models.py new file mode 100644 index 0000000..50683dc --- /dev/null +++ b/game/tests/test_deck_models.py @@ -0,0 +1,153 @@ +"""Tests for deck and card management models. + +This module tests models responsible for managing the state of cards in the game: +- GameDeck: The main draw pile. +- PlayerHand: Cards held by a player. +- TableCard: Attacking and defending cards in play. +- DiscardPile: Cards that are out of play. +""" + +import pytest +from game.models import ( + GameDeck, PlayerHand, TableCard, DiscardPile, GamePlayer +) + + +@pytest.mark.django_db +class TestGameDeckModel: + """Test suite for the GameDeck model.""" + + def test_game_deck_creation(self, basic_game, basic_cards): + """Tests that GameDeck entries are created correctly.""" + deck_card = GameDeck.objects.create( + game=basic_game, card=basic_cards['ace_hearts'], position=1 + ) + assert deck_card.game == basic_game + assert deck_card.card == basic_cards['ace_hearts'] + assert deck_card.position == 1 + + def test_get_top_card(self, basic_game, basic_cards): + """Tests get_top_card() returns the card with the lowest position.""" + card1 = GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + + assert GameDeck.get_top_card(basic_game) == card1 + + def test_draw_card(self, basic_game, basic_cards): + """Tests draw_card() removes and returns the top card.""" + GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + + drawn_card = GameDeck.draw_card(basic_game) + assert drawn_card == basic_cards['ace_hearts'] + assert GameDeck.objects.filter(game=basic_game).count() == 1 + assert GameDeck.get_top_card(basic_game).card == basic_cards['king_spades'] + + def test_is_last_card(self, basic_game, basic_cards): + """Tests is_last_card() correctly identifies the final card.""" + card1 = GameDeck.objects.create(game=basic_game, card=basic_cards['ace_hearts'], position=1) + assert card1.is_last_card() is True + card2 = GameDeck.objects.create(game=basic_game, card=basic_cards['king_spades'], position=2) + assert card1.is_last_card() is False + assert card2.is_last_card() is True + + +@pytest.mark.django_db +class TestPlayerHandModel: + """Test suite for the PlayerHand model.""" + + def test_player_hand_creation(self, basic_game, test_user, basic_cards): + """Tests that PlayerHand entries are created correctly.""" + hand_card = PlayerHand.objects.create( + game=basic_game, player=test_user, card=basic_cards['ace_hearts'], order_in_hand=1 + ) + assert hand_card.game == basic_game + assert hand_card.player == test_user + assert hand_card.card == basic_cards['ace_hearts'] + + def test_get_player_hand(self, basic_game, test_user, basic_cards): + """Tests get_player_hand() returns all cards for a player, ordered.""" + PlayerHand.objects.create(game=basic_game, player=test_user, card=basic_cards['ace_hearts'], order_in_hand=2) + PlayerHand.objects.create(game=basic_game, player=test_user, card=basic_cards['king_spades'], order_in_hand=1) + + hand = list(PlayerHand.get_player_hand(basic_game, test_user)) + assert len(hand) == 2 + assert hand[0].card == basic_cards['king_spades'] + assert hand[1].card == basic_cards['ace_hearts'] + + def test_remove_from_hand(self, basic_game, test_user, basic_cards): + """Tests remove_from_hand() deletes the card and updates the player's card count.""" + game_player = GamePlayer.objects.create( + game=basic_game, user=test_user, seat_position=1, cards_remaining=1 + ) + hand_card = PlayerHand.objects.create( + game=basic_game, player=test_user, card=basic_cards['ace_hearts'] + ) + hand_card.remove_from_hand() + + game_player.refresh_from_db() + assert not PlayerHand.objects.filter(pk=hand_card.pk).exists() + assert game_player.cards_remaining == 0 + + +@pytest.mark.django_db +class TestTableCardModel: + """Test suite for the TableCard model.""" + + def test_table_card_creation(self, basic_game, basic_cards): + """Tests that TableCard instances are created correctly.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + assert table_card.game == basic_game + assert table_card.attack_card == basic_cards['seven_hearts'] + assert table_card.defense_card is None + + def test_is_defended(self, basic_game, basic_cards): + """Tests is_defended() method.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + assert table_card.is_defended() is False + table_card.defense_card = basic_cards['ace_hearts'] + assert table_card.is_defended() is True + + def test_defend_with_valid_card(self, basic_game, card_suits, basic_cards): + """Tests defend_with() successfully updates defense_card with a valid defense.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['seven_hearts'] + ) + # Trump suit is Hearts, so any Heart can beat another Heart if it's higher rank + trump_suit = card_suits['hearts'] + result = table_card.defend_with(basic_cards['ace_hearts'], trump_suit) + + assert result is True + table_card.refresh_from_db() + assert table_card.defense_card == basic_cards['ace_hearts'] + + def test_defend_with_invalid_card(self, basic_game, card_suits, basic_cards): + """Tests defend_with() fails to update with an invalid defense card.""" + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['ace_hearts'] + ) + # seven_hearts cannot beat ace_hearts + trump_suit = card_suits['hearts'] + result = table_card.defend_with(basic_cards['seven_hearts'], trump_suit) + + assert result is False + table_card.refresh_from_db() + assert table_card.defense_card is None + + +@pytest.mark.django_db +class TestDiscardPileModel: + """Test suite for the DiscardPile model.""" + + def test_discard_pile_creation(self, basic_game, basic_cards): + """Tests that DiscardPile entries are created correctly.""" + discarded = DiscardPile.objects.create( + game=basic_game, card=basic_cards['ace_hearts'], position=1 + ) + assert discarded.game == basic_game + assert discarded.card == basic_cards['ace_hearts'] + assert discarded.position == 1 diff --git a/game/tests/test_game_models.py b/game/tests/test_game_models.py new file mode 100644 index 0000000..2ba822e --- /dev/null +++ b/game/tests/test_game_models.py @@ -0,0 +1,280 @@ +"""Tests for game-related models: Game and GamePlayer. + +This module tests game creation, player management, game state tracking, +and winner determination. +""" + +import pytest +from django.db import IntegrityError +from django.utils import timezone +from game.models import Game, GamePlayer, PlayerHand, Card, CardSuit, CardRank + + +@pytest.mark.django_db +class TestGameModel: + """Test suite for Game model.""" + + def test_game_creation(self, basic_lobby, basic_cards): + """Test that Game instances are created correctly with required fields.""" + basic_lobby.status = 'playing' + basic_lobby.save() + + game = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + + assert game.lobby == basic_lobby + assert game.trump_card == basic_cards['ace_hearts'] + assert game.status == 'in_progress' + assert game.finished_at is None + assert game.loser is None + + def test_game_str_representation(self, basic_game): + """Test string representation shows lobby name and game status.""" + expected = "Game in Test Lobby (in_progress)" + assert str(basic_game) == expected + + def test_is_active_method(self, basic_game): + """Test is_active() returns True for in_progress games and False for finished.""" + # Active game + assert basic_game.is_active() is True + + # Finished game + basic_game.status = 'finished' + assert basic_game.is_active() is False + + def test_get_trump_suit_method(self, basic_game, card_suits): + """Test get_trump_suit() returns the suit of the trump card.""" + trump_suit = basic_game.get_trump_suit() + assert trump_suit == card_suits['hearts'] + + def test_get_player_count_method(self, basic_game, test_user, second_user): + """Test get_player_count() returns correct number of players in game.""" + # Initially no players + assert basic_game.get_player_count() == 0 + + # Add first player + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + assert basic_game.get_player_count() == 1 + + # Add second player + GamePlayer.objects.create( + game=basic_game, + user=second_user, + seat_position=2, + cards_remaining=6 + ) + assert basic_game.get_player_count() == 2 + + def test_get_winner_method_active_game(self, basic_game): + """Test get_winner() returns None for games that are still in progress.""" + assert basic_game.get_winner() is None + + def test_get_winner_method_finished_game(self, basic_game, test_user, second_user): + """Test get_winner() returns all players except the loser. + + In the game, the loser is the player left with cards when the deck + is empty. All other players are considered winners. + """ + player1 = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=0 + ) + + player2 = GamePlayer.objects.create( + game=basic_game, + user=second_user, + seat_position=2, + cards_remaining=3 + ) + + # Finish the game with player2 as loser + basic_game.status = 'finished' + basic_game.loser = second_user + basic_game.finished_at = timezone.now() + basic_game.save() + + winners = basic_game.get_winner() + + assert winners is not None + assert winners.count() == 1 + assert winners.first().user == test_user + + def test_game_ordering(self, basic_lobby, basic_cards): + """Test that games are ordered by start time (newest first). + + The model's Meta.ordering should ensure that newly created games + appear first in querysets. + """ + basic_lobby.status = 'playing' + basic_lobby.save() + + game1 = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['ace_hearts'], + status='in_progress' + ) + + game2 = Game.objects.create( + lobby=basic_lobby, + trump_card=basic_cards['king_spades'], + status='in_progress' + ) + + games = list(Game.objects.all()) + + assert games[0] == game2 # Newest first + assert games[1] == game1 + + +@pytest.mark.django_db +class TestGamePlayerModel: + """Test suite for GamePlayer model.""" + + def test_game_player_creation(self, basic_game, test_user): + """Test that GamePlayer instances are created correctly.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.game == basic_game + assert game_player.user == test_user + assert game_player.seat_position == 1 + assert game_player.cards_remaining == 6 + + def test_game_player_str_representation(self, basic_game, test_user): + """Test string representation shows username, card count, and position.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + expected = "player1 (6 cards) - Position 1" + assert str(game_player) == expected + + def test_has_cards_method(self, basic_game, test_user): + """Test has_cards() returns True when player has cards remaining.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.has_cards() is True + + game_player.cards_remaining = 0 + assert game_player.has_cards() is False + + def test_is_eliminated_method(self, basic_game, test_user): + """Test is_eliminated() returns True when player has no cards left.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + assert game_player.is_eliminated() is False + + game_player.cards_remaining = 0 + assert game_player.is_eliminated() is True + + def test_get_hand_cards_method(self, basic_game, test_user, card_suits, card_ranks): + """Test get_hand_cards() returns all cards in player's hand.""" + game_player = GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + # Create a card in player's hand + seven = CardRank.objects.get_or_create(name="Seven", value=7)[0] + hearts = card_suits['hearts'] + card = Card.objects.create(suit=hearts, rank=seven) + + PlayerHand.objects.create( + game=basic_game, + player=test_user, + card=card, + order_in_hand=1 + ) + + hand_cards = game_player.get_hand_cards() + + assert hand_cards.count() == 1 + assert hand_cards.first().card == card + + def test_unique_together_constraint(self, basic_game, test_user): + """Test that a user cannot be added to the same game twice. + + The database enforces uniqueness on (game, user) combination. + """ + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=1, + cards_remaining=6 + ) + + # Attempting to create duplicate should fail + with pytest.raises(IntegrityError): + GamePlayer.objects.create( + game=basic_game, + user=test_user, + seat_position=2, + cards_remaining=6 + ) + + def test_game_player_ordering(self, basic_game, user_factory): + """Test that game players are ordered by seat position. + + Players should be sorted by their seat_position in ascending order. + """ + user1 = user_factory(username="player1") + user2 = user_factory(username="player2") + user3 = user_factory(username="player3") + + # Create players in non-sequential order + player2 = GamePlayer.objects.create( + game=basic_game, + user=user2, + seat_position=3, + cards_remaining=6 + ) + + player3 = GamePlayer.objects.create( + game=basic_game, + user=user3, + seat_position=2, + cards_remaining=6 + ) + + player1 = GamePlayer.objects.create( + game=basic_game, + user=user1, + seat_position=1, + cards_remaining=6 + ) + + players = list(GamePlayer.objects.filter(game=basic_game)) + + # Should be sorted by seat position + assert players[0].seat_position == 1 + assert players[1].seat_position == 2 + assert players[2].seat_position == 3 diff --git a/game/tests/test_lobby_models.py b/game/tests/test_lobby_models.py new file mode 100644 index 0000000..f1b7308 --- /dev/null +++ b/game/tests/test_lobby_models.py @@ -0,0 +1,520 @@ +"""Tests for lobby-related models: Lobby, LobbySettings, and LobbyPlayer. + +This module tests lobby creation, player management, game readiness checks, +and lobby settings configuration. +""" + +import pytest +from django.db import IntegrityError +from game.models import Lobby, LobbySettings, LobbyPlayer, SpecialRuleSet + + +@pytest.mark.django_db +class TestLobbyModel: + """Test suite for Lobby model.""" + + def test_lobby_creation(self, test_user): + """Test that Lobby instances are created correctly with basic attributes.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + is_private=False, + status='waiting' + ) + + assert lobby.name == "Test Lobby" + assert lobby.owner == test_user + assert lobby.is_private is False + assert lobby.status == 'waiting' + + def test_lobby_str_representation(self, test_user): + """Test string representation returns lobby name.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Epic Game Room", + status='waiting' + ) + + assert str(lobby) == "Epic Game Room" + + def test_lobby_uuid_generation(self, test_user): + """Test that UUID is automatically generated as primary key. + + The lobby uses UUID4 for primary key which should be automatically + generated and be 36 characters long when converted to string. + """ + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + assert lobby.id is not None + assert len(str(lobby.id)) == 36 + + def test_is_full_method_empty_lobby(self, basic_lobby): + """Test is_full() returns False for empty lobby.""" + assert basic_lobby.is_full() is False + + def test_is_full_method_with_players(self, basic_lobby, user_factory): + """Test is_full() returns True when lobby reaches max_players. + + The default lobby has max_players=4, so adding 4 active players + should make is_full() return True. + """ + # Add players up to max (default is 4) + for i in range(4): + user = user_factory(username=f"player{i + 10}") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user, + status='waiting' + ) + + assert basic_lobby.is_full() is True + + def test_is_full_excludes_left_players(self, basic_lobby, user_factory): + """Test that is_full() doesn't count players who have left. + + Players with status 'left' should not be counted toward the + lobby's capacity, even if they haven't been removed from the database. + """ + # Add 3 active players + for i in range(3): + user = user_factory(username=f"player{i + 10}") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user, + status='waiting' + ) + + # Add 1 player who left + left_user = user_factory(username="left_player") + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=left_user, + status='left' + ) + + # Lobby should not be full (3 active + 1 left, max is 4) + assert basic_lobby.is_full() is False + + def test_can_start_game_method_not_enough_players(self, basic_lobby, test_user): + """Test can_start_game() returns False with insufficient ready players. + + A game requires at least 2 ready players to start. + """ + # Add only 1 ready player + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='ready' + ) + + assert basic_lobby.can_start_game() is False + + def test_can_start_game_method_enough_ready_players(self, basic_lobby, test_user, second_user): + """Test can_start_game() returns True with at least 2 ready players.""" + # Add 2 ready players + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='ready') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + assert basic_lobby.can_start_game() is True + + def test_can_start_game_method_wrong_status(self, basic_lobby, test_user, second_user): + """Test can_start_game() returns False if lobby status is not 'waiting'. + + Only lobbies in 'waiting' status can start a game. Lobbies that are + 'playing', 'closed', or have other statuses cannot start a new game. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='ready') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + # Change lobby status to 'playing' + basic_lobby.status = 'playing' + basic_lobby.save() + + assert basic_lobby.can_start_game() is False + + def test_get_active_players_method(self, basic_lobby, test_user, second_user, user_factory): + """Test get_active_players() returns only players who haven't left. + + Active players are those with status 'waiting', 'ready', or 'playing'. + Players with status 'left' should not be included. + """ + LobbyPlayer.objects.create(lobby=basic_lobby, user=test_user, status='waiting') + LobbyPlayer.objects.create(lobby=basic_lobby, user=second_user, status='ready') + + left_user = user_factory(username="left") + LobbyPlayer.objects.create(lobby=basic_lobby, user=left_user, status='left') + + active_players = basic_lobby.get_active_players() + + assert active_players.count() == 2 + assert left_user not in [player.user for player in active_players] + + def test_lobby_ordering(self, test_user, second_user): + """Test that lobbies are ordered by creation date (newest first). + + The model's Meta.ordering should ensure that newly created lobbies + appear first in querysets. + """ + lobby1 = Lobby.objects.create( + owner=test_user, + name="Old Lobby", + status='waiting' + ) + lobby2 = Lobby.objects.create( + owner=second_user, + name="Newer Lobby", + status='waiting' + ) + + lobbies = list(Lobby.objects.all()) + + assert lobbies[0] == lobby2 # Newest first + assert lobbies[1] == lobby1 + + def test_private_lobby_with_password(self, test_user): + """Test creating a private lobby with password hash. + + Private lobbies should have is_private=True and can optionally + include a password_hash for authentication. + """ + private_lobby = Lobby.objects.create( + owner=test_user, + name="Private Game", + is_private=True, + password_hash="hashed_password_here", + status='waiting' + ) + + assert private_lobby.is_private is True + assert private_lobby.password_hash == "hashed_password_here" + + +@pytest.mark.django_db +class TestLobbySettingsModel: + """Test suite for LobbySettings model.""" + + def test_lobby_settings_creation(self, test_user): + """Test that LobbySettings instances are created correctly. + + Settings should be created with proper defaults and relationships + to the lobby. + """ + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False, + turn_time_limit=60 + ) + + assert settings.max_players == 4 + assert settings.card_count == 36 + assert settings.is_transferable is True + assert settings.turn_time_limit == 60 + + def test_lobby_settings_str_representation(self, test_user): + """Test string representation shows lobby name, card count, and players.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36 + ) + + expected = "Test Lobby Settings (36 cards, 4 players)" + assert str(settings) == expected + + def test_has_time_limit_method(self, test_user): + """Test has_time_limit() returns True when turn_time_limit is set.""" + lobby = Lobby.objects.create( + owner=test_user, + name="Test Lobby", + status='waiting' + ) + + settings_with_limit = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + turn_time_limit=60 + ) + + assert settings_with_limit.has_time_limit() is True + + def test_has_time_limit_method_no_limit(self, test_user): + """Test has_time_limit() returns False when turn_time_limit is None.""" + lobby1 = Lobby.objects.create(owner=test_user, name="No Limit", status='waiting') + lobby2 = Lobby.objects.create(owner=test_user, name="Zero Limit", status='waiting') + + settings_no_limit = LobbySettings.objects.create( + lobby=lobby1, + max_players=2, + card_count=24, + turn_time_limit=None + ) + + settings_zero_limit = LobbySettings.objects.create( + lobby=lobby2, + max_players=2, + card_count=24, + turn_time_limit=0 + ) + + assert settings_no_limit.has_time_limit() is False + assert settings_zero_limit.has_time_limit() is False + + def test_is_beginner_friendly_method_true(self, test_user): + """Test is_beginner_friendly() returns True for simple settings. + + Beginner-friendly lobbies have: + - No transferable cards + - No jokers + - No special rule sets + - No neighbor throw restrictions + """ + lobby = Lobby.objects.create(owner=test_user, name="Beginner", status='waiting') + + beginner_settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + special_rule_set=None + ) + + assert beginner_settings.is_beginner_friendly() is True + + def test_is_beginner_friendly_method_false_transferable(self, test_user): + """Test is_beginner_friendly() returns False with transferable cards enabled.""" + lobby = Lobby.objects.create(owner=test_user, name="Advanced", status='waiting') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False + ) + + assert settings.is_beginner_friendly() is False + + def test_is_beginner_friendly_method_false_jokers(self, test_user): + """Test is_beginner_friendly() returns False with jokers enabled.""" + lobby = Lobby.objects.create(owner=test_user, name="Jokers", status='waiting') + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=True + ) + + assert settings.is_beginner_friendly() is False + + def test_is_beginner_friendly_method_false_special_rules(self, test_user): + """Test is_beginner_friendly() returns False with special rule set.""" + lobby = Lobby.objects.create(owner=test_user, name="Special", status='waiting') + + special_rules = SpecialRuleSet.objects.create( + name="Advanced Rules", + description="Complex rules", + min_players=2 + ) + + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=24, + is_transferable=False, + allow_jokers=False, + special_rule_set=special_rules + ) + + assert settings.is_beginner_friendly() is False + + def test_card_count_choices(self, test_user): + """Test that valid card counts (24, 36, 52) are accepted. + + These are the standard deck sizes supported by the game: + - 24 cards: 9 through Ace in all suits + - 36 cards: 6 through Ace in all suits (most common) + - 52 cards: 2 through Ace in all suits (full deck) + """ + for count in [24, 36, 52]: + lobby = Lobby.objects.create( + owner=test_user, + name=f"Lobby{count}", + status='waiting' + ) + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=2, + card_count=count + ) + assert settings.card_count == count + + +@pytest.mark.django_db +class TestLobbyPlayerModel: + """Test suite for LobbyPlayer model.""" + + def test_lobby_player_creation(self, basic_lobby, test_user): + """Test that LobbyPlayer instances are created correctly.""" + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + assert lobby_player.lobby == basic_lobby + assert lobby_player.user == test_user + assert lobby_player.status == 'waiting' + + def test_lobby_player_str_representation(self, basic_lobby, test_user): + """Test string representation shows username, status, and lobby name.""" + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + expected = "player1 (waiting) in Test Lobby" + assert str(lobby_player) == expected + + def test_is_active_method(self, basic_lobby, test_user): + """Test is_active() method for various player statuses. + + Active statuses: 'waiting', 'ready', 'playing' + Inactive status: 'left' + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # Test 'waiting' status + assert lobby_player.is_active() is True + + # Test 'ready' status + lobby_player.status = 'ready' + assert lobby_player.is_active() is True + + # Test 'playing' status + lobby_player.status = 'playing' + assert lobby_player.is_active() is True + + # Test 'left' status + lobby_player.status = 'left' + assert lobby_player.is_active() is False + + def test_can_start_game_method(self, basic_lobby, test_user): + """Test can_start_game() method returns True only for 'ready' status. + + Only players with 'ready' status are considered ready to start a game. + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # 'waiting' status cannot start game + assert lobby_player.can_start_game() is False + + # 'ready' status can start game + lobby_player.status = 'ready' + assert lobby_player.can_start_game() is True + + # 'playing' status cannot start game (already in game) + lobby_player.status = 'playing' + assert lobby_player.can_start_game() is False + + def test_leave_lobby_method(self, basic_lobby, test_user): + """Test leave_lobby() method updates player status to 'left'. + + When a player leaves, their status should be updated to 'left' + and persisted to the database. + """ + lobby_player = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + lobby_player.leave_lobby() + + lobby_player.refresh_from_db() + assert lobby_player.status == 'left' + + def test_unique_together_constraint(self, basic_lobby, test_user): + """Test that a user cannot join the same lobby twice. + + The database enforces uniqueness on (lobby, user) combination + to prevent duplicate player entries. + """ + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + # Attempting to create duplicate should fail + with pytest.raises(IntegrityError): + LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, + status='waiting' + ) + + def test_lobby_player_ordering(self, basic_lobby, test_user, second_user, user_factory): + """Test that lobby players are ordered by lobby and username. + + Within a lobby, players should be sorted alphabetically by username. + """ + user3 = user_factory(username="aaa_first") + + # Create players in non-alphabetical order + player1 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=test_user, # player1 + status='waiting' + ) + player2 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=second_user, # player2 + status='waiting' + ) + player3 = LobbyPlayer.objects.create( + lobby=basic_lobby, + user=user3, # aaa_first + status='waiting' + ) + + players = list(LobbyPlayer.objects.filter(lobby=basic_lobby)) + + # Should be sorted alphabetically by username + assert players[0].user.username == "aaa_first" + assert players[1].user.username == "player1" + assert players[2].user.username == "player2" diff --git a/game/tests/test_special_models.py b/game/tests/test_special_models.py new file mode 100644 index 0000000..c0281c6 --- /dev/null +++ b/game/tests/test_special_models.py @@ -0,0 +1,107 @@ +"""Tests for special card and rule set models. + +This module tests models responsible for custom game rules: +- SpecialCard: Defines special effects like 'skip' or 'draw'. +- SpecialRuleSet: A collection of special card rules. +- SpecialRuleSetCard: Links special cards to a rule set. +""" + +import pytest +from game.models import ( + SpecialCard, SpecialRuleSet, SpecialRuleSetCard, LobbySettings +) + + +@pytest.mark.django_db +class TestSpecialCardModel: + """Test suite for the SpecialCard model.""" + + def test_special_card_creation(self, special_card_draw): + """Tests that SpecialCard instances are created correctly.""" + assert special_card_draw.name == "Draw Two" + assert special_card_draw.effect_type == "draw" + assert special_card_draw.effect_value == {"card_count": 2} + + def test_get_effect_description(self, special_card_draw, special_card_skip): + """Tests that get_effect_description formats the description correctly.""" + draw_desc = special_card_draw.get_effect_description() + assert "2 cards" in draw_desc + + skip_desc = special_card_skip.get_effect_description() + assert skip_desc == "Next player loses their turn" + + def test_is_targetable(self, special_card_skip, special_card_reverse): + """Tests is_targetable() for different effect types.""" + assert special_card_skip.is_targetable() is True + assert special_card_reverse.is_targetable() is False + + def test_can_be_countered(self, special_card_skip): + """Tests can_be_countered() respects the default and explicit values.""" + # Default is counterable + assert special_card_skip.can_be_countered() is True + + # Explicitly set to not be counterable + uncounterable = SpecialCard.objects.create( + name="Unstoppable", + effect_type="custom", + effect_value={"counterable": False} + ) + assert uncounterable.can_be_countered() is False + + +@pytest.mark.django_db +class TestSpecialRuleSetModel: + """Test suite for the SpecialRuleSet model.""" + + def test_rule_set_creation(self, basic_rule_set): + """Tests that SpecialRuleSet instances are created correctly.""" + assert basic_rule_set.name == "Beginner Special" + assert basic_rule_set.min_players == 2 + + def test_get_special_card_count(self, basic_rule_set, special_card_skip): + """Tests get_special_card_count() returns the correct number of cards.""" + assert basic_rule_set.get_special_card_count() == 0 + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + assert basic_rule_set.get_special_card_count() == 1 + + def test_get_enabled_special_cards( + self, basic_rule_set, special_card_skip, special_card_draw + ): + """Tests get_enabled_special_cards() returns only enabled cards.""" + # Add one enabled and one disabled card to the rule set + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_draw, is_enabled=False + ) + + enabled_cards = basic_rule_set.get_enabled_special_cards() + assert enabled_cards.count() == 1 + assert enabled_cards.first() == special_card_skip + + +@pytest.mark.django_db +class TestSpecialRuleSetCardModel: + """Test suite for the SpecialRuleSetCard through-model.""" + + def test_association_creation(self, basic_rule_set, special_card_skip): + """Tests the creation of the association between a rule set and a card.""" + association = SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + assert association.rule_set == basic_rule_set + assert association.card == special_card_skip + assert association.is_enabled is True + + def test_toggle_enabled(self, basic_rule_set, special_card_skip): + """Tests that toggle_enabled() correctly flips the is_enabled status.""" + association = SpecialRuleSetCard.objects.create( + rule_set=basic_rule_set, card=special_card_skip, is_enabled=True + ) + association.toggle_enabled() + assert association.is_enabled is False + association.toggle_enabled() + assert association.is_enabled is True diff --git a/game/tests/test_turn_models.py b/game/tests/test_turn_models.py new file mode 100644 index 0000000..cc8c0ac --- /dev/null +++ b/game/tests/test_turn_models.py @@ -0,0 +1,114 @@ +"""Tests for turn and move tracking models. + +This module tests Turn and Move models which track game progression, +player actions, and move history. +""" + +import pytest +from django.db import IntegrityError +from game.models import Turn, Move, TableCard + + +@pytest.mark.django_db +class TestTurnModel: + """Test suite for Turn model. + + A Turn represents a single turn in the game, tracking which player's + turn it is and the turn number in sequence. + """ + + def test_turn_creation(self, basic_game, test_user): + """Tests that Turn instances are created correctly.""" + turn = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + assert turn.game == basic_game + assert turn.player == test_user + assert turn.turn_number == 1 + + def test_get_current_turn(self, basic_game, test_user, second_user): + """Tests get_current_turn() returns the most recent turn for a game.""" + turn1 = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + assert Turn.get_current_turn(basic_game) == turn1 + + turn2 = Turn.objects.create(game=basic_game, player=second_user, turn_number=2) + assert Turn.get_current_turn(basic_game) == turn2 + + def test_get_current_turn_no_turns(self, basic_game): + """Tests get_current_turn() returns None for a game without any turns.""" + assert Turn.get_current_turn(basic_game) is None + + def test_create_next_turn(self, basic_game, test_user, second_user): + """ + Tests create_next_turn() creates a new turn with an incremented number. + + The new turn should have a turn_number equal to the previous turn's + number plus one. + """ + Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + next_turn = Turn.create_next_turn(basic_game, second_user) + + assert next_turn.turn_number == 2 + assert next_turn.player == second_user + assert next_turn.game == basic_game + + def test_unique_together_constraint(self, basic_game, test_user, second_user): + """ + Tests that the (game, turn_number) combination is unique. + + Each game can only have one turn with a specific turn_number. + """ + Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + with pytest.raises(IntegrityError): + Turn.objects.create(game=basic_game, player=second_user, turn_number=1) + + +@pytest.mark.django_db +class TestMoveModel: + """Test suite for Move model. + + A Move represents a single action (e.g., attack, defend) during a turn. + """ + + @pytest.fixture + def attack_move(self, basic_game, test_user, basic_cards): + """Fixture for creating a sample attack move.""" + turn = Turn.objects.create(game=basic_game, player=test_user, turn_number=1) + table_card = TableCard.objects.create( + game=basic_game, attack_card=basic_cards['ace_hearts'] + ) + return Move.objects.create( + turn=turn, table_card=table_card, action_type='attack' + ) + + def test_move_creation(self, attack_move, test_user): + """Tests that Move instances are created correctly.""" + assert attack_move.turn.player == test_user + assert attack_move.action_type == 'attack' + assert attack_move.table_card is not None + + def test_get_player(self, attack_move, test_user): + """Tests get_player() returns the player from the associated turn.""" + assert attack_move.get_player() == test_user + + @pytest.mark.parametrize( + "action, method_name, expected", + [ + ("attack", "is_attack", True), + ("defend", "is_attack", False), + ("defend", "is_defense", True), + ("pickup", "is_pickup", True), + ("attack", "is_pickup", False), + ], + ) + def test_action_check_methods(self, attack_move, action, method_name, expected): + """ + Tests the boolean check methods (is_attack, is_defense, is_pickup). + + Args: + attack_move: Fixture for a sample move. + action: The action_type to set for the move. + method_name: The name of the method to call (e.g., 'is_attack'). + expected: The expected boolean result. + """ + attack_move.action_type = action + method_to_call = getattr(attack_move, method_name) + assert method_to_call() is expected diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..8f0445d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +DJANGO_SETTINGS_MODULE = Fools_Arena.settings +python_files = tests.py test_*.py *_tests.py diff --git a/requirements.txt b/requirements.txt index e3447ed767c27c5e7b607b83bbeb72f236c3cba7..454e5dc976e1dd6083cc6089df3dde676432eb7b 100644 GIT binary patch delta 57 ycmeyvyo$x(|Gxr;N`?}KREA<8$;-gSfXvrrNMXogNMy)kNN30g%Wh=)#Rvez%?+sl delta 11 ScmZ3*@`st}|G$j}zZd}^yakB> From fddff33b6a6b39fa2974fc166830ae529500ebe3 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sun, 26 Oct 2025 14:37:19 +0100 Subject: [PATCH 15/38] CI setup --- .github/workflows/ci.yml | 55 ++++++++++++++++++++++++++++++++++++++++ README.md | 6 ++--- 2 files changed, 58 insertions(+), 3 deletions(-) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0df3d66 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,55 @@ +name: Django CI + +on: + push: + branches: ["main", "master", "develop"] + pull_request: + branches: ["main", "master", "develop"] + +jobs: + test: + runs-on: ubuntu-latest + + services: + postgres: + image: postgres:15 + env: + POSTGRES_DB: django_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + ports: + - 5432:5432 + options: >- + --health-cmd="pg_isready -U postgres" + --health-interval=10s + --health-timeout=5s + --health-retries=5 + + env: + DJANGO_SETTINGS_MODULE: Fools_Arena.settings + POSTGRES_DB: django_test + POSTGRES_USER: postgres + POSTGRES_PASSWORD: postgres + DATABASE_URL: postgres://postgres:postgres@localhost:5432/django_test + PYTHONPATH: . + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run migrations + run: | + python manage.py migrate + + - name: Run tests + run: | + pytest -v --disable-warnings diff --git a/README.md b/README.md index 5973da0..a55fa2a 100644 --- a/README.md +++ b/README.md @@ -26,16 +26,16 @@ Available services: ### 4. Apply migrations and create a superuser Run migrations: ```bash -docker-compose exec web python manage.py migrate +docker compose exec web python manage.py migrate ``` Create a superuser (optional): ```bash -docker-compose exec web python manage.py createsuperuser +docker compose exec web python manage.py createsuperuser ``` ### 5. Generate static files ```bash -docker-compose exec web python manage.py collectstatic +docker compose exec web python manage.py collectstatic ``` ### 6. Work with Django From d341ec4275706723dac7ef89852b86145046991c Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich Date: Sun, 26 Oct 2025 14:42:10 +0100 Subject: [PATCH 16/38] Update ci.yml --- .github/workflows/ci.yml | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0df3d66..4a072fd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,15 +21,17 @@ jobs: - 5432:5432 options: >- --health-cmd="pg_isready -U postgres" - --health-interval=10s + --health-interval=5s --health-timeout=5s - --health-retries=5 + --health-retries=10 env: DJANGO_SETTINGS_MODULE: Fools_Arena.settings POSTGRES_DB: django_test POSTGRES_USER: postgres POSTGRES_PASSWORD: postgres + POSTGRES_HOST: localhost + POSTGRES_PORT: 5432 DATABASE_URL: postgres://postgres:postgres@localhost:5432/django_test PYTHONPATH: . @@ -45,10 +47,18 @@ jobs: run: | python -m pip install --upgrade pip pip install -r requirements.txt + - name: Wait for PostgreSQL to be ready + run: | + echo "Waiting for PostgreSQL to accept connections..." + for i in {1..10}; do + pg_isready -h localhost -p 5432 -U postgres && break + echo "PostgreSQL not ready yet, retrying in 3 seconds..." + sleep 3 + done - name: Run migrations run: | - python manage.py migrate + python manage.py migrate --noinput - name: Run tests run: | From 5861414def263774b31bf45aa88487de2dcff375 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:10:02 +0300 Subject: [PATCH 17/38] Added generate_test_users command. --- .../commands/generate_test_users.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 accounts/management/commands/generate_test_users.py diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py new file mode 100644 index 0000000..0778399 --- /dev/null +++ b/accounts/management/commands/generate_test_users.py @@ -0,0 +1,226 @@ +""" +generate_test_users management command. + +Creates one or more test users for development/testing. + +Example: + # create 5 regular test users with default password + python manage.py generate_test_users --count 5 --prefix testuser + + # create 3 staff users with a custom email domain and password + python manage.py generate_test_users -c 3 --prefix staff_ --email-domain example.org --password secret123 --staff + + # create 1 superuser + python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass + +Google style docstrings are used in the class and methods. +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +import random +import string +from typing import List + +User = get_user_model() + + +def _random_suffix(length: int = 4) -> str: + """Return a short random alphanumeric suffix. + + Args: + length: Length of the suffix. + + Returns: + A random string composed of lowercase letters and digits. + """ + chars = string.ascii_lowercase + string.digits + return ''.join(random.choice(chars) for _ in range(length)) + + +class Command(BaseCommand): + """Django command to generate test users. + + The command creates users using `User.objects.create_user()` (or + `create_superuser()` when the `--superuser` flag is used). It will skip + usernames that already exist unless `--force` is provided, in which case + a short random suffix is appended to the username. + + Methods + ------- + add_arguments(parser): + Add command-line arguments. + handle(*args, **options): + Main entry point that creates users according to parsed options. + """ + + help = "Generate test users (regular, staff, or superuser) for development." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: The argparse parser to configure. + """ + parser.add_argument( + '--count', '-c', + type=int, + default=1, + help='Number of users to create (default: 1)' + ) + parser.add_argument( + '--prefix', '-p', + type=str, + default='testuser', + help='Prefix for usernames (default: "testuser")' + ) + parser.add_argument( + '--start', + type=int, + default=1, + help='Starting index appended to username (default: 1)' + ) + parser.add_argument( + '--email-domain', + type=str, + default='example.com', + help='Email domain to use for generated users (default: example.com)' + ) + parser.add_argument( + '--password', + type=str, + default='test_password', + help='Password to set for all created users (default: "test_password")' + ) + parser.add_argument( + '--staff', + action='store_true', + help='Mark created users as staff (is_staff=True)' + ) + parser.add_argument( + '--superuser', + action='store_true', + help='Create superuser(s) (uses create_superuser)' + ) + parser.add_argument( + '--inactive', + action='store_true', + help='Create users with is_active=False' + ) + parser.add_argument( + '--force', + action='store_true', + help='If username exists, append random suffix and create anyway' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Print what would be created without saving to the database' + ) + + def _make_username(self, prefix: str, idx: int) -> str: + """Construct a username from prefix and index. + + Args: + prefix: Username prefix. + idx: Index to append. + + Returns: + The composed username (e.g. "prefix1"). + """ + return f"{prefix}{idx}" + + def _email_for_username(self, username: str, domain: str) -> str: + """Construct an email address for a username. + + Args: + username: The username to use before the @. + domain: The domain to use after the @. + + Returns: + A complete email address string. + """ + return f"{username}@{domain}" + + def handle(self, *args, **options): + """Create the requested number of test users. + + Args: + *args: positional args (unused). + **options: Parsed command-line options. + + Returns: + None + """ + count: int = options['count'] + prefix: str = options['prefix'] + start: int = options['start'] + email_domain: str = options['email_domain'] + password: str = options['password'] + make_staff: bool = options['staff'] + make_superuser: bool = options['superuser'] + inactive: bool = options['inactive'] + force: bool = options['force'] + dry_run: bool = options['dry_run'] + + created: List[User] = [] + + # loop to create the requested number of users + for i in range(start, start + count): + username = self._make_username(prefix, i) + email = self._email_for_username(username, email_domain) + + # If the username already exists and --force is not used, skip it. + if User.objects.filter(username=username).exists(): + if not force: + self.stdout.write(self.style.WARNING( + f"Skipping existing username: {username}" + )) + continue + + # If force mode, append a short random suffix to make it unique. + username = f"{username}_{_random_suffix()}" + email = self._email_for_username(username, email_domain) + self.stdout.write(self.style.NOTICE( + f"Username existed; using fallback username: {username}" + )) + + # Dry-run prints and does not save to DB. + if dry_run: + self.stdout.write(f"[DRY RUN] Would create: username='{username}', email='{email}', " + f"is_staff={make_staff}, is_superuser={make_superuser}, is_active={not inactive}") + continue + + # Create a superuser if requested (this calls create_superuser which + # typically sets is_staff/is_superuser automatically). + if make_superuser: + # create_superuser signature: (username, email=None, password=None, **extra_fields) + user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] + # Ensure flags align with requested options (some custom user models + # might require setting them explicitly). + user.is_staff = True + user.is_superuser = True + else: + # Regular user creation (hashes the password) + user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] + user.is_staff = bool(make_staff) + user.is_superuser = False + + # Set active state based on --inactive + user.is_active = not bool(inactive) + + # Save changes (if any) and collect result. + user.save() + created.append(user) + + # Informational output for each created user. + self.stdout.write(self.style.SUCCESS( + f"Created user: username='{username}' email='{email}' " + f"{'(staff)' if user.is_staff else ''} {'(superuser)' if user.is_superuser else ''}" + )) + + # Summary output + if dry_run: + self.stdout.write(self.style.WARNING("Dry run complete. No users were created.")) + else: + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) From 072d5cc379a8cffd76ef9584bf86c8d0586d554e Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:13:07 +0300 Subject: [PATCH 18/38] Removed unnecessary comment line. --- accounts/management/commands/generate_test_users.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 0778399..6fe0a9f 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -12,8 +12,6 @@ # create 1 superuser python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass - -Google style docstrings are used in the class and methods. """ from django.core.management.base import BaseCommand From 5e4e12d67420995c8205dfcfd59d82d953704e2a Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sun, 26 Oct 2025 15:14:12 +0300 Subject: [PATCH 19/38] Added __init__ in every managment/commands folder so pyhton whould understand what this is a commands, Added export_db, import_db commands. --- accounts/management/__init__.py | 0 accounts/management/commands/__init__.py | 0 chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 game/management/__init__.py | 0 game/management/commands/__init__.py | 0 game/management/commands/export_db.py | 252 +++++++++++++++++++++++ game/management/commands/import_db.py | 213 +++++++++++++++++++ 8 files changed, 465 insertions(+) create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 game/management/__init__.py create mode 100644 game/management/commands/__init__.py create mode 100644 game/management/commands/export_db.py create mode 100644 game/management/commands/import_db.py diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/__init__.py b/game/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py new file mode 100644 index 0000000..7686253 --- /dev/null +++ b/game/management/commands/export_db.py @@ -0,0 +1,252 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.apps import apps +from django.conf import settings +from django.core import serializers + + +EXCLUDE_MODEL_NAMES = { + "ContentType", + "Session", +} + + +def resolve_output_path(path: str) -> str: + """Resolve a possibly-relative output path against BASE_DIR. + + Args: + path: Output file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + return path + + +class Command(BaseCommand): + """Export database rows to a JSON file (Django serialization) with optional apps filter. + + This command streams model instances to a JSON array in chunks (to avoid + building one huge list in memory). Use ``--apps`` to limit exported objects + to models that belong to a set of app labels (comma-separated). Use + ``--indent N`` to pretty-print multi-line JSON. + + Example usage: + # Default: writes db_backups/backup.json under BASE_DIR + python manage.py export_db + + # Pretty-printed, only export 'game' and 'auth' apps + python manage.py export_db --apps game,auth --indent 2 + + # Gzipped output inside project + python manage.py export_db -o db_backups/backup.json.gz --indent 2 + """ + + help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "--output", + "-o", + help=( + "Output file path (relative paths are created inside BASE_DIR). " + "Use '-' for stdout. Use .gz to gzip." + ), + default="db_backups/backup.json", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to export (e.g. 'auth,game'). If omitted exports all apps.", + default=None, + ) + parser.add_argument( + "--exclude", + help="Comma-separated model names to exclude (ModelName or app_label.ModelName).", + default="", + ) + parser.add_argument( + "--indent", + type=int, + default=None, + help="JSON indent level (pass an integer). If omitted output is compact (single line).", + ) + parser.add_argument( + "--natural-foreign", + action="store_true", + help="Use natural foreign keys when serializing (if supported by models).", + ) + parser.add_argument( + "--natural-primary", + action="store_true", + help="Use natural primary keys when serializing (if supported).", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=1000, + help="Number of objects to serialize per chunk (memory / performance tuning).", + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Collect models to export (apply --apps and --exclude filters). + 2. Stream objects per-model and per-chunk, serializing each chunk and + writing the inner JSON to the output file/stream. + 3. Produce pretty JSON when --indent is provided. + """ + output = options["output"] + apps_arg = options["apps"] + exclude_arg = options["exclude"] + indent = options["indent"] + use_nat_foreign = options["natural_foreign"] + use_nat_primary = options["natural_primary"] + chunk_size = options["chunk_size"] + + apps_filter = self._parse_apps_arg(apps_arg) + exclude_set = set(x.strip() for x in exclude_arg.split(",") if x.strip()) + + # Collect models, applying app & exclude filters. + all_models = list(apps.get_models()) + models_to_export: List[type] = [] + for m in all_models: + full_name = f"{m._meta.app_label}.{m.__name__}" + if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: + # skip common internal models or anything explicitly excluded + continue + if apps_filter is not None and m._meta.app_label not in apps_filter: + # skip models that are not in the requested apps + continue + models_to_export.append(m) + + if not models_to_export: + raise CommandError("No models found to export (check --apps and --exclude).") + + # Determine output destination + write_to_stdout = (output == "-") + if not write_to_stdout: + output = resolve_output_path(output) + + # Open output (gz or plain file) or use stdout + if write_to_stdout: + write_chunk = lambda s: self.stdout.write(s) + close_fh = lambda: None + else: + if output.endswith(".gz"): + fh = gzip.open(output, "wt", encoding="utf-8") + else: + fh = open(output, "w", encoding="utf-8") + write_chunk = fh.write + close_fh = fh.close + + total_objects = 0 + first_piece = True + + try: + # Prepare array formatting depending on pretty vs compact + if indent is not None: + write_chunk("[\n") + separator = ",\n" + closing = "\n]\n" + else: + write_chunk("[") + separator = "," + closing = "]" + + # Stream per model to limit memory usage + for model in models_to_export: + qs = model._default_manager.all().iterator() + chunk: List[object] = [] + for obj in qs: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + # Extract the JSON inner array content (strip leading/trailing [ ]) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + # Trim surrounding newlines for nicer concatenation + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] + + # Flush any remaining objects for this model + if chunk: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + + # Close JSON array + write_chunk(closing) + finally: + close_fh() + + self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output}")) diff --git a/game/management/commands/import_db.py b/game/management/commands/import_db.py new file mode 100644 index 0000000..2b5d83f --- /dev/null +++ b/game/management/commands/import_db.py @@ -0,0 +1,213 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.core import serializers +from django.db import transaction, IntegrityError, connection +from django.core.management import call_command +from django.conf import settings +from django.apps import apps +from django.core.management.color import no_style + + +def resolve_input_path(path: str) -> str: + """Resolve a possibly-relative input path against BASE_DIR. + + Args: + path: Input file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + return path + + +class Command(BaseCommand): + """Import JSON exported by export_db (Django serialization) with optional app filter. + + The command accepts a Django-serialized JSON array (optionally gzipped) and + deserializes objects into the database. Use `--apps` to restrict import to + objects belonging to a set of app labels (comma-separated). When PostgreSQL + is detected (or `--reset-sequences` is passed) the command will attempt to + reset DB sequences — by default for all models, or only for the selected apps + when `--apps` is used. + + Example usages: + # Import everything + python manage.py import_db db_backups/backup.json + + # Import gzipped file and continue past errors + python manage.py import_db db_backups/backup.json.gz --ignore-errors + + # Import only objects for 'game' and 'auth' apps + python manage.py import_db db_backups/backup.json --apps game,auth + + # Clear DB first (dangerous) + python manage.py import_db db_backups/backup.json --clear + """ + + help = "Import JSON exported by export_db (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "input", + help="Input JSON file path (relative paths searched in BASE_DIR). Use '-' for stdin. Supports .gz.", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Clear the database via `flush --noinput` before importing. USE WITH CARE.", + ) + parser.add_argument( + "--ignore-errors", + action="store_true", + help="Try to continue past individual object errors (logs them).", + ) + parser.add_argument( + "--reset-sequences", + action="store_true", + help="Attempt to reset DB sequences after import (Postgres only). Default: on for Postgres.", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to import (e.g. 'auth,game'). If omitted imports all apps.", + default=None, + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + # strip whitespace and ignore empty pieces + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Resolve input path (or read stdin). + 2. Optionally flush DB (--clear). + 3. Read and deserialize JSON (supports .gz). + 4. Iterate deserialized objects, optionally filtering by --apps, saving each. + 5. Optionally reset sequences for Postgres (either all models or filtered models). + """ + input_path = options["input"] + do_clear = options["clear"] + ignore_errors = options["ignore_errors"] + reset_sequences_flag = options["reset_sequences"] + apps_arg = options["apps"] + + apps_filter = self._parse_apps_arg(apps_arg) + + # Resolve path if not stdin + read_from_stdin = (input_path == "-") + if not read_from_stdin: + input_path = resolve_input_path(input_path) + if not os.path.exists(input_path): + raise CommandError(f"Input file not found: {input_path}") + + # Optionally clear the DB (flush clears all data) + if do_clear: + self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) + call_command("flush", "--noinput") + + # Read input file / stdin + if read_from_stdin: + raw = self.stdin.read() + else: + if input_path.endswith(".gz"): + with gzip.open(input_path, "rt", encoding="utf-8") as fh: + raw = fh.read() + else: + with open(input_path, "r", encoding="utf-8") as fh: + raw = fh.read() + + if not raw.strip(): + raise CommandError("Input file is empty.") + + # Create a generator of deserialized objects. This does not eagerly load into a list. + try: + deserialized_iter = serializers.deserialize("json", raw) + except Exception as e: + raise CommandError(f"Failed to deserialize input JSON: {e}") + + saved = 0 + skipped = 0 + errors: List[str] = [] + + # Save objects inside a single atomic transaction. If --ignore-errors, continue past failing objects. + with transaction.atomic(): + for dobj in deserialized_iter: + # Determine the app label of the object's model + try: + obj_app_label = dobj.object._meta.app_label + except Exception: + # Defensive: if object lacks expected attributes, skip it + skipped += 1 + continue + + # If --apps filter is provided, skip objects not in that set + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + try: + # dobj.save() persists the object and handles m2m relationships + dobj.save() + saved += 1 + except IntegrityError as e: + msg = f"IntegrityError saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + # Re-raise to abort the transaction + raise + except Exception as e: + msg = f"Error saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + raise + + # Decide whether to reset sequences: + engine = connection.settings_dict.get("ENGINE", "") + is_postgres = "postgresql" in engine or connection.vendor == "postgresql" + do_reset = reset_sequences_flag or is_postgres + + if do_reset and is_postgres: + # Build model list: either all models or only models in selected apps + if apps_filter is None: + models_to_reset = list(apps.get_models()) + else: + models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] + + style = no_style() + try: + sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) + with connection.cursor() as cursor: + for sql in sql_list: + if sql.strip(): + cursor.execute(sql) + self.stdout.write(self.style.SUCCESS("Postgres sequences reset successfully.")) + except Exception as e: + err_msg = f"Failed to reset sequences: {e}" + self.stderr.write(self.style.ERROR(err_msg)) + errors.append(err_msg) + + # Summary output + self.stdout.write(self.style.SUCCESS(f"Imported {saved} objects.")) + if skipped: + self.stdout.write(self.style.WARNING(f"Skipped {skipped} objects (outside --apps or invalid).")) + if errors: + self.stdout.write(self.style.WARNING(f"{len(errors)} errors occurred during import. See stderr for details.")) From d8a0c1c17259b5dac9e7b9bbe18800e48d74f5e9 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Mon, 27 Oct 2025 18:53:30 +0300 Subject: [PATCH 20/38] Added backup* to .gitignore --- .gitignore | 1 + game/management/commands/export_db.py | 126 ++++++++++++++------------ 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 40dfc85..a97294d 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ celerybeat.pid *.sql.gz *.dump *.backup +backup* # ========================= # Docker diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py index 7686253..d64d86a 100644 --- a/game/management/commands/export_db.py +++ b/game/management/commands/export_db.py @@ -1,11 +1,12 @@ import os import gzip -from typing import Optional, Set, List +from typing import Optional, Set, List, Type from django.core.management.base import BaseCommand, CommandError from django.apps import apps from django.conf import settings from django.core import serializers - +from django.db import connection +from django.db.utils import ProgrammingError, OperationalError EXCLUDE_MODEL_NAMES = { "ContentType", @@ -34,20 +35,14 @@ def resolve_output_path(path: str) -> str: class Command(BaseCommand): """Export database rows to a JSON file (Django serialization) with optional apps filter. - This command streams model instances to a JSON array in chunks (to avoid + The command streams model instances to a JSON array in chunks (to avoid building one huge list in memory). Use ``--apps`` to limit exported objects to models that belong to a set of app labels (comma-separated). Use ``--indent N`` to pretty-print multi-line JSON. - Example usage: - # Default: writes db_backups/backup.json under BASE_DIR - python manage.py export_db - - # Pretty-printed, only export 'game' and 'auth' apps - python manage.py export_db --apps game,auth --indent 2 - - # Gzipped output inside project - python manage.py export_db -o db_backups/backup.json.gz --indent 2 + The command is resilient: if a model's table does not exist in the database + (e.g. after code changes but before running migrations), it logs a warning + and continues exporting other models. """ help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." @@ -97,14 +92,7 @@ def add_arguments(self, parser): ) def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: - """Parse the --apps argument into a set of app labels. - - Args: - apps_arg: Comma-separated app labels or None. - - Returns: - A set of app labels if apps_arg provided, otherwise None. - """ + """Parse the --apps argument into a set of app labels.""" if not apps_arg: return None return {p.strip() for p in apps_arg.split(",") if p.strip()} @@ -116,7 +104,7 @@ def handle(self, *args, **options): 1. Collect models to export (apply --apps and --exclude filters). 2. Stream objects per-model and per-chunk, serializing each chunk and writing the inner JSON to the output file/stream. - 3. Produce pretty JSON when --indent is provided. + 3. If a model has no table, log and continue. """ output = options["output"] apps_arg = options["apps"] @@ -131,14 +119,12 @@ def handle(self, *args, **options): # Collect models, applying app & exclude filters. all_models = list(apps.get_models()) - models_to_export: List[type] = [] + models_to_export: List[Type] = [] for m in all_models: full_name = f"{m._meta.app_label}.{m.__name__}" if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: - # skip common internal models or anything explicitly excluded continue if apps_filter is not None and m._meta.app_label not in apps_filter: - # skip models that are not in the requested apps continue models_to_export.append(m) @@ -178,41 +164,65 @@ def handle(self, *args, **options): # Stream per model to limit memory usage for model in models_to_export: - qs = model._default_manager.all().iterator() + # Attempt to iterate model objects; if table missing, log and continue. + try: + qs_iter = model._default_manager.all().iterator() + except (ProgrammingError, OperationalError) as e: + # Table might not exist — log and continue with next model. + self.stderr.write(self.style.WARNING( + f"Skipping model {model._meta.label}: DB error when creating queryset: {e}" + )) + # Close connection to reset any partially-open cursor + try: + connection.close() + except Exception: + pass + continue + chunk: List[object] = [] - for obj in qs: - chunk.append(obj) - if len(chunk) >= chunk_size: - serialized = serializers.serialize( - "json", - chunk, - indent=indent, - use_natural_foreign_keys=use_nat_foreign, - use_natural_primary_keys=use_nat_primary, - ) - # Extract the JSON inner array content (strip leading/trailing [ ]) - start = serialized.find('[') - end = serialized.rfind(']') - inner = serialized[start+1:end] - - if indent is not None: - # Trim surrounding newlines for nicer concatenation - inner = inner.lstrip('\n').rstrip('\n') - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - else: - inner = inner.strip() - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - - total_objects += len(chunk) - chunk = [] + try: + for obj in qs_iter: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] + except (ProgrammingError, OperationalError) as e: + # Something happened mid-iteration (e.g. table dropped). Log and reset connection. + self.stderr.write(self.style.WARNING( + f"Error iterating model {model._meta.label}: {e} — skipping remaining rows of this model." + )) + try: + connection.close() + except Exception: + pass + # continue to next model + continue # Flush any remaining objects for this model if chunk: From 113ff7b513d4a062e5be0fc0e879b0f989762608 Mon Sep 17 00:00:00 2001 From: Viton8 Date: Wed, 29 Oct 2025 18:27:16 +0300 Subject: [PATCH 21/38] Change test, to pytest --- accounts/forms.py | 4 +- accounts/serializers.py | 4 +- accounts/tests/test_auth.py | 129 +++++++++++++++++------------------- 3 files changed, 68 insertions(+), 69 deletions(-) diff --git a/accounts/forms.py b/accounts/forms.py index ae7981d..933479b 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,6 +1,8 @@ from django import forms from django.contrib.auth.forms import UserCreationForm, AuthenticationForm -from django.contrib.auth.models import User +from django.contrib.auth import get_user_model + +User = get_user_model() class RegistrationForm(UserCreationForm): """Form for user registration with email field included.""" diff --git a/accounts/serializers.py b/accounts/serializers.py index f4a89f1..cf717ff 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,6 +1,8 @@ -from django.contrib.auth.models import User from django.contrib.auth import authenticate from rest_framework import serializers +from django.contrib.auth import get_user_model + +User = get_user_model() class RegistrationSerializer(serializers.ModelSerializer): # Serializer for user registration diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index 0425052..ae47f60 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -1,130 +1,125 @@ -from django.contrib.auth.models import User -from django.test import TestCase +import pytest +from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework.test import APIClient +User = get_user_model() -class TemplateAuthTests(TestCase): + +@pytest.mark.django_db +class TestTemplateAuth: """UI-based authentication tests.""" - def test_register_valid(self): - """User can register with valid data.""" - resp = self.client.post(reverse('register'), { + def test_register_valid(self, client): + resp = client.post(reverse('register'), { 'username': 'maksim', 'email': 'm@example.com', 'password1': 'StrongPass123', 'password2': 'StrongPass123', }) - self.assertRedirects(resp, reverse('profile')) - self.assertTrue(User.objects.filter(username='maksim').exists()) + assert resp.status_code == 302 + assert resp.url == reverse('profile') + assert User.objects.filter(username='maksim').exists() - def test_register_invalid_password_mismatch(self): - """Registration fails when passwords do not match.""" - resp = self.client.post(reverse('register'), { + def test_register_invalid_password_mismatch(self, client): + resp = client.post(reverse('register'), { 'username': 'bad', 'email': 'b@example.com', 'password1': 'StrongPass123', 'password2': 'StrongPass124', }) - self.assertEqual(resp.status_code, 200) - self.assertFalse(User.objects.filter(username='bad').exists()) + assert resp.status_code == 200 + assert not User.objects.filter(username='bad').exists() - def test_login_valid(self): - """User can log in with correct credentials.""" + def test_login_valid(self, client): User.objects.create_user('u', 'u@example.com', 'p@55word!') - resp = self.client.post(reverse('login'), { + resp = client.post(reverse('login'), { 'username': 'u', 'password': 'p@55word!' }) - self.assertRedirects(resp, reverse('profile')) + assert resp.status_code == 302 + assert resp.url == reverse('profile') - def test_login_invalid(self): - """Login fails with invalid credentials.""" - resp = self.client.post(reverse('login'), { + def test_login_invalid(self, client): + resp = client.post(reverse('login'), { 'username': 'nope', 'password': 'wrong' }) - self.assertEqual(resp.status_code, 200) + assert resp.status_code == 200 - def test_profile_requires_authentication(self): - """Profile page redirects unauthenticated users to login.""" - resp = self.client.get(reverse('profile')) - self.assertEqual(resp.status_code, 302) # redirect to login + def test_profile_requires_authentication(self, client): + resp = client.get(reverse('profile')) + assert resp.status_code == 302 + assert reverse('login') in resp.url - def test_logout(self): - """User can log out and is redirected to login page.""" + def test_logout(self, client): User.objects.create_user('u', 'u@example.com', 'p@55word!') - self.client.post(reverse('login'), {'username': 'u', 'password': 'p@55word!'}) - resp = self.client.post(reverse('logout')) - self.assertRedirects(resp, reverse('login')) + client.post(reverse('login'), {'username': 'u', 'password': 'p@55word!'}) + resp = client.post(reverse('logout')) + assert resp.status_code == 302 + assert resp.url == reverse('login') -class APIAuthTests(TestCase): +@pytest.mark.django_db +class TestAPIAuth: """API authentication endpoint tests.""" - def setUp(self): - """Initialize API client before each test.""" - self.client = APIClient() + @pytest.fixture + def api_client(self): + return APIClient() - def test_api_register_valid(self): - """API: register user with valid data.""" - resp = self.client.post('/api/auth/register/', { + def test_api_register_valid(self, api_client): + resp = api_client.post('/api/auth/register/', { 'username': 'maksim_api', 'email': 'mapi@example.com', 'password': 'StrongPass123', }, format='json') - self.assertEqual(resp.status_code, 201) - self.assertTrue(User.objects.filter(username='maksim_api').exists()) + assert resp.status_code == 201 + assert User.objects.filter(username='maksim_api').exists() - def test_api_register_invalid(self): - """API: registration fails with invalid data.""" - resp = self.client.post('/api/auth/register/', { + def test_api_register_invalid(self, api_client): + resp = api_client.post('/api/auth/register/', { 'username': '', 'email': 'bad', 'password': 'short', }, format='json') - self.assertEqual(resp.status_code, 400) + assert resp.status_code == 400 - def test_api_login_valid(self): - """API: user can log in with correct credentials.""" + def test_api_login_valid(self, api_client): User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - resp = self.client.post('/api/auth/login/', { + resp = api_client.post('/api/auth/login/', { 'username': 'uapi', 'password': 'p@55word!' }, format='json') - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.data['username'], 'uapi') + assert resp.status_code == 200 + assert resp.data['username'] == 'uapi' - def test_api_login_invalid(self): - """API: login fails with invalid credentials.""" - resp = self.client.post('/api/auth/login/', { + def test_api_login_invalid(self, api_client): + resp = api_client.post('/api/auth/login/', { 'username': 'nope', 'password': 'wrong' }, format='json') - self.assertEqual(resp.status_code, 400) + assert resp.status_code == 400 - def test_api_profile_authenticated(self): - """API: authenticated user can access their profile.""" + def test_api_profile_authenticated(self, api_client): User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - self.client.post('/api/auth/login/', { + api_client.post('/api/auth/login/', { 'username': 'uapi', 'password': 'p@55word!' }, format='json') - resp = self.client.get('/api/auth/profile/') - self.assertEqual(resp.status_code, 200) - self.assertEqual(resp.data['username'], 'uapi') + resp = api_client.get('/api/auth/profile/') + assert resp.status_code == 200 + assert resp.data['username'] == 'uapi' - def test_api_profile_unauthenticated(self): - """API: unauthenticated user cannot access profile.""" - resp = self.client.get('/api/auth/profile/') - self.assertEqual(resp.status_code, 403) + def test_api_profile_unauthenticated(self, api_client): + resp = api_client.get('/api/auth/profile/') + assert resp.status_code == 403 - def test_api_logout(self): - """API: user can log out successfully.""" + def test_api_logout(self, api_client): User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - self.client.post('/api/auth/login/', { + api_client.post('/api/auth/login/', { 'username': 'uapi', 'password': 'p@55word!' }, format='json') - resp = self.client.post('/api/auth/logout/') - self.assertEqual(resp.status_code, 200) + resp = api_client.post('/api/auth/logout/') + assert resp.status_code == 200 From db1ff8049e3356fda56b9ebdc7f121e1befb3f5e Mon Sep 17 00:00:00 2001 From: Surmachov Date: Thu, 30 Oct 2025 23:45:22 +0300 Subject: [PATCH 22/38] Created init_game_data managment command --- game/management/commands/init_game_data.py | 178 +++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 game/management/commands/init_game_data.py diff --git a/game/management/commands/init_game_data.py b/game/management/commands/init_game_data.py new file mode 100644 index 0000000..637259d --- /dev/null +++ b/game/management/commands/init_game_data.py @@ -0,0 +1,178 @@ +""" +Initialize default card suits, ranks and create Card entries. + +This management command will create standard card suits and ranks and then +create Card objects for each suit × rank combination for a chosen deck size. + +Usage: + python manage.py init_game_data + python manage.py init_game_data --deck-size 36 + python manage.py init_game_data --reset + +The command is idempotent by default (it uses get_or_create and updates mismatched +names/colors). Using --reset will delete existing Card, CardRank and CardSuit +records before recreating them. + +Module contents: + Command -- Django management command class implementing the behavior. +""" + +from django.core.management.base import BaseCommand +from django.db import transaction +from typing import List, Tuple + +from game.models import CardSuit, CardRank, Card + + +class Command(BaseCommand): + """ + Django management command to initialize card suits, ranks and cards. + + The command supports 24-, 36- and 52-card decks and an optional reset flag + which deletes existing Card, CardRank and CardSuit records before creating + new ones. + + Attributes: + help (str): Short description displayed by `manage.py help`. + """ + + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." + + def add_arguments(self, parser): + """ + Add command-line arguments for the management command. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + Recognized flags: + --deck-size {24,36,52}: Which deck to create (default 52). + --reset: If present, deletes existing Card/Rank/Suit rows before creating. + """ + parser.add_argument( + "--deck-size", + type=int, + choices=[24, 36, 52], + default=52, + help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", + ) + + def handle(self, *args, **options): + """ + Main entry point for the management command. + + This method creates suits and ranks (using get_or_create so it is safe to + run repeatedly), then creates Card objects for each combination of suit + and rank. If --reset is passed, existing Card, CardRank and CardSuit + records will be deleted first. + + Args: + *args: Positional arguments (unused). + **options: Command options dictionary with keys: + deck_size (int): Deck size to create (24, 36, 52). + reset (bool): Whether to delete existing entries first. + + Raises: + ValueError: If an unsupported deck size is provided (shouldn't happen + because argparse restricts choices). + """ + deck_size = options["deck_size"] + do_reset = options["reset"] + + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + + def ranks_for_deck(size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + ranks = ranks_for_deck(deck_size) + + with transaction.atomic(): + if do_reset: + self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...") + # Delete Cards first because of foreign key references to ranks & suits + Card.objects.all().delete() + CardRank.objects.all().delete() + CardSuit.objects.all().delete() + self.stdout.write("Existing card data deleted.") + + # Create or update suits + created_suits = [] + for name, color in suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + # Summary output + self.stdout.write(self.style.SUCCESS( + f"Deck initialization finished for deck_size={deck_size}." + )) + self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.") + self.stdout.write( + f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}") From 11cfb76414a83f84829ba22d2492990657214ca6 Mon Sep 17 00:00:00 2001 From: Viton8 Date: Fri, 31 Oct 2025 01:24:00 +0300 Subject: [PATCH 23/38] Add base.html, and solve reviewed problems --- Fools_Arena/urls.py | 2 +- accounts/api_urls.py | 15 ++++ accounts/api_views.py | 20 ++++- accounts/serializers.py | 3 +- accounts/templates/accounts/login.html | 12 ++- accounts/templates/accounts/profile.html | 6 ++ accounts/templates/accounts/registration.html | 6 ++ accounts/templates/base.html | 27 +++++++ accounts/tests/test_auth.py | 74 ++++++++++--------- accounts/views.py | 4 +- conftest.py | 6 ++ 11 files changed, 130 insertions(+), 45 deletions(-) create mode 100644 accounts/templates/base.html diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index da7a872..f8e673e 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -28,7 +28,7 @@ path('accounts/', include('accounts.urls')), # API - path('api/', include('accounts.api_urls')), + path('api/accounts/', include('accounts.api_urls')), ] diff --git a/accounts/api_urls.py b/accounts/api_urls.py index 6b3f3ea..6ddf279 100644 --- a/accounts/api_urls.py +++ b/accounts/api_urls.py @@ -1,6 +1,21 @@ from django.urls import path from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI +""" +Authentication API routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile retrieval, and logout. These routes are included in the +project’s main urls.py under the prefix "api/accounts/", which means +the final URLs are: + + /api/accounts/auth/register/ → Register a new user + /api/accounts/auth/login/ → Log in an existing user + /api/accounts/auth/profile/ → Retrieve the authenticated user's profile + /api/accounts/auth/logout/ → Log out the current user + +Each path is mapped to a class-based API view defined in accounts/api_views.py. +""" urlpatterns = [ path('auth/register/', RegistrationAPI.as_view(), name='api_register'), path('auth/login/', LoginAPI.as_view(), name='api_login'), diff --git a/accounts/api_views.py b/accounts/api_views.py index a025b9d..17e2e5c 100644 --- a/accounts/api_views.py +++ b/accounts/api_views.py @@ -5,12 +5,28 @@ from .serializers import RegistrationSerializer, LoginSerializer, ProfileSerializer class RegistrationAPI(generics.CreateAPIView): - """API endpoint for user registration.""" + """ + API endpoint for user registration. + + This view handles the creation of a new user account. + It uses the RegistrationSerializer to validate and save + the incoming data. Once the user is successfully created, + they are automatically logged in so that the client + immediately receives an authenticated session. + """ serializer_class = RegistrationSerializer permission_classes = [permissions.AllowAny] def perform_create(self, serializer): - """Create a new user and log them in automatically.""" + """ + Save the new user instance and log them in. + + This method overrides the default behavior of CreateAPIView. + After the serializer successfully saves the user, we call + Django's built-in auth_login to attach the user to the current + session. This ensures that the client does not need to perform + a separate login request right after registration. + """ user = serializer.save() auth_login(self.request, user) diff --git a/accounts/serializers.py b/accounts/serializers.py index cf717ff..0b910f6 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,6 +1,5 @@ -from django.contrib.auth import authenticate +from django.contrib.auth import authenticate, get_user_model from rest_framework import serializers -from django.contrib.auth import get_user_model User = get_user_model() diff --git a/accounts/templates/accounts/login.html b/accounts/templates/accounts/login.html index 9ce547f..ca0bc63 100644 --- a/accounts/templates/accounts/login.html +++ b/accounts/templates/accounts/login.html @@ -1,7 +1,15 @@ -

Extrance

+{% extends "base.html" %} + +{% block title %}Login{% endblock %} + +{% block content %} +

Login

{% csrf_token %} {{ form.as_p }}
-Registration +

+ Don't have an account? Registration +

+{% endblock %} diff --git a/accounts/templates/accounts/profile.html b/accounts/templates/accounts/profile.html index 88ba517..68191ef 100644 --- a/accounts/templates/accounts/profile.html +++ b/accounts/templates/accounts/profile.html @@ -1,3 +1,8 @@ +{% extends "base.html" %} + +{% block title %}Profile{% endblock %} + +{% block content %}

Profile

User name: {{ user.username }}

Email: {{ user.email }}

@@ -5,3 +10,4 @@

Profile

{% csrf_token %} +{% endblock %} diff --git a/accounts/templates/accounts/registration.html b/accounts/templates/accounts/registration.html index dfba916..52bbee1 100644 --- a/accounts/templates/accounts/registration.html +++ b/accounts/templates/accounts/registration.html @@ -1,3 +1,8 @@ +{% extends "base.html" %} + +{% block title %}Register{% endblock %} + +{% block content %}

Registration

{% csrf_token %} @@ -5,3 +10,4 @@

Registration

Already exist? Login +{% endblock %} diff --git a/accounts/templates/base.html b/accounts/templates/base.html new file mode 100644 index 0000000..61e5dd0 --- /dev/null +++ b/accounts/templates/base.html @@ -0,0 +1,27 @@ + + + + + {% block title %}Fools Arena{% endblock %} + + +
+

Fools Arena

+ +
+ +
+ {% block content %} + + {% endblock %} +
+ +
+

© 2025 Fools Arena

+
+ + diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index ae47f60..53a0e3d 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -2,7 +2,6 @@ from django.contrib.auth import get_user_model from django.urls import reverse from rest_framework.test import APIClient - User = get_user_model() @@ -31,11 +30,11 @@ def test_register_invalid_password_mismatch(self, client): assert resp.status_code == 200 assert not User.objects.filter(username='bad').exists() - def test_login_valid(self, client): - User.objects.create_user('u', 'u@example.com', 'p@55word!') + def test_login_valid(self, client, user_factory): + user = user_factory(password="test123") resp = client.post(reverse('login'), { - 'username': 'u', - 'password': 'p@55word!' + 'username': user.username, + 'password': 'test123', }) assert resp.status_code == 302 assert resp.url == reverse('profile') @@ -52,9 +51,9 @@ def test_profile_requires_authentication(self, client): assert resp.status_code == 302 assert reverse('login') in resp.url - def test_logout(self, client): - User.objects.create_user('u', 'u@example.com', 'p@55word!') - client.post(reverse('login'), {'username': 'u', 'password': 'p@55word!'}) + def test_logout(self, client, user_factory): + user = user_factory(password="test123") + client.post(reverse('login'), {'username': user.username, 'password': 'test123'}) resp = client.post(reverse('logout')) assert resp.status_code == 302 assert resp.url == reverse('login') @@ -64,12 +63,9 @@ def test_logout(self, client): class TestAPIAuth: """API authentication endpoint tests.""" - @pytest.fixture - def api_client(self): - return APIClient() - def test_api_register_valid(self, api_client): - resp = api_client.post('/api/auth/register/', { + register_url = reverse("api_register") + resp = api_client.post(register_url, { 'username': 'maksim_api', 'email': 'mapi@example.com', 'password': 'StrongPass123', @@ -78,48 +74,56 @@ def test_api_register_valid(self, api_client): assert User.objects.filter(username='maksim_api').exists() def test_api_register_invalid(self, api_client): - resp = api_client.post('/api/auth/register/', { + register_url = reverse("api_register") + resp = api_client.post(register_url, { 'username': '', 'email': 'bad', 'password': 'short', }, format='json') assert resp.status_code == 400 - def test_api_login_valid(self, api_client): - User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - resp = api_client.post('/api/auth/login/', { - 'username': 'uapi', - 'password': 'p@55word!' + def test_api_login_valid(self, api_client, user_factory): + user = user_factory(password="test123") + url = reverse("api_login") + resp = api_client.post(url, { + 'username': user.username, + 'password': 'test123' }, format='json') assert resp.status_code == 200 - assert resp.data['username'] == 'uapi' + assert resp.data['username'] == user.username def test_api_login_invalid(self, api_client): - resp = api_client.post('/api/auth/login/', { + url = reverse("api_login") + resp = api_client.post(url, { 'username': 'nope', 'password': 'wrong' }, format='json') assert resp.status_code == 400 - def test_api_profile_authenticated(self, api_client): - User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - api_client.post('/api/auth/login/', { - 'username': 'uapi', - 'password': 'p@55word!' + def test_api_profile_authenticated(self, api_client, user_factory): + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' }, format='json') - resp = api_client.get('/api/auth/profile/') + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) assert resp.status_code == 200 - assert resp.data['username'] == 'uapi' + assert resp.data['username'] == user.username def test_api_profile_unauthenticated(self, api_client): - resp = api_client.get('/api/auth/profile/') + profile_url = reverse("api_profile") + resp = api_client.get(profile_url) assert resp.status_code == 403 - def test_api_logout(self, api_client): - User.objects.create_user('uapi', 'uapi@example.com', 'p@55word!') - api_client.post('/api/auth/login/', { - 'username': 'uapi', - 'password': 'p@55word!' + def test_api_logout(self, api_client, user_factory): + login_url = reverse("api_login") + user = user_factory(password="test123") + api_client.post(login_url, { + 'username': user.username, + 'password': 'test123' }, format='json') - resp = api_client.post('/api/auth/logout/') + logout_url = reverse("api_logout") + resp = api_client.post(logout_url) assert resp.status_code == 200 diff --git a/accounts/views.py b/accounts/views.py index f415a63..f7315f3 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,10 +1,8 @@ -from django.shortcuts import render - -# Create your views here. from django.contrib.auth import login as auth_login, logout as auth_logout from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect from django.views.decorators.csrf import csrf_protect + from .forms import RegistrationForm, LoginForm @csrf_protect diff --git a/conftest.py b/conftest.py index 23d949f..1a1b3b4 100644 --- a/conftest.py +++ b/conftest.py @@ -29,6 +29,7 @@ def test_basic_game_has_trump(basic_game, basic_cards): django.setup() from django.contrib.auth import get_user_model +from rest_framework.test import APIClient from game.models import ( CardSuit, CardRank, Card, Lobby, LobbySettings, Game, GamePlayer, SpecialCard, SpecialRuleSet @@ -357,3 +358,8 @@ def basic_rule_set(db): description="Simple special cards for new players", min_players=2 ) + +@pytest.fixture +def api_client(): + """DRF APIClient for API-tests.""" + return APIClient() From 7982b000fd4101e2f410f5d99cfefd1137f9416e Mon Sep 17 00:00:00 2001 From: Viton8 Date: Fri, 31 Oct 2025 20:30:34 +0300 Subject: [PATCH 24/38] unify comments across views, serializers, and tests --- accounts/api_urls.py | 10 +++--- accounts/api_views.py | 67 ++++++++++++++++++++++++++++--------- accounts/forms.py | 28 ++++++++++++++-- accounts/serializers.py | 49 +++++++++++++++++++++++---- accounts/tests/test_auth.py | 15 ++++++++- accounts/urls.py | 16 +++++++++ accounts/views.py | 48 +++++++++++++++++++++++--- 7 files changed, 199 insertions(+), 34 deletions(-) diff --git a/accounts/api_urls.py b/accounts/api_urls.py index 6ddf279..8d6dffa 100644 --- a/accounts/api_urls.py +++ b/accounts/api_urls.py @@ -1,6 +1,3 @@ -from django.urls import path -from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI - """ Authentication API routes for the Accounts app. @@ -13,9 +10,14 @@ /api/accounts/auth/login/ → Log in an existing user /api/accounts/auth/profile/ → Retrieve the authenticated user's profile /api/accounts/auth/logout/ → Log out the current user - + Each path is mapped to a class-based API view defined in accounts/api_views.py. """ + +from django.urls import path +from .api_views import RegistrationAPI, LoginAPI, ProfileAPI, LogoutAPI + + urlpatterns = [ path('auth/register/', RegistrationAPI.as_view(), name='api_register'), path('auth/login/', LoginAPI.as_view(), name='api_login'), diff --git a/accounts/api_views.py b/accounts/api_views.py index 17e2e5c..6097b31 100644 --- a/accounts/api_views.py +++ b/accounts/api_views.py @@ -1,3 +1,18 @@ +""" +API views for the Accounts app. + +This module defines class-based views for handling user authentication +via RESTful endpoints. It includes registration, login, profile retrieval, +and logout functionality. These views are connected to the routes defined +in accounts/api_urls.py and use serializers from accounts/serializers.py. + +Available API views: + - RegistrationAPI: create a new user and log them in automatically. + - LoginAPI: authenticate user credentials and start a session. + - ProfileAPI: return profile data for the authenticated user. + - LogoutAPI: end the current user session. +""" + from django.contrib.auth import login as auth_login, logout as auth_logout from rest_framework import generics, permissions, status from rest_framework.response import Response @@ -8,11 +23,8 @@ class RegistrationAPI(generics.CreateAPIView): """ API endpoint for user registration. - This view handles the creation of a new user account. - It uses the RegistrationSerializer to validate and save - the incoming data. Once the user is successfully created, - they are automatically logged in so that the client - immediately receives an authenticated session. + Handles the creation of a new user account using validated input. + Automatically logs in the newly created user to establish a session. """ serializer_class = RegistrationSerializer permission_classes = [permissions.AllowAny] @@ -21,21 +33,28 @@ def perform_create(self, serializer): """ Save the new user instance and log them in. - This method overrides the default behavior of CreateAPIView. - After the serializer successfully saves the user, we call - Django's built-in auth_login to attach the user to the current - session. This ensures that the client does not need to perform - a separate login request right after registration. + Overrides the default CreateAPIView behavior to attach the user + to the current session immediately after registration. """ user = serializer.save() auth_login(self.request, user) class LoginAPI(APIView): - """API endpoint for user login.""" + """ + API endpoint for user login. + + Accepts username and password, authenticates the user, + and returns their profile data upon successful login. + """ permission_classes = [permissions.AllowAny] def post(self, request): - """Authenticate user credentials and start a session.""" + """ + Authenticate user credentials and start a session. + + If credentials are valid, the user is logged in and their + profile data is returned in the response. + """ serializer = LoginSerializer(data=request.data) serializer.is_valid(raise_exception=True) user = serializer.validated_data['user'] @@ -43,19 +62,35 @@ def post(self, request): return Response(ProfileSerializer(user).data) class ProfileAPI(generics.RetrieveAPIView): - """API endpoint for retrieving the authenticated user's profile.""" + """ + API endpoint for retrieving the authenticated user's profile. + + Requires the user to be logged in. Returns basic profile information. + """ serializer_class = ProfileSerializer permission_classes = [permissions.IsAuthenticated] def get_object(self): - """Return the current authenticated user.""" + """ + Return the current authenticated user. + + Used by RetrieveAPIView to serialize and return profile data. + """ return self.request.user class LogoutAPI(APIView): - """API endpoint for logging out the current user.""" + """ + API endpoint for logging out the current user. + + Requires authentication. Ends the session and returns a confirmation message. + """ permission_classes = [permissions.IsAuthenticated] def post(self, request): - """End the current user session.""" + """ + End the current user session. + + Logs out the user and returns a success response. + """ auth_logout(request) return Response({'detail': 'You are out of the system'}, status=status.HTTP_200_OK) diff --git a/accounts/forms.py b/accounts/forms.py index 933479b..945d67c 100644 --- a/accounts/forms.py +++ b/accounts/forms.py @@ -1,3 +1,15 @@ +""" +Forms for the Accounts app. + +This module defines form classes used for user registration and login. +They extend Django's built-in authentication forms to include additional +fields or custom behavior where necessary. + +Available forms: + - RegistrationForm: extends UserCreationForm to include an email field. + - LoginForm: extends AuthenticationForm for user login. +""" + from django import forms from django.contrib.auth.forms import UserCreationForm, AuthenticationForm from django.contrib.auth import get_user_model @@ -5,7 +17,13 @@ User = get_user_model() class RegistrationForm(UserCreationForm): - """Form for user registration with email field included.""" + """ + Form for user registration. + + Extends Django's built-in UserCreationForm by adding + a required email field. Handles validation and creation + of a new user instance with username, email, and password. + """ email = forms.EmailField(required=True) class Meta: @@ -13,5 +31,11 @@ class Meta: fields = ('username', 'email', 'password1', 'password2') class LoginForm(AuthenticationForm): - """Form for user login using Django's built-in authentication.""" + """ + Form for user login. + + Extends Django's built-in AuthenticationForm without + additional fields. Used to authenticate existing users + with their username and password. + """ pass diff --git a/accounts/serializers.py b/accounts/serializers.py index 0b910f6..979d751 100644 --- a/accounts/serializers.py +++ b/accounts/serializers.py @@ -1,11 +1,28 @@ +""" +Serializers for the Accounts app. + +This module defines serializers used for user authentication and profile +management. They handle validation and transformation of input/output data +between Django models and API views. + +Available serializers: + - RegistrationSerializer: validates and creates new user accounts. + - LoginSerializer: authenticates existing users with username/password. + - ProfileSerializer: returns basic profile information for authenticated users. +""" + from django.contrib.auth import authenticate, get_user_model from rest_framework import serializers User = get_user_model() class RegistrationSerializer(serializers.ModelSerializer): - # Serializer for user registration - # Validates and creates a new user instance + """ + Serializer for user registration. + + Validates the provided username, email, and password. + Creates a new user instance with an encrypted password. + """ password = serializers.CharField(write_only=True, min_length=8) class Meta: @@ -13,7 +30,12 @@ class Meta: fields = ('username', 'email', 'password') def create(self, validated_data): - # Creates a new user with encrypted password + """ + Create a new user with the given validated data. + + Uses Django's built-in create_user method to ensure + the password is properly hashed before saving. + """ return User.objects.create_user( username=validated_data['username'], email=validated_data.get('email', ''), @@ -21,13 +43,22 @@ def create(self, validated_data): ) class LoginSerializer(serializers.Serializer): - # Serializer for user login - # Authenticates user credentials + """ + Serializer for user login. + + Accepts username and password, and authenticates the user + using Django's built-in authentication system. + """ username = serializers.CharField() password = serializers.CharField(write_only=True) def validate(self, attrs): - # Validates the provided username and password + """ + Validate the provided credentials. + + If authentication fails, raise a ValidationError. + On success, attach the authenticated user to attrs. + """ user = authenticate(username=attrs['username'], password=attrs['password']) if not user: raise serializers.ValidationError('Incorrect login details') @@ -35,7 +66,11 @@ def validate(self, attrs): return attrs class ProfileSerializer(serializers.ModelSerializer): - # Serializer for displaying user profile data + """ + Serializer for displaying user profile data. + + Returns basic information about the authenticated user. + """ class Meta: model = User fields = ('id', 'username', 'email') diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index 53a0e3d..bdabc12 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -1,7 +1,7 @@ import pytest from django.contrib.auth import get_user_model from django.urls import reverse -from rest_framework.test import APIClient + User = get_user_model() @@ -10,6 +10,7 @@ class TestTemplateAuth: """UI-based authentication tests.""" def test_register_valid(self, client): + # Valid registration should redirect to profile and create a user resp = client.post(reverse('register'), { 'username': 'maksim', 'email': 'm@example.com', @@ -21,6 +22,7 @@ def test_register_valid(self, client): assert User.objects.filter(username='maksim').exists() def test_register_invalid_password_mismatch(self, client): + # Registration with mismatched passwords should fail and not create a user resp = client.post(reverse('register'), { 'username': 'bad', 'email': 'b@example.com', @@ -31,6 +33,7 @@ def test_register_invalid_password_mismatch(self, client): assert not User.objects.filter(username='bad').exists() def test_login_valid(self, client, user_factory): + # Valid login should redirect to profile user = user_factory(password="test123") resp = client.post(reverse('login'), { 'username': user.username, @@ -40,6 +43,7 @@ def test_login_valid(self, client, user_factory): assert resp.url == reverse('profile') def test_login_invalid(self, client): + # Invalid login should return the login page with no redirect resp = client.post(reverse('login'), { 'username': 'nope', 'password': 'wrong' @@ -47,11 +51,13 @@ def test_login_invalid(self, client): assert resp.status_code == 200 def test_profile_requires_authentication(self, client): + # Accessing profile without login should redirect to login page resp = client.get(reverse('profile')) assert resp.status_code == 302 assert reverse('login') in resp.url def test_logout(self, client, user_factory): + # Logged-in user should be logged out and redirected to login user = user_factory(password="test123") client.post(reverse('login'), {'username': user.username, 'password': 'test123'}) resp = client.post(reverse('logout')) @@ -64,6 +70,7 @@ class TestAPIAuth: """API authentication endpoint tests.""" def test_api_register_valid(self, api_client): + # Valid API registration should return 201 and create a user register_url = reverse("api_register") resp = api_client.post(register_url, { 'username': 'maksim_api', @@ -74,6 +81,7 @@ def test_api_register_valid(self, api_client): assert User.objects.filter(username='maksim_api').exists() def test_api_register_invalid(self, api_client): + # Invalid API registration should return 400 register_url = reverse("api_register") resp = api_client.post(register_url, { 'username': '', @@ -83,6 +91,7 @@ def test_api_register_invalid(self, api_client): assert resp.status_code == 400 def test_api_login_valid(self, api_client, user_factory): + # Valid API login should return profile data user = user_factory(password="test123") url = reverse("api_login") resp = api_client.post(url, { @@ -93,6 +102,7 @@ def test_api_login_valid(self, api_client, user_factory): assert resp.data['username'] == user.username def test_api_login_invalid(self, api_client): + # Invalid API login should return 400 url = reverse("api_login") resp = api_client.post(url, { 'username': 'nope', @@ -101,6 +111,7 @@ def test_api_login_invalid(self, api_client): assert resp.status_code == 400 def test_api_profile_authenticated(self, api_client, user_factory): + # Authenticated user should receive profile data login_url = reverse("api_login") user = user_factory(password="test123") api_client.post(login_url, { @@ -113,11 +124,13 @@ def test_api_profile_authenticated(self, api_client, user_factory): assert resp.data['username'] == user.username def test_api_profile_unauthenticated(self, api_client): + # Unauthenticated request to profile should return 403 profile_url = reverse("api_profile") resp = api_client.get(profile_url) assert resp.status_code == 403 def test_api_logout(self, api_client, user_factory): + # Authenticated user should be able to log out successfully login_url = reverse("api_login") user = user_factory(password="test123") api_client.post(login_url, { diff --git a/accounts/urls.py b/accounts/urls.py index 5f5feeb..0444d0f 100644 --- a/accounts/urls.py +++ b/accounts/urls.py @@ -1,3 +1,19 @@ +""" +Authentication template routes for the Accounts app. + +This module defines the endpoints for user registration, login, +profile display, and logout. These routes are included in the +project’s main urls.py under the prefix "accounts/", which means +the final URLs are: + + /accounts/register/ → Render the registration form and create a new user + /accounts/login/ → Render the login form and authenticate a user + /accounts/profile/ → Display the authenticated user's profile page + /accounts/logout/ → Log out the current user and redirect accordingly + +Each path is mapped to a function-based view defined in accounts/views.py. +""" + from django.urls import path from .views import register_view, login_view, profile_view, logout_view diff --git a/accounts/views.py b/accounts/views.py index f7315f3..55ce079 100644 --- a/accounts/views.py +++ b/accounts/views.py @@ -1,3 +1,18 @@ +""" +Views for the Accounts app. + +This module defines function-based views for handling user authentication +through HTML templates. It includes registration, login, profile display, +and logout functionality. These views are connected to the routes defined +in accounts/urls.py and render templates located in accounts/templates/accounts/. + +Available views: + - register_view: render and process the registration form. + - login_view: render and process the login form. + - profile_view: display the authenticated user's profile page. + - logout_view: log out the current user and redirect accordingly. +""" + from django.contrib.auth import login as auth_login, logout as auth_logout from django.contrib.auth.decorators import login_required from django.shortcuts import render, redirect @@ -7,7 +22,14 @@ @csrf_protect def register_view(request): - """Register a new user and log them in.""" + """ + Render and process the registration form. + + If the request method is POST and the form is valid, a new user + is created and automatically logged in. On success, the user is + redirected to the profile page. Otherwise, the registration form + is re-rendered with validation errors. + """ if request.method == 'POST': form = RegistrationForm(request.POST) if form.is_valid(): @@ -20,7 +42,14 @@ def register_view(request): @csrf_protect def login_view(request): - """Authenticate and log in an existing user.""" + """ + Render and process the login form. + + If the request method is POST and the form is valid, the user + is authenticated and logged in. On success, the user is redirected + to the profile page. Otherwise, the login form is re-rendered with + validation errors. + """ if request.method == 'POST': form = LoginForm(request, data=request.POST) if form.is_valid(): @@ -32,12 +61,23 @@ def login_view(request): @login_required def profile_view(request): - """Display the authenticated user's profile.""" + """ + Display the authenticated user's profile page. + + Requires the user to be logged in. If the user is not authenticated, + they will be redirected to the login page. + """ return render(request, 'accounts/profile.html') @csrf_protect def logout_view(request): - """Log out the current user.""" + """ + Log out the current user. + + If the request method is POST, the user is logged out and redirected + to the login page. For non-POST requests, the user is redirected + back to the profile page. + """ if request.method == 'POST': auth_logout(request) return redirect('login') From a7be0ee7d963b1512fd9cd6f33d4ed5de3f6cc86 Mon Sep 17 00:00:00 2001 From: Kiryl Alishkevich <64920776+uxabix@users.noreply.github.com> Date: Fri, 31 Oct 2025 18:39:45 +0100 Subject: [PATCH 25/38] Refactor docstrings in authentication test cases Updated docstrings for authentication tests to provide clearer explanations of each test case, including details about expected behavior and parameters. --- accounts/tests/test_auth.py | 87 ++++++++++++++++++++++++++++++------- 1 file changed, 72 insertions(+), 15 deletions(-) diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index bdabc12..ba3ae5b 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -1,3 +1,11 @@ +"""Authentication tests for both UI and REST API endpoints. + +This module contains test cases for verifying authentication flows in +a Django application that provides both template-based (UI) and +REST API endpoints. Tests cover registration, login, logout, and +profile access behaviors. +""" + import pytest from django.contrib.auth import get_user_model from django.urls import reverse @@ -7,10 +15,17 @@ @pytest.mark.django_db class TestTemplateAuth: - """UI-based authentication tests.""" + """Test suite for UI-based authentication using Django templates.""" def test_register_valid(self, client): - # Valid registration should redirect to profile and create a user + """Test successful registration through UI. + + Sends valid registration data through a POST request to the 'register' view. + Ensures that the user is created and redirected to the profile page. + + Args: + client (django.test.Client): Django test client fixture. + """ resp = client.post(reverse('register'), { 'username': 'maksim', 'email': 'm@example.com', @@ -22,7 +37,11 @@ def test_register_valid(self, client): assert User.objects.filter(username='maksim').exists() def test_register_invalid_password_mismatch(self, client): - # Registration with mismatched passwords should fail and not create a user + """Test registration with mismatched passwords. + + Ensures that invalid password confirmation prevents user creation + and that the registration form is re-rendered with status 200. + """ resp = client.post(reverse('register'), { 'username': 'bad', 'email': 'b@example.com', @@ -33,7 +52,10 @@ def test_register_invalid_password_mismatch(self, client): assert not User.objects.filter(username='bad').exists() def test_login_valid(self, client, user_factory): - # Valid login should redirect to profile + """Test successful login through UI. + + Verifies that valid credentials redirect the user to the profile page. + """ user = user_factory(password="test123") resp = client.post(reverse('login'), { 'username': user.username, @@ -43,7 +65,10 @@ def test_login_valid(self, client, user_factory): assert resp.url == reverse('profile') def test_login_invalid(self, client): - # Invalid login should return the login page with no redirect + """Test login with invalid credentials. + + Ensures the response remains on the login page (status 200) and does not redirect. + """ resp = client.post(reverse('login'), { 'username': 'nope', 'password': 'wrong' @@ -51,13 +76,19 @@ def test_login_invalid(self, client): assert resp.status_code == 200 def test_profile_requires_authentication(self, client): - # Accessing profile without login should redirect to login page + """Test profile page access without authentication. + + Ensures that unauthenticated users are redirected to the login page. + """ resp = client.get(reverse('profile')) assert resp.status_code == 302 assert reverse('login') in resp.url def test_logout(self, client, user_factory): - # Logged-in user should be logged out and redirected to login + """Test logout functionality through UI. + + Verifies that an authenticated user is logged out and redirected to the login page. + """ user = user_factory(password="test123") client.post(reverse('login'), {'username': user.username, 'password': 'test123'}) resp = client.post(reverse('logout')) @@ -67,10 +98,14 @@ def test_logout(self, client, user_factory): @pytest.mark.django_db class TestAPIAuth: - """API authentication endpoint tests.""" + """Test suite for REST API authentication endpoints.""" def test_api_register_valid(self, api_client): - # Valid API registration should return 201 and create a user + """Test successful API registration. + + Sends valid user data to the registration endpoint and verifies + that a new user is created with HTTP 201 Created. + """ register_url = reverse("api_register") resp = api_client.post(register_url, { 'username': 'maksim_api', @@ -81,7 +116,10 @@ def test_api_register_valid(self, api_client): assert User.objects.filter(username='maksim_api').exists() def test_api_register_invalid(self, api_client): - # Invalid API registration should return 400 + """Test API registration with invalid data. + + Ensures that malformed input returns HTTP 400 Bad Request. + """ register_url = reverse("api_register") resp = api_client.post(register_url, { 'username': '', @@ -91,7 +129,11 @@ def test_api_register_invalid(self, api_client): assert resp.status_code == 400 def test_api_login_valid(self, api_client, user_factory): - # Valid API login should return profile data + """Test successful API login. + + Sends valid credentials to the login endpoint and verifies that + profile data is returned in the response. + """ user = user_factory(password="test123") url = reverse("api_login") resp = api_client.post(url, { @@ -102,7 +144,10 @@ def test_api_login_valid(self, api_client, user_factory): assert resp.data['username'] == user.username def test_api_login_invalid(self, api_client): - # Invalid API login should return 400 + """Test API login with invalid credentials. + + Ensures that incorrect credentials return HTTP 400 Bad Request. + """ url = reverse("api_login") resp = api_client.post(url, { 'username': 'nope', @@ -111,7 +156,11 @@ def test_api_login_invalid(self, api_client): assert resp.status_code == 400 def test_api_profile_authenticated(self, api_client, user_factory): - # Authenticated user should receive profile data + """Test authenticated API profile access. + + After logging in, verifies that the authenticated user can retrieve + their own profile data. + """ login_url = reverse("api_login") user = user_factory(password="test123") api_client.post(login_url, { @@ -124,13 +173,21 @@ def test_api_profile_authenticated(self, api_client, user_factory): assert resp.data['username'] == user.username def test_api_profile_unauthenticated(self, api_client): - # Unauthenticated request to profile should return 403 + """Test unauthenticated API profile access. + + Ensures that accessing the profile endpoint without authentication + returns HTTP 403 Forbidden. + """ profile_url = reverse("api_profile") resp = api_client.get(profile_url) assert resp.status_code == 403 def test_api_logout(self, api_client, user_factory): - # Authenticated user should be able to log out successfully + """Test API logout. + + Verifies that an authenticated user can log out successfully, + receiving HTTP 200 OK in response. + """ login_url = reverse("api_login") user = user_factory(password="test123") api_client.post(login_url, { From ffffcd5136bd282b98962f1252c5d07d776efa9c Mon Sep 17 00:00:00 2001 From: Viton8 Date: Fri, 31 Oct 2025 21:17:41 +0300 Subject: [PATCH 26/38] fix wrong indentation in accounts/test_auth.py --- accounts/tests/test_auth.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/accounts/tests/test_auth.py b/accounts/tests/test_auth.py index ba3ae5b..c08c576 100644 --- a/accounts/tests/test_auth.py +++ b/accounts/tests/test_auth.py @@ -37,7 +37,7 @@ def test_register_valid(self, client): assert User.objects.filter(username='maksim').exists() def test_register_invalid_password_mismatch(self, client): - """Test registration with mismatched passwords. + """Test registration with mismatched passwords. Ensures that invalid password confirmation prevents user creation and that the registration form is re-rendered with status 200. From ed7899ae79b653ba49f9b52200693402a13afe78 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Fri, 31 Oct 2025 22:41:51 +0300 Subject: [PATCH 27/38] Created generate_fake_games, reset_games and updated generate_test_users commands --- .../commands/generate_test_users.py | 356 ++++++++----- .../commands/generate_fake_games.py | 488 ++++++++++++++++++ game/management/commands/reset_games.py | 170 ++++++ 3 files changed, 876 insertions(+), 138 deletions(-) create mode 100644 game/management/commands/generate_fake_games.py create mode 100644 game/management/commands/reset_games.py diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 6fe0a9f..6e94154 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -1,224 +1,304 @@ """ -generate_test_users management command. +Create test users for development/testing and optionally delete all users +belonging to the marker group (default: Test_Users). -Creates one or more test users for development/testing. +This command has two modes: -Example: - # create 5 regular test users with default password - python manage.py generate_test_users --count 5 --prefix testuser +* Creation (default): create users with --count, --prefix, --password, etc. + Created users are added to the marker group so they can be deleted safely later. - # create 3 staff users with a custom email domain and password - python manage.py generate_test_users -c 3 --prefix staff_ --email-domain example.org --password secret123 --staff +* Deletion: pass --delete to delete all users who are members of the marker group. + Deletion excludes staff and superusers by default to avoid accidental removal. - # create 1 superuser - python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass +Examples: + # Create 5 users: + python manage.py generate_test_users --count 5 --prefix dev_ --password secret + + # Dry-run create: + python manage.py generate_test_users --count 3 --prefix demo --dry-run + + # Delete all users in Test_Users group (interactive confirmation) + python manage.py generate_test_users --delete + + # Delete without prompt (careful!) + python manage.py generate_test_users --delete --noinput + + # Preview deletions without performing them + python manage.py generate_test_users --delete --dry-run """ +from __future__ import annotations -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model import random import string -from typing import List +from typing import List, Optional + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.db import transaction User = get_user_model() def _random_suffix(length: int = 4) -> str: - """Return a short random alphanumeric suffix. + """Generate a short random alphanumeric suffix. Args: - length: Length of the suffix. + length: Length of the suffix (default: 4). Returns: A random string composed of lowercase letters and digits. """ chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + return "".join(random.choice(chars) for _ in range(length)) class Command(BaseCommand): - """Django command to generate test users. - - The command creates users using `User.objects.create_user()` (or - `create_superuser()` when the `--superuser` flag is used). It will skip - usernames that already exist unless `--force` is provided, in which case - a short random suffix is appended to the username. - - Methods - ------- - add_arguments(parser): - Add command-line arguments. - handle(*args, **options): - Main entry point that creates users according to parsed options. + """Management command to create test users and delete marker-group users. + + Creation mode (default) creates test users and adds them to a marker group + (default group name: "Test_Users") so they can be deleted later. + + Deletion mode (pass --delete) deletes all users who are members of the + marker group. Staff and superusers are excluded from deletion by default. """ - help = "Generate test users (regular, staff, or superuser) for development." + help = "Create test users or delete all users in the marker group (use --delete)." def add_arguments(self, parser): - """Add command-line arguments. - - Args: - parser: The argparse parser to configure. - """ + """Define command-line arguments.""" + # Creation args parser.add_argument( - '--count', '-c', + "--count", + "-c", type=int, default=1, - help='Number of users to create (default: 1)' + help="Number of users to create (default: 1).", ) parser.add_argument( - '--prefix', '-p', + "--prefix", + "-p", type=str, - default='testuser', - help='Prefix for usernames (default: "testuser")' + default="testuser", + help='Prefix for usernames (default: "testuser").', ) parser.add_argument( - '--start', + "--start", type=int, default=1, - help='Starting index appended to username (default: 1)' + help="Starting index appended to username (default: 1).", ) parser.add_argument( - '--email-domain', + "--email-domain", type=str, - default='example.com', - help='Email domain to use for generated users (default: example.com)' + default="example.com", + help="Email domain for generated users (default: example.com).", ) parser.add_argument( - '--password', + "--password", type=str, - default='test_password', - help='Password to set for all created users (default: "test_password")' + default="test_password", + help='Password to set for created users (default: "test_password").', ) parser.add_argument( - '--staff', - action='store_true', - help='Mark created users as staff (is_staff=True)' + "--staff", + action="store_true", + help="Mark created users as staff (is_staff=True).", ) parser.add_argument( - '--superuser', - action='store_true', - help='Create superuser(s) (uses create_superuser)' + "--superuser", + action="store_true", + help="Create superuser(s).", ) parser.add_argument( - '--inactive', - action='store_true', - help='Create users with is_active=False' + "--inactive", + action="store_true", + help="Create users with is_active=False.", ) parser.add_argument( - '--force', - action='store_true', - help='If username exists, append random suffix and create anyway' + "--force", + action="store_true", + help="If username exists, append short random suffix and create anyway.", ) + + # Marker group (default Test_Users) parser.add_argument( - '--dry-run', - action='store_true', - help='Print what would be created without saving to the database' + "--marker-group", + type=str, + default="Test_Users", + help='Group name used to mark generated users (default: "Test_Users").', ) - def _make_username(self, prefix: str, idx: int) -> str: - """Construct a username from prefix and index. + # Deletion mode - simplified: one flag to delete all marker-group members + parser.add_argument( + "--delete", + action="store_true", + help="Delete ALL users who are members of the marker group (default: Test_Users).", + ) - Args: - prefix: Username prefix. - idx: Index to append. + # Shared safety args + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview actions (no DB changes).", + ) + parser.add_argument( + "--noinput", + action="store_true", + help="Do not prompt for confirmation when deleting (use with care).", + ) - Returns: - The composed username (e.g. "prefix1"). - """ + def _make_username(self, prefix: str, idx: int) -> str: + """Construct username from prefix and index.""" return f"{prefix}{idx}" def _email_for_username(self, username: str, domain: str) -> str: - """Construct an email address for a username. - - Args: - username: The username to use before the @. - domain: The domain to use after the @. - - Returns: - A complete email address string. - """ + """Construct a simple email for a username.""" return f"{username}@{domain}" def handle(self, *args, **options): - """Create the requested number of test users. - - Args: - *args: positional args (unused). - **options: Parsed command-line options. - - Returns: - None - """ - count: int = options['count'] - prefix: str = options['prefix'] - start: int = options['start'] - email_domain: str = options['email_domain'] - password: str = options['password'] - make_staff: bool = options['staff'] - make_superuser: bool = options['superuser'] - inactive: bool = options['inactive'] - force: bool = options['force'] - dry_run: bool = options['dry_run'] + """Main entry: create users or delete marker-group users.""" + dry_run: bool = options.get("dry_run", False) + marker_group_name: str = options.get("marker_group") or "Test_Users" + + # Deletion mode: delete all users in marker group (excluding staff/superuser) + if options.get("delete"): + # Ensure the group exists + try: + group = Group.objects.get(name=marker_group_name) + except Group.DoesNotExist: + self.stdout.write(self.style.WARNING( + f"Marker group '{marker_group_name}' does not exist. Nothing to delete." + )) + return + + # Query users in the group + qs = User.objects.filter(groups__name=marker_group_name) + + # Exclude staff and superuser accounts for safety if model has those flags + if hasattr(User, "is_staff"): + qs = qs.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + qs = qs.exclude(is_superuser=True) + + total = qs.count() + if total == 0: + self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) + return + + # List matched users + self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) + for u in qs: + parts = [f"username='{getattr(u, 'username', '')}'"] + if getattr(u, "email", None): + parts.append(f"email='{u.email}'") + self.stdout.write(" - " + " ".join(parts)) + + if dry_run: + self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) + return + + # Confirm unless noinput + if not options.get("noinput"): + answer = input("Delete all listed users? This is irreversible. [y/N]: ") + if answer.lower() not in ("y", "yes"): + self.stdout.write(self.style.WARNING("Aborted by user.")) + return + + # Perform deletions + deleted = 0 + failed = [] + try: + with transaction.atomic(): + for u in qs: + try: + u.delete() # call delete() to respect signals/cascades + deleted += 1 + except Exception as exc: + failed.append((u, exc)) + # continue deleting others + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) + if failed: + self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) + for u, exc in failed: + self.stdout.write(f" - {getattr(u, 'username', '')}: {exc}") + except Exception as exc_outer: + raise CommandError(f"Deletion transaction failed: {exc_outer}") + + return # done + + # Creation mode: create users and add to marker group + count: int = int(options.get("count", 1)) + prefix: str = options.get("prefix") or "testuser" + start: int = int(options.get("start", 1)) + email_domain: str = options.get("email_domain") or "example.com" + password: str = options.get("password") or "test_password" + make_staff: bool = bool(options.get("staff")) + make_superuser: bool = bool(options.get("superuser")) + inactive: bool = bool(options.get("inactive")) + force: bool = bool(options.get("force")) created: List[User] = [] - # loop to create the requested number of users + # Ensure marker group exists (get_or_create is safe; use admin-created group if present) + group_obj: Optional[Group] = None + try: + group_obj, _ = Group.objects.get_or_create(name=marker_group_name) + except Exception: + group_obj = None + for i in range(start, start + count): username = self._make_username(prefix, i) email = self._email_for_username(username, email_domain) - # If the username already exists and --force is not used, skip it. + # Handle existing username if User.objects.filter(username=username).exists(): if not force: - self.stdout.write(self.style.WARNING( - f"Skipping existing username: {username}" - )) + self.stdout.write(self.style.WARNING(f"Skipping existing username: {username}")) continue - - # If force mode, append a short random suffix to make it unique. username = f"{username}_{_random_suffix()}" email = self._email_for_username(username, email_domain) - self.stdout.write(self.style.NOTICE( - f"Username existed; using fallback username: {username}" - )) + self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) - # Dry-run prints and does not save to DB. if dry_run: - self.stdout.write(f"[DRY RUN] Would create: username='{username}', email='{email}', " - f"is_staff={make_staff}, is_superuser={make_superuser}, is_active={not inactive}") + self.stdout.write( + f"[DRY RUN] Would create username='{username}', email='{email}', staff={make_staff}, superuser={make_superuser}, active={not inactive}" + ) continue - # Create a superuser if requested (this calls create_superuser which - # typically sets is_staff/is_superuser automatically). + # Create user if make_superuser: - # create_superuser signature: (username, email=None, password=None, **extra_fields) - user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] - # Ensure flags align with requested options (some custom user models - # might require setting them explicitly). - user.is_staff = True - user.is_superuser = True + user = User.objects.create_superuser(username=username, email=email, + password=password) # type: ignore[attr-defined] + try: + user.is_staff = True + user.is_superuser = True + except Exception: + pass else: - # Regular user creation (hashes the password) - user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] - user.is_staff = bool(make_staff) - user.is_superuser = False - - # Set active state based on --inactive - user.is_active = not bool(inactive) + user = User.objects.create_user(username=username, email=email, + password=password) # type: ignore[attr-defined] + try: + user.is_staff = bool(make_staff) + user.is_superuser = False + except Exception: + pass + + try: + user.is_active = not bool(inactive) + except Exception: + pass + + # Add to marker group if possible + try: + if group_obj is not None and hasattr(user, "groups"): + user.groups.add(group_obj) + except Exception: + self.stdout.write( + self.style.WARNING(f"Warning: couldn't add user '{username}' to group '{marker_group_name}'")) - # Save changes (if any) and collect result. user.save() created.append(user) + self.stdout.write(self.style.SUCCESS(f"Created user: username='{username}' email='{email}'")) - # Informational output for each created user. - self.stdout.write(self.style.SUCCESS( - f"Created user: username='{username}' email='{email}' " - f"{'(staff)' if user.is_staff else ''} {'(superuser)' if user.is_superuser else ''}" - )) - - # Summary output - if dry_run: - self.stdout.write(self.style.WARNING("Dry run complete. No users were created.")) - else: - self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) diff --git a/game/management/commands/generate_fake_games.py b/game/management/commands/generate_fake_games.py new file mode 100644 index 0000000..630db51 --- /dev/null +++ b/game/management/commands/generate_fake_games.py @@ -0,0 +1,488 @@ +""" +Generate synthetic Durak game data for UI & statistics testing. + +This command builds realistic game rows (lobbies, games, game players, hands, +game deck, turns, table cards, moves, and discard piles). It prefers to create +test users by invoking the `generate_test_users` management command (which you +said lives in the `accounts` app). The command will call that management +command with a marker group (`Test_Users`) so created accounts are easy and +safe to delete later. + +Usage: + python manage.py generate_fake_games --games 3 --players 4 --moves 30 --card-count 36 +""" +from __future__ import annotations + +import itertools +import random +import re +import uuid +from typing import List, Optional + +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone +from django.contrib.auth import get_user_model + +from game.models import ( + CardSuit, + CardRank, + Card, + Lobby, + LobbySettings, + LobbyPlayer, + Game, + GamePlayer, + GameDeck, + PlayerHand, + TableCard, + DiscardPile, + Turn, + Move, +) + +User = get_user_model() + +DEFAULT_RANKS_52 = [ + ("2", 2), + ("3", 3), + ("4", 4), + ("5", 5), + ("6", 6), + ("7", 7), + ("8", 8), + ("9", 9), + ("10", 10), + ("Jack", 11), + ("Queen", 12), + ("King", 13), + ("Ace", 14), +] + +DEFAULT_RANKS_36 = [r for r in DEFAULT_RANKS_52 if r[1] >= 6] +DEFAULT_RANKS_24 = [r for r in DEFAULT_RANKS_52 if r[1] >= 9] + + +class Command(BaseCommand): + """Management command to generate fake Durak games. + + The command produces decks, deals hands, creates games and simulates a + sequence of turns with Move rows (attack/defend/pickup). For test user + creation it prefers to call the `generate_test_users` command (from the + `accounts` app) with marker group `Test_Users`. If that call fails the + command falls back to inline user creation to avoid blocking generation. + + Options: + --games: Number of games to generate (default 5) + --players: Players per game (default 4) + --moves: Approximate number of moves per game (default 20) + --card-count: Deck size (24, 36 or 52, default 36) + --seed: Optional random seed + --reset: Delete generated lobbies/games and fake users (prefix 'fake_user_') + """ + + help = "Generate sample Durak games with move history for UI & statistics testing." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: Argument parser provided by Django. + """ + parser.add_argument("--games", type=int, default=5, help="Number of games to generate (default: 5)") + parser.add_argument("--players", type=int, default=4, help="Players per game (2-8 recommended, default: 4)") + parser.add_argument("--moves", type=int, default=20, help="Approx number of moves per game (default: 20)") + parser.add_argument( + "--card-count", + type=int, + choices=[24, 36, 52], + default=36, + help="Deck size per lobby (24, 36, or 52). Default: 36", + ) + parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible runs") + parser.add_argument( + "--reset", + action="store_true", + default=False, + help="Delete generated lobbies/games (prefix 'Fake Lobby ') and fake users (prefix 'fake_user_')", + ) + + def handle(self, *args, **options): + """Main entry point. + + Args: + *args: Positional args (unused). + **options: Parsed CLI options. + """ + games = options["games"] + players = options["players"] + approx_moves = options["moves"] + card_count = options["card_count"] + seed = options["seed"] + do_reset = options["reset"] + + if seed is not None: + random.seed(seed) + + if do_reset: + self._reset_generated_lobbies_and_users() + return + + if not (2 <= players <= 8): + self.stdout.write(self.style.WARNING("players outside normal range (2-8). Continuing anyway.")) + + self.stdout.write( + f"Generating {games} game(s), {players} players each, ~{approx_moves} moves per game, {card_count}-card decks..." + ) + + with transaction.atomic(): + self._ensure_suits_and_ranks(card_count) + self._ensure_cards(card_count) + + # estimate users needed: owner + players per game, reuse users when possible + users = self._ensure_users(max_needed=games * (players + 1)) + + # cycle through the users so we can reuse them across games if fewer provided + user_iter = itertools.cycle(users) + created_games = [] + + for g_idx in range(games): + game = self._create_fake_game( + players=players, + approx_moves=approx_moves, + card_count=card_count, + user_iter=user_iter, + game_index=g_idx + 1, + ) + created_games.append(str(game.id)) + self.stdout.write(self.style.SUCCESS(f"Created Game {game.id} in Lobby '{game.lobby.name}'")) + + self.stdout.write(self.style.SUCCESS(f"Done. Created {len(created_games)} games: {created_games}")) + + # --------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------- + def _reset_generated_lobbies_and_users(self): + """Remove generated lobbies/games and fake users. + + Lobbies are identified by name prefix 'Fake Lobby '. Fake users are + identified by username prefix 'fake_user_'. Staff and superuser users + are excluded from user deletion if those fields exist. + """ + with transaction.atomic(): + lobby_qs = Lobby.objects.filter(name__startswith="Fake Lobby ") + deleted_count, details = lobby_qs.delete() + self.stdout.write( + self.style.WARNING(f"Reset requested: deleted {deleted_count} objects (details: {details}).")) + + try: + fake_user_qs = User.objects.filter(username__startswith="fake_user_").exclude(is_staff=True).exclude( + is_superuser=True) + except Exception: + fake_user_qs = User.objects.filter(username__startswith="fake_user_") + + fake_user_count = fake_user_qs.count() + if fake_user_count: + u_deleted_count, u_deleted_details = fake_user_qs.delete() + self.stdout.write( + self.style.WARNING(f"Deleted {fake_user_count} fake user(s) (deleted objects: {u_deleted_count}).")) + else: + self.stdout.write(self.style.NOTICE("No fake users found to delete.")) + + def _ensure_suits_and_ranks(self, card_count: int): + """Ensure CardSuit and CardRank rows exist. + + Args: + card_count: Number of cards in deck (24/36/52) to decide which ranks to create. + """ + suits = {"Hearts": "red", "Diamonds": "red", "Clubs": "black", "Spades": "black"} + for name, color in suits.items(): + CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + + if card_count == 52: + ranks = DEFAULT_RANKS_52 + elif card_count == 36: + ranks = DEFAULT_RANKS_36 + else: + ranks = DEFAULT_RANKS_24 + + existing_values = set(CardRank.objects.values_list("value", flat=True)) + for name, val in ranks: + if val not in existing_values: + CardRank.objects.create(name=name, value=val) + + def _ensure_cards(self, card_count: int): + """Ensure Card objects exist for the requested deck size. + + Args: + card_count: Deck size (24/36/52). + """ + suits = list(CardSuit.objects.all()) + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + created = 0 + for suit in suits: + for rank in ranks: + _, created_flag = Card.objects.get_or_create(suit=suit, rank=rank) + if created_flag: + created += 1 + if created: + self.stdout.write(self.style.NOTICE(f"Created {created} Card objects.")) + + def _ensure_users(self, max_needed: int) -> List[User]: + """Ensure at least `max_needed` users exist. + + The method prefers to call the `generate_test_users` management command + (from the accounts app). It requests creation with prefix `fake_user_` and + marker group `Test_Users`. If the call fails, it will fall back to inline + creation to ensure generation continues. + + Args: + max_needed: Minimum number of users required. + + Returns: + List of User instances (length >= max_needed). + """ + existing = list(User.objects.all().order_by("id")) + if len(existing) >= max_needed: + return existing[:max_needed] + + needed = max_needed - len(existing) + + prefix = "fake_user_" + existing_fake = list(User.objects.filter(username__startswith=prefix)) + max_index = 0 + for u in existing_fake: + m = re.search(rf'^{re.escape(prefix)}(\d+)$', u.username) + if m: + try: + idx = int(m.group(1)) + if idx > max_index: + max_index = idx + except Exception: + pass + start = max_index + 1 + + try: + # Ask the accounts app's generate_test_users command to create the missing users + call_command( + "generate_test_users", + "--count", + str(needed), + "--prefix", + prefix, + "--start", + str(start), + "--marker-group", + "Test_Users", + ) + self.stdout.write(self.style.NOTICE( + f"Requested {needed} test user(s) via generate_test_users (prefix={prefix}, start={start}).")) + except Exception as exc: + # Fallback inline creation + self.stdout.write( + self.style.WARNING(f"Calling generate_test_users failed: {exc}. Falling back to inline creation.")) + created_users = [] + for i in range(needed): + username = f"{prefix}{start + i}" + try: + user = User.objects.create_user(username=username, password="testpass") + except Exception: + # Try create without password method if custom user model differs + user = User.objects.create(username=username) + created_users.append(user) + self.stdout.write(self.style.NOTICE(f"Fallback created {len(created_users)} users.")) + all_users = existing + created_users + return all_users[:max_needed] + + all_users = list(User.objects.all().order_by("id")) + return all_users[:max_needed] + + def _draw_from_deck_list(self, deck_cards: List[Card]) -> Optional[Card]: + """Pop a card from a list representing the deck. + + Args: + deck_cards: Mutable list acting as the deck (front = top). + + Returns: + Card or None if deck is empty. + """ + if not deck_cards: + return None + return deck_cards.pop(0) + + def _create_fake_game(self, players: int, approx_moves: int, card_count: int, user_iter, game_index: int) -> Game: + """Create a single fake lobby/game and simulate play. + + Args: + players: Number of players in the created game. + approx_moves: Approximate number of attack/defend/pickup moves to simulate. + card_count: Deck size (24/36/52). + user_iter: Iterator that yields User objects (owner + players). + game_index: One-based index used for naming. + + Returns: + The created Game instance. + """ + owner_user = next(user_iter) + lobby = Lobby.objects.create(owner=owner_user, name=f"Fake Lobby {uuid.uuid4().hex[:6]}", is_private=False, + status="playing") + + LobbySettings.objects.create( + lobby=lobby, + max_players=players, + card_count=card_count, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + ) + + # Build list of Card objects representing the deck + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + suits = list(CardSuit.objects.all()) + deck_cards: List[Card] = [] + for suit in suits: + for rank in ranks: + try: + deck_cards.append(Card.objects.get(suit=suit, rank=rank)) + except Card.DoesNotExist: + continue + + random.shuffle(deck_cards) + trump_card_obj = deck_cards[-1] if deck_cards else None + if trump_card_obj is None: + raise RuntimeError("No Card objects available to create a game.") + + game = Game.objects.create(lobby=lobby, trump_card=trump_card_obj, status="in_progress") + + # Persist deck into GameDeck model + for pos, card in enumerate(deck_cards, start=1): + GameDeck.objects.create(game=game, card=card, position=pos) + + # Create LobbyPlayer and GamePlayer entries + game_players: List[GamePlayer] = [] + for seat in range(1, players + 1): + user = next(user_iter) + LobbyPlayer.objects.create(lobby=lobby, user=user, status="playing") + gp = GamePlayer.objects.create(game=game, user=user, seat_position=seat, cards_remaining=0) + game_players.append(gp) + + # Helper to pop top GameDeck entry (DB-backed) + def pop_top_db_card(game_obj: Game) -> Optional[Card]: + top_qs = GameDeck.objects.filter(game=game_obj).order_by("position")[:1] + if not top_qs: + return None + gd = top_qs[0] + card = gd.card + gd.delete() + return card + + # Deal initial hands (6 cards typical) + initial_hand_size = 6 + for gp in game_players: + for idx in range(initial_hand_size): + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, order_in_hand=idx + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + # Simulate a sequence of turns and moves + current_attacker_index = 0 + turn_counter = 0 + discard_position = 1 + + def find_defense_card_for_player(defender_user, attack_card): + """Return a PlayerHand instance that can defend or None.""" + ph_qs = PlayerHand.objects.filter(game=game, player=defender_user) + for ph in ph_qs: + try: + if ph.card.can_beat(attack_card, game.get_trump_suit()): + return ph + except Exception: + return None + return None + + for _ in range(approx_moves): + turn_counter += 1 + attacker_gp = game_players[current_attacker_index % len(game_players)] + attacker_user = attacker_gp.user + turn = Turn.objects.create(game=game, player=attacker_user, turn_number=turn_counter) + + attacker_hand = list(PlayerHand.objects.filter(game=game, player=attacker_user).order_by("order_in_hand")) + if not attacker_hand: + current_attacker_index += 1 + continue + + attack_ph = attacker_hand[0] + attack_card = attack_ph.card + attack_ph.delete() + attacker_gp.cards_remaining = max(0, attacker_gp.cards_remaining - 1) + attacker_gp.save(update_fields=["cards_remaining"]) + + table_card = TableCard.objects.create(game=game, attack_card=attack_card) + Move.objects.create(turn=turn, table_card=table_card, action_type="attack", created_at=timezone.now()) + + defender_index = (current_attacker_index + 1) % len(game_players) + defender_gp = game_players[defender_index] + defender_user = defender_gp.user + + defense_ph = find_defense_card_for_player(defender_user, attack_card) + if defense_ph: + defense_card = defense_ph.card + table_card.defense_card = defense_card + table_card.save(update_fields=["defense_card"]) + defense_ph.delete() + defender_gp.cards_remaining = max(0, defender_gp.cards_remaining - 1) + defender_gp.save(update_fields=["cards_remaining"]) + + Move.objects.create(turn=turn, table_card=table_card, action_type="defend", created_at=timezone.now()) + + DiscardPile.objects.create(game=game, card=attack_card, position=discard_position) + discard_position += 1 + DiscardPile.objects.create(game=game, card=defense_card, position=discard_position) + discard_position += 1 + else: + Move.objects.create(turn=turn, table_card=table_card, action_type="pickup", created_at=timezone.now()) + PlayerHand.objects.create(game=game, player=defender_user, card=attack_card, + order_in_hand=defender_gp.cards_remaining + 1) + defender_gp.cards_remaining += 1 + defender_gp.save(update_fields=["cards_remaining"]) + + # Refill hands up to 6 cards + for gp in game_players: + while gp.cards_remaining < 6: + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, + order_in_hand=gp.cards_remaining + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + current_attacker_index = (current_attacker_index + 1) % len(game_players) + + # Optionally finish the game and pick a loser + if random.random() < 0.6: + loser_gp = random.choice(game_players) + game.loser = loser_gp.user + game.status = "finished" + game.finished_at = timezone.now() + game.save(update_fields=["loser", "status", "finished_at"]) + + return game diff --git a/game/management/commands/reset_games.py b/game/management/commands/reset_games.py new file mode 100644 index 0000000..d9c5ec6 --- /dev/null +++ b/game/management/commands/reset_games.py @@ -0,0 +1,170 @@ +# game/management/commands/reset_games.py +from __future__ import annotations + +""" +Django management command to remove active/unfinished Game sessions and related data. + +This command is intended to clean up "in-progress" / partially-complete game data +from the database (for example after testing, during QA, or when resetting state). + +Behavior +-------- +- By default the command does a dry-run and prints how many Game objects would be affected + and shows their IDs and related Lobby names. +- To actually delete, pass --confirm. +- You can also limit deletion to specific game UUIDs via --game-ids (comma-separated). +- Deletion removes game-specific related objects: + GameDeck, PlayerHand, TableCard, DiscardPile, Move, Turn, GamePlayer + and finally the Game row itself. Deletion is performed inside a transaction. + +Notes +----- +- The command identifies "unfinished / active" games as those where either: + * status != 'finished' + OR + * finished_at IS NULL + (This is intentionally broad to catch any games that haven't been properly finished.) +- Lobbies are not deleted by default. If you want the lobbies removed as well, run the + separate `generate_fake_games --reset` (or tell me and I can add a --remove-lobbies flag). +""" + +from typing import List, Optional +import textwrap + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from game.models import ( + Game, GameDeck, PlayerHand, TableCard, DiscardPile, + Move, Turn, GamePlayer +) + + +class Command(BaseCommand): + """Remove active or unfinished games and related data.""" + + help = "Remove active/unfinished Game rows and their related game-specific data." + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--confirm", + action="store_true", + default=False, + help="Actually perform deletion. Without this flag the command will only show a dry-run report." + ) + parser.add_argument( + "--game-ids", + type=str, + default=None, + help=( + "Optional comma-separated list of Game UUIDs to restrict deletions to. " + "If omitted, all active/unfinished games are targeted." + ) + ) + parser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Print verbose information about each game that will be (or was) deleted." + ) + + def handle(self, *args, **options): + """ + Entry point for the management command. + + Steps: + 1. Construct a queryset of games considered 'active' or 'unfinished'. + 2. If --game-ids provided, restrict to those UUIDs. + 3. Report a dry-run summary unless --confirm is present. + 4. If --confirm, delete related objects and the Game rows within a transaction. + """ + confirm: bool = options["confirm"] + game_ids_raw: Optional[str] = options["game_ids"] + verbose: bool = options["verbose"] + + # Identify unfinished/active games: + # - status != 'finished' OR finished_at is NULL + queryset = Game.objects.filter(Q(finished_at__isnull=True) | ~Q(status="finished")) + + if game_ids_raw: + # parse comma-separated uuids and filter + ids = [s.strip() for s in game_ids_raw.split(",") if s.strip()] + if not ids: + self.stdout.write(self.style.ERROR("No valid game IDs parsed from --game-ids. Aborting.")) + return + queryset = queryset.filter(id__in=ids) + + total = queryset.count() + if total == 0: + self.stdout.write(self.style.NOTICE("No active or unfinished games found. Nothing to do.")) + return + + # Dry-run info + self.stdout.write(self.style.WARNING( + textwrap.dedent( + f""" + Found {total} active/unfinished game(s) that match the criteria. + To actually delete these games and their related data, re-run with --confirm. + """ + ).strip() + )) + + # show brief list (and verbose details when requested) + games_list = list(queryset.values("id", "lobby__name", "status", "finished_at")[:200]) + # show up to 200 items to avoid spamming console for massive deletions + for g in games_list: + self.stdout.write(f"- Game {g['id']} lobby='{g['lobby__name']}' status='{g['status']}' finished_at={g['finished_at']}") + + if total > len(games_list): + self.stdout.write(self.style.NOTICE(f"... (only first {len(games_list)} shown)")) + + if not confirm: + self.stdout.write(self.style.NOTICE("Dry run complete. No rows were deleted. Use --confirm to proceed.")) + return + + # Perform deletion inside a single transaction for safety + deleted_games = [] + with transaction.atomic(): + # Iterate games to ensure we delete related rows in safe order and can report progress + for game in queryset.select_related("lobby").all(): + if verbose: + self.stdout.write(f"Deleting related objects for game {game.id} (lobby='{getattr(game.lobby, 'name', None)}')...") + + # Delete moves (which reference turns) first + moves_deleted = Move.objects.filter(turn__game=game).delete() + if verbose: + self.stdout.write(f" - Moves deleted: {moves_deleted[0]}") + + # Delete turns + turns_deleted = Turn.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - Turns deleted: {turns_deleted[0]}") + + # Delete table cards and discard piles + tablecards_deleted = TableCard.objects.filter(game=game).delete() + discards_deleted = DiscardPile.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - TableCard deleted: {tablecards_deleted[0]}, DiscardPile deleted: {discards_deleted[0]}") + + # Delete player hands and deck entries + ph_deleted = PlayerHand.objects.filter(game=game).delete() + deck_deleted = GameDeck.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - PlayerHand deleted: {ph_deleted[0]}, GameDeck deleted: {deck_deleted[0]}") + + # Delete game player rows + gp_deleted = GamePlayer.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - GamePlayer deleted: {gp_deleted[0]}") + + # Finally delete the game row itself + game_id = str(game.id) + game.delete() + deleted_games.append(game_id) + self.stdout.write(self.style.SUCCESS(f"Deleted Game {game_id} and related objects.")) + + # finished + self.stdout.write(self.style.SUCCESS(f"Deletion complete. Removed {len(deleted_games)} game(s): {deleted_games}")) From 6a6b0bdd501a012306975fe6d8a97b9079720dd3 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:10:02 +0300 Subject: [PATCH 28/38] Added generate_test_users command. --- .../commands/generate_test_users.py | 226 ++++++++++++++++++ 1 file changed, 226 insertions(+) create mode 100644 accounts/management/commands/generate_test_users.py diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py new file mode 100644 index 0000000..0778399 --- /dev/null +++ b/accounts/management/commands/generate_test_users.py @@ -0,0 +1,226 @@ +""" +generate_test_users management command. + +Creates one or more test users for development/testing. + +Example: + # create 5 regular test users with default password + python manage.py generate_test_users --count 5 --prefix testuser + + # create 3 staff users with a custom email domain and password + python manage.py generate_test_users -c 3 --prefix staff_ --email-domain example.org --password secret123 --staff + + # create 1 superuser + python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass + +Google style docstrings are used in the class and methods. +""" + +from django.core.management.base import BaseCommand +from django.contrib.auth import get_user_model +import random +import string +from typing import List + +User = get_user_model() + + +def _random_suffix(length: int = 4) -> str: + """Return a short random alphanumeric suffix. + + Args: + length: Length of the suffix. + + Returns: + A random string composed of lowercase letters and digits. + """ + chars = string.ascii_lowercase + string.digits + return ''.join(random.choice(chars) for _ in range(length)) + + +class Command(BaseCommand): + """Django command to generate test users. + + The command creates users using `User.objects.create_user()` (or + `create_superuser()` when the `--superuser` flag is used). It will skip + usernames that already exist unless `--force` is provided, in which case + a short random suffix is appended to the username. + + Methods + ------- + add_arguments(parser): + Add command-line arguments. + handle(*args, **options): + Main entry point that creates users according to parsed options. + """ + + help = "Generate test users (regular, staff, or superuser) for development." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: The argparse parser to configure. + """ + parser.add_argument( + '--count', '-c', + type=int, + default=1, + help='Number of users to create (default: 1)' + ) + parser.add_argument( + '--prefix', '-p', + type=str, + default='testuser', + help='Prefix for usernames (default: "testuser")' + ) + parser.add_argument( + '--start', + type=int, + default=1, + help='Starting index appended to username (default: 1)' + ) + parser.add_argument( + '--email-domain', + type=str, + default='example.com', + help='Email domain to use for generated users (default: example.com)' + ) + parser.add_argument( + '--password', + type=str, + default='test_password', + help='Password to set for all created users (default: "test_password")' + ) + parser.add_argument( + '--staff', + action='store_true', + help='Mark created users as staff (is_staff=True)' + ) + parser.add_argument( + '--superuser', + action='store_true', + help='Create superuser(s) (uses create_superuser)' + ) + parser.add_argument( + '--inactive', + action='store_true', + help='Create users with is_active=False' + ) + parser.add_argument( + '--force', + action='store_true', + help='If username exists, append random suffix and create anyway' + ) + parser.add_argument( + '--dry-run', + action='store_true', + help='Print what would be created without saving to the database' + ) + + def _make_username(self, prefix: str, idx: int) -> str: + """Construct a username from prefix and index. + + Args: + prefix: Username prefix. + idx: Index to append. + + Returns: + The composed username (e.g. "prefix1"). + """ + return f"{prefix}{idx}" + + def _email_for_username(self, username: str, domain: str) -> str: + """Construct an email address for a username. + + Args: + username: The username to use before the @. + domain: The domain to use after the @. + + Returns: + A complete email address string. + """ + return f"{username}@{domain}" + + def handle(self, *args, **options): + """Create the requested number of test users. + + Args: + *args: positional args (unused). + **options: Parsed command-line options. + + Returns: + None + """ + count: int = options['count'] + prefix: str = options['prefix'] + start: int = options['start'] + email_domain: str = options['email_domain'] + password: str = options['password'] + make_staff: bool = options['staff'] + make_superuser: bool = options['superuser'] + inactive: bool = options['inactive'] + force: bool = options['force'] + dry_run: bool = options['dry_run'] + + created: List[User] = [] + + # loop to create the requested number of users + for i in range(start, start + count): + username = self._make_username(prefix, i) + email = self._email_for_username(username, email_domain) + + # If the username already exists and --force is not used, skip it. + if User.objects.filter(username=username).exists(): + if not force: + self.stdout.write(self.style.WARNING( + f"Skipping existing username: {username}" + )) + continue + + # If force mode, append a short random suffix to make it unique. + username = f"{username}_{_random_suffix()}" + email = self._email_for_username(username, email_domain) + self.stdout.write(self.style.NOTICE( + f"Username existed; using fallback username: {username}" + )) + + # Dry-run prints and does not save to DB. + if dry_run: + self.stdout.write(f"[DRY RUN] Would create: username='{username}', email='{email}', " + f"is_staff={make_staff}, is_superuser={make_superuser}, is_active={not inactive}") + continue + + # Create a superuser if requested (this calls create_superuser which + # typically sets is_staff/is_superuser automatically). + if make_superuser: + # create_superuser signature: (username, email=None, password=None, **extra_fields) + user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] + # Ensure flags align with requested options (some custom user models + # might require setting them explicitly). + user.is_staff = True + user.is_superuser = True + else: + # Regular user creation (hashes the password) + user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] + user.is_staff = bool(make_staff) + user.is_superuser = False + + # Set active state based on --inactive + user.is_active = not bool(inactive) + + # Save changes (if any) and collect result. + user.save() + created.append(user) + + # Informational output for each created user. + self.stdout.write(self.style.SUCCESS( + f"Created user: username='{username}' email='{email}' " + f"{'(staff)' if user.is_staff else ''} {'(superuser)' if user.is_superuser else ''}" + )) + + # Summary output + if dry_run: + self.stdout.write(self.style.WARNING("Dry run complete. No users were created.")) + else: + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) From 2aea5362a32ec63ff3b690e43cf2cfac2462fe7e Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 25 Oct 2025 23:13:07 +0300 Subject: [PATCH 29/38] Removed unnecessary comment line. --- accounts/management/commands/generate_test_users.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 0778399..6fe0a9f 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -12,8 +12,6 @@ # create 1 superuser python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass - -Google style docstrings are used in the class and methods. """ from django.core.management.base import BaseCommand From 6311533c13f1a5ded20a39c7b2b3b3166ecb1b92 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sun, 26 Oct 2025 15:14:12 +0300 Subject: [PATCH 30/38] Added __init__ in every managment/commands folder so pyhton whould understand what this is a commands, Added export_db, import_db commands. --- accounts/management/__init__.py | 0 accounts/management/commands/__init__.py | 0 chat/management/__init__.py | 0 chat/management/commands/__init__.py | 0 game/management/__init__.py | 0 game/management/commands/__init__.py | 0 game/management/commands/export_db.py | 252 +++++++++++++++++++++++ game/management/commands/import_db.py | 213 +++++++++++++++++++ 8 files changed, 465 insertions(+) create mode 100644 accounts/management/__init__.py create mode 100644 accounts/management/commands/__init__.py create mode 100644 chat/management/__init__.py create mode 100644 chat/management/commands/__init__.py create mode 100644 game/management/__init__.py create mode 100644 game/management/commands/__init__.py create mode 100644 game/management/commands/export_db.py create mode 100644 game/management/commands/import_db.py diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/__init__.py b/chat/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/management/commands/__init__.py b/chat/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/__init__.py b/game/management/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py new file mode 100644 index 0000000..7686253 --- /dev/null +++ b/game/management/commands/export_db.py @@ -0,0 +1,252 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.apps import apps +from django.conf import settings +from django.core import serializers + + +EXCLUDE_MODEL_NAMES = { + "ContentType", + "Session", +} + + +def resolve_output_path(path: str) -> str: + """Resolve a possibly-relative output path against BASE_DIR. + + Args: + path: Output file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + return path + + +class Command(BaseCommand): + """Export database rows to a JSON file (Django serialization) with optional apps filter. + + This command streams model instances to a JSON array in chunks (to avoid + building one huge list in memory). Use ``--apps`` to limit exported objects + to models that belong to a set of app labels (comma-separated). Use + ``--indent N`` to pretty-print multi-line JSON. + + Example usage: + # Default: writes db_backups/backup.json under BASE_DIR + python manage.py export_db + + # Pretty-printed, only export 'game' and 'auth' apps + python manage.py export_db --apps game,auth --indent 2 + + # Gzipped output inside project + python manage.py export_db -o db_backups/backup.json.gz --indent 2 + """ + + help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "--output", + "-o", + help=( + "Output file path (relative paths are created inside BASE_DIR). " + "Use '-' for stdout. Use .gz to gzip." + ), + default="db_backups/backup.json", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to export (e.g. 'auth,game'). If omitted exports all apps.", + default=None, + ) + parser.add_argument( + "--exclude", + help="Comma-separated model names to exclude (ModelName or app_label.ModelName).", + default="", + ) + parser.add_argument( + "--indent", + type=int, + default=None, + help="JSON indent level (pass an integer). If omitted output is compact (single line).", + ) + parser.add_argument( + "--natural-foreign", + action="store_true", + help="Use natural foreign keys when serializing (if supported by models).", + ) + parser.add_argument( + "--natural-primary", + action="store_true", + help="Use natural primary keys when serializing (if supported).", + ) + parser.add_argument( + "--chunk-size", + type=int, + default=1000, + help="Number of objects to serialize per chunk (memory / performance tuning).", + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Collect models to export (apply --apps and --exclude filters). + 2. Stream objects per-model and per-chunk, serializing each chunk and + writing the inner JSON to the output file/stream. + 3. Produce pretty JSON when --indent is provided. + """ + output = options["output"] + apps_arg = options["apps"] + exclude_arg = options["exclude"] + indent = options["indent"] + use_nat_foreign = options["natural_foreign"] + use_nat_primary = options["natural_primary"] + chunk_size = options["chunk_size"] + + apps_filter = self._parse_apps_arg(apps_arg) + exclude_set = set(x.strip() for x in exclude_arg.split(",") if x.strip()) + + # Collect models, applying app & exclude filters. + all_models = list(apps.get_models()) + models_to_export: List[type] = [] + for m in all_models: + full_name = f"{m._meta.app_label}.{m.__name__}" + if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: + # skip common internal models or anything explicitly excluded + continue + if apps_filter is not None and m._meta.app_label not in apps_filter: + # skip models that are not in the requested apps + continue + models_to_export.append(m) + + if not models_to_export: + raise CommandError("No models found to export (check --apps and --exclude).") + + # Determine output destination + write_to_stdout = (output == "-") + if not write_to_stdout: + output = resolve_output_path(output) + + # Open output (gz or plain file) or use stdout + if write_to_stdout: + write_chunk = lambda s: self.stdout.write(s) + close_fh = lambda: None + else: + if output.endswith(".gz"): + fh = gzip.open(output, "wt", encoding="utf-8") + else: + fh = open(output, "w", encoding="utf-8") + write_chunk = fh.write + close_fh = fh.close + + total_objects = 0 + first_piece = True + + try: + # Prepare array formatting depending on pretty vs compact + if indent is not None: + write_chunk("[\n") + separator = ",\n" + closing = "\n]\n" + else: + write_chunk("[") + separator = "," + closing = "]" + + # Stream per model to limit memory usage + for model in models_to_export: + qs = model._default_manager.all().iterator() + chunk: List[object] = [] + for obj in qs: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + # Extract the JSON inner array content (strip leading/trailing [ ]) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + # Trim surrounding newlines for nicer concatenation + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] + + # Flush any remaining objects for this model + if chunk: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + + # Close JSON array + write_chunk(closing) + finally: + close_fh() + + self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output}")) diff --git a/game/management/commands/import_db.py b/game/management/commands/import_db.py new file mode 100644 index 0000000..2b5d83f --- /dev/null +++ b/game/management/commands/import_db.py @@ -0,0 +1,213 @@ +import os +import gzip +from typing import Optional, Set, List +from django.core.management.base import BaseCommand, CommandError +from django.core import serializers +from django.db import transaction, IntegrityError, connection +from django.core.management import call_command +from django.conf import settings +from django.apps import apps +from django.core.management.color import no_style + + +def resolve_input_path(path: str) -> str: + """Resolve a possibly-relative input path against BASE_DIR. + + Args: + path: Input file path (absolute or relative). + + Returns: + Absolute path inside BASE_DIR if a relative path was provided. + """ + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + return path + + +class Command(BaseCommand): + """Import JSON exported by export_db (Django serialization) with optional app filter. + + The command accepts a Django-serialized JSON array (optionally gzipped) and + deserializes objects into the database. Use `--apps` to restrict import to + objects belonging to a set of app labels (comma-separated). When PostgreSQL + is detected (or `--reset-sequences` is passed) the command will attempt to + reset DB sequences — by default for all models, or only for the selected apps + when `--apps` is used. + + Example usages: + # Import everything + python manage.py import_db db_backups/backup.json + + # Import gzipped file and continue past errors + python manage.py import_db db_backups/backup.json.gz --ignore-errors + + # Import only objects for 'game' and 'auth' apps + python manage.py import_db db_backups/backup.json --apps game,auth + + # Clear DB first (dangerous) + python manage.py import_db db_backups/backup.json --clear + """ + + help = "Import JSON exported by export_db (Django serialization). Supports --apps filter." + + def add_arguments(self, parser): + """Define command-line arguments.""" + parser.add_argument( + "input", + help="Input JSON file path (relative paths searched in BASE_DIR). Use '-' for stdin. Supports .gz.", + ) + parser.add_argument( + "--clear", + action="store_true", + help="Clear the database via `flush --noinput` before importing. USE WITH CARE.", + ) + parser.add_argument( + "--ignore-errors", + action="store_true", + help="Try to continue past individual object errors (logs them).", + ) + parser.add_argument( + "--reset-sequences", + action="store_true", + help="Attempt to reset DB sequences after import (Postgres only). Default: on for Postgres.", + ) + parser.add_argument( + "--apps", + help="Comma-separated app labels to import (e.g. 'auth,game'). If omitted imports all apps.", + default=None, + ) + + def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ + if not apps_arg: + return None + # strip whitespace and ignore empty pieces + return {p.strip() for p in apps_arg.split(",") if p.strip()} + + def handle(self, *args, **options): + """Main command entry point. + + Steps: + 1. Resolve input path (or read stdin). + 2. Optionally flush DB (--clear). + 3. Read and deserialize JSON (supports .gz). + 4. Iterate deserialized objects, optionally filtering by --apps, saving each. + 5. Optionally reset sequences for Postgres (either all models or filtered models). + """ + input_path = options["input"] + do_clear = options["clear"] + ignore_errors = options["ignore_errors"] + reset_sequences_flag = options["reset_sequences"] + apps_arg = options["apps"] + + apps_filter = self._parse_apps_arg(apps_arg) + + # Resolve path if not stdin + read_from_stdin = (input_path == "-") + if not read_from_stdin: + input_path = resolve_input_path(input_path) + if not os.path.exists(input_path): + raise CommandError(f"Input file not found: {input_path}") + + # Optionally clear the DB (flush clears all data) + if do_clear: + self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) + call_command("flush", "--noinput") + + # Read input file / stdin + if read_from_stdin: + raw = self.stdin.read() + else: + if input_path.endswith(".gz"): + with gzip.open(input_path, "rt", encoding="utf-8") as fh: + raw = fh.read() + else: + with open(input_path, "r", encoding="utf-8") as fh: + raw = fh.read() + + if not raw.strip(): + raise CommandError("Input file is empty.") + + # Create a generator of deserialized objects. This does not eagerly load into a list. + try: + deserialized_iter = serializers.deserialize("json", raw) + except Exception as e: + raise CommandError(f"Failed to deserialize input JSON: {e}") + + saved = 0 + skipped = 0 + errors: List[str] = [] + + # Save objects inside a single atomic transaction. If --ignore-errors, continue past failing objects. + with transaction.atomic(): + for dobj in deserialized_iter: + # Determine the app label of the object's model + try: + obj_app_label = dobj.object._meta.app_label + except Exception: + # Defensive: if object lacks expected attributes, skip it + skipped += 1 + continue + + # If --apps filter is provided, skip objects not in that set + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + try: + # dobj.save() persists the object and handles m2m relationships + dobj.save() + saved += 1 + except IntegrityError as e: + msg = f"IntegrityError saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + # Re-raise to abort the transaction + raise + except Exception as e: + msg = f"Error saving {dobj}: {e}" + errors.append(msg) + self.stderr.write(self.style.ERROR(msg)) + if not ignore_errors: + raise + + # Decide whether to reset sequences: + engine = connection.settings_dict.get("ENGINE", "") + is_postgres = "postgresql" in engine or connection.vendor == "postgresql" + do_reset = reset_sequences_flag or is_postgres + + if do_reset and is_postgres: + # Build model list: either all models or only models in selected apps + if apps_filter is None: + models_to_reset = list(apps.get_models()) + else: + models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] + + style = no_style() + try: + sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) + with connection.cursor() as cursor: + for sql in sql_list: + if sql.strip(): + cursor.execute(sql) + self.stdout.write(self.style.SUCCESS("Postgres sequences reset successfully.")) + except Exception as e: + err_msg = f"Failed to reset sequences: {e}" + self.stderr.write(self.style.ERROR(err_msg)) + errors.append(err_msg) + + # Summary output + self.stdout.write(self.style.SUCCESS(f"Imported {saved} objects.")) + if skipped: + self.stdout.write(self.style.WARNING(f"Skipped {skipped} objects (outside --apps or invalid).")) + if errors: + self.stdout.write(self.style.WARNING(f"{len(errors)} errors occurred during import. See stderr for details.")) From 8be040cf6f55d8ae05fb4f07aa8ab7fbc3d89700 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Mon, 27 Oct 2025 18:53:30 +0300 Subject: [PATCH 31/38] Added backup* to .gitignore --- .gitignore | 1 + game/management/commands/export_db.py | 126 ++++++++++++++------------ 2 files changed, 69 insertions(+), 58 deletions(-) diff --git a/.gitignore b/.gitignore index 40dfc85..a97294d 100644 --- a/.gitignore +++ b/.gitignore @@ -111,6 +111,7 @@ celerybeat.pid *.sql.gz *.dump *.backup +backup* # ========================= # Docker diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py index 7686253..d64d86a 100644 --- a/game/management/commands/export_db.py +++ b/game/management/commands/export_db.py @@ -1,11 +1,12 @@ import os import gzip -from typing import Optional, Set, List +from typing import Optional, Set, List, Type from django.core.management.base import BaseCommand, CommandError from django.apps import apps from django.conf import settings from django.core import serializers - +from django.db import connection +from django.db.utils import ProgrammingError, OperationalError EXCLUDE_MODEL_NAMES = { "ContentType", @@ -34,20 +35,14 @@ def resolve_output_path(path: str) -> str: class Command(BaseCommand): """Export database rows to a JSON file (Django serialization) with optional apps filter. - This command streams model instances to a JSON array in chunks (to avoid + The command streams model instances to a JSON array in chunks (to avoid building one huge list in memory). Use ``--apps`` to limit exported objects to models that belong to a set of app labels (comma-separated). Use ``--indent N`` to pretty-print multi-line JSON. - Example usage: - # Default: writes db_backups/backup.json under BASE_DIR - python manage.py export_db - - # Pretty-printed, only export 'game' and 'auth' apps - python manage.py export_db --apps game,auth --indent 2 - - # Gzipped output inside project - python manage.py export_db -o db_backups/backup.json.gz --indent 2 + The command is resilient: if a model's table does not exist in the database + (e.g. after code changes but before running migrations), it logs a warning + and continues exporting other models. """ help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." @@ -97,14 +92,7 @@ def add_arguments(self, parser): ) def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: - """Parse the --apps argument into a set of app labels. - - Args: - apps_arg: Comma-separated app labels or None. - - Returns: - A set of app labels if apps_arg provided, otherwise None. - """ + """Parse the --apps argument into a set of app labels.""" if not apps_arg: return None return {p.strip() for p in apps_arg.split(",") if p.strip()} @@ -116,7 +104,7 @@ def handle(self, *args, **options): 1. Collect models to export (apply --apps and --exclude filters). 2. Stream objects per-model and per-chunk, serializing each chunk and writing the inner JSON to the output file/stream. - 3. Produce pretty JSON when --indent is provided. + 3. If a model has no table, log and continue. """ output = options["output"] apps_arg = options["apps"] @@ -131,14 +119,12 @@ def handle(self, *args, **options): # Collect models, applying app & exclude filters. all_models = list(apps.get_models()) - models_to_export: List[type] = [] + models_to_export: List[Type] = [] for m in all_models: full_name = f"{m._meta.app_label}.{m.__name__}" if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: - # skip common internal models or anything explicitly excluded continue if apps_filter is not None and m._meta.app_label not in apps_filter: - # skip models that are not in the requested apps continue models_to_export.append(m) @@ -178,41 +164,65 @@ def handle(self, *args, **options): # Stream per model to limit memory usage for model in models_to_export: - qs = model._default_manager.all().iterator() + # Attempt to iterate model objects; if table missing, log and continue. + try: + qs_iter = model._default_manager.all().iterator() + except (ProgrammingError, OperationalError) as e: + # Table might not exist — log and continue with next model. + self.stderr.write(self.style.WARNING( + f"Skipping model {model._meta.label}: DB error when creating queryset: {e}" + )) + # Close connection to reset any partially-open cursor + try: + connection.close() + except Exception: + pass + continue + chunk: List[object] = [] - for obj in qs: - chunk.append(obj) - if len(chunk) >= chunk_size: - serialized = serializers.serialize( - "json", - chunk, - indent=indent, - use_natural_foreign_keys=use_nat_foreign, - use_natural_primary_keys=use_nat_primary, - ) - # Extract the JSON inner array content (strip leading/trailing [ ]) - start = serialized.find('[') - end = serialized.rfind(']') - inner = serialized[start+1:end] - - if indent is not None: - # Trim surrounding newlines for nicer concatenation - inner = inner.lstrip('\n').rstrip('\n') - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - else: - inner = inner.strip() - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - - total_objects += len(chunk) - chunk = [] + try: + for obj in qs_iter: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] + except (ProgrammingError, OperationalError) as e: + # Something happened mid-iteration (e.g. table dropped). Log and reset connection. + self.stderr.write(self.style.WARNING( + f"Error iterating model {model._meta.label}: {e} — skipping remaining rows of this model." + )) + try: + connection.close() + except Exception: + pass + # continue to next model + continue # Flush any remaining objects for this model if chunk: From 737efef167c48a2ded65be9033154ad75b796db8 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sun, 26 Oct 2025 15:14:12 +0300 Subject: [PATCH 32/38] Added __init__ in every managment/commands folder so pyhton whould understand what this is a commands, Added export_db, import_db commands. --- game/management/commands/export_db.py | 125 ++++++++++++-------------- 1 file changed, 57 insertions(+), 68 deletions(-) diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py index d64d86a..0381415 100644 --- a/game/management/commands/export_db.py +++ b/game/management/commands/export_db.py @@ -1,12 +1,10 @@ import os import gzip -from typing import Optional, Set, List, Type +from typing import Optional, Set, List from django.core.management.base import BaseCommand, CommandError from django.apps import apps from django.conf import settings from django.core import serializers -from django.db import connection -from django.db.utils import ProgrammingError, OperationalError EXCLUDE_MODEL_NAMES = { "ContentType", @@ -35,14 +33,20 @@ def resolve_output_path(path: str) -> str: class Command(BaseCommand): """Export database rows to a JSON file (Django serialization) with optional apps filter. - The command streams model instances to a JSON array in chunks (to avoid + This command streams model instances to a JSON array in chunks (to avoid building one huge list in memory). Use ``--apps`` to limit exported objects to models that belong to a set of app labels (comma-separated). Use ``--indent N`` to pretty-print multi-line JSON. - The command is resilient: if a model's table does not exist in the database - (e.g. after code changes but before running migrations), it logs a warning - and continues exporting other models. + Example usage: + # Default: writes db_backups/backup.json under BASE_DIR + python manage.py export_db + + # Pretty-printed, only export 'game' and 'auth' apps + python manage.py export_db --apps game,auth --indent 2 + + # Gzipped output inside project + python manage.py export_db -o db_backups/backup.json.gz --indent 2 """ help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." @@ -92,7 +96,14 @@ def add_arguments(self, parser): ) def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: - """Parse the --apps argument into a set of app labels.""" + """Parse the --apps argument into a set of app labels. + + Args: + apps_arg: Comma-separated app labels or None. + + Returns: + A set of app labels if apps_arg provided, otherwise None. + """ if not apps_arg: return None return {p.strip() for p in apps_arg.split(",") if p.strip()} @@ -104,7 +115,7 @@ def handle(self, *args, **options): 1. Collect models to export (apply --apps and --exclude filters). 2. Stream objects per-model and per-chunk, serializing each chunk and writing the inner JSON to the output file/stream. - 3. If a model has no table, log and continue. + 3. Produce pretty JSON when --indent is provided. """ output = options["output"] apps_arg = options["apps"] @@ -119,12 +130,14 @@ def handle(self, *args, **options): # Collect models, applying app & exclude filters. all_models = list(apps.get_models()) - models_to_export: List[Type] = [] + models_to_export: List[type] = [] for m in all_models: full_name = f"{m._meta.app_label}.{m.__name__}" if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: + # skip common internal models or anything explicitly excluded continue if apps_filter is not None and m._meta.app_label not in apps_filter: + # skip models that are not in the requested apps continue models_to_export.append(m) @@ -164,65 +177,41 @@ def handle(self, *args, **options): # Stream per model to limit memory usage for model in models_to_export: - # Attempt to iterate model objects; if table missing, log and continue. - try: - qs_iter = model._default_manager.all().iterator() - except (ProgrammingError, OperationalError) as e: - # Table might not exist — log and continue with next model. - self.stderr.write(self.style.WARNING( - f"Skipping model {model._meta.label}: DB error when creating queryset: {e}" - )) - # Close connection to reset any partially-open cursor - try: - connection.close() - except Exception: - pass - continue - + qs = model._default_manager.all().iterator() chunk: List[object] = [] - try: - for obj in qs_iter: - chunk.append(obj) - if len(chunk) >= chunk_size: - serialized = serializers.serialize( - "json", - chunk, - indent=indent, - use_natural_foreign_keys=use_nat_foreign, - use_natural_primary_keys=use_nat_primary, - ) - start = serialized.find('[') - end = serialized.rfind(']') - inner = serialized[start+1:end] - - if indent is not None: - inner = inner.lstrip('\n').rstrip('\n') - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - else: - inner = inner.strip() - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - - total_objects += len(chunk) - chunk = [] - except (ProgrammingError, OperationalError) as e: - # Something happened mid-iteration (e.g. table dropped). Log and reset connection. - self.stderr.write(self.style.WARNING( - f"Error iterating model {model._meta.label}: {e} — skipping remaining rows of this model." - )) - try: - connection.close() - except Exception: - pass - # continue to next model - continue + for obj in qs: + chunk.append(obj) + if len(chunk) >= chunk_size: + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + # Extract the JSON inner array content (strip leading/trailing [ ]) + start = serialized.find('[') + end = serialized.rfind(']') + inner = serialized[start+1:end] + + if indent is not None: + # Trim surrounding newlines for nicer concatenation + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + first_piece = False + + total_objects += len(chunk) + chunk = [] # Flush any remaining objects for this model if chunk: From 246a036811cb3129290a8695b15e9c474e75a7ff Mon Sep 17 00:00:00 2001 From: Surmachov Date: Thu, 30 Oct 2025 23:45:22 +0300 Subject: [PATCH 33/38] Created init_game_data managment command --- game/management/commands/init_game_data.py | 178 +++++++++++++++++++++ 1 file changed, 178 insertions(+) create mode 100644 game/management/commands/init_game_data.py diff --git a/game/management/commands/init_game_data.py b/game/management/commands/init_game_data.py new file mode 100644 index 0000000..637259d --- /dev/null +++ b/game/management/commands/init_game_data.py @@ -0,0 +1,178 @@ +""" +Initialize default card suits, ranks and create Card entries. + +This management command will create standard card suits and ranks and then +create Card objects for each suit × rank combination for a chosen deck size. + +Usage: + python manage.py init_game_data + python manage.py init_game_data --deck-size 36 + python manage.py init_game_data --reset + +The command is idempotent by default (it uses get_or_create and updates mismatched +names/colors). Using --reset will delete existing Card, CardRank and CardSuit +records before recreating them. + +Module contents: + Command -- Django management command class implementing the behavior. +""" + +from django.core.management.base import BaseCommand +from django.db import transaction +from typing import List, Tuple + +from game.models import CardSuit, CardRank, Card + + +class Command(BaseCommand): + """ + Django management command to initialize card suits, ranks and cards. + + The command supports 24-, 36- and 52-card decks and an optional reset flag + which deletes existing Card, CardRank and CardSuit records before creating + new ones. + + Attributes: + help (str): Short description displayed by `manage.py help`. + """ + + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." + + def add_arguments(self, parser): + """ + Add command-line arguments for the management command. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + Recognized flags: + --deck-size {24,36,52}: Which deck to create (default 52). + --reset: If present, deletes existing Card/Rank/Suit rows before creating. + """ + parser.add_argument( + "--deck-size", + type=int, + choices=[24, 36, 52], + default=52, + help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.", + ) + parser.add_argument( + "--reset", + action="store_true", + help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", + ) + + def handle(self, *args, **options): + """ + Main entry point for the management command. + + This method creates suits and ranks (using get_or_create so it is safe to + run repeatedly), then creates Card objects for each combination of suit + and rank. If --reset is passed, existing Card, CardRank and CardSuit + records will be deleted first. + + Args: + *args: Positional arguments (unused). + **options: Command options dictionary with keys: + deck_size (int): Deck size to create (24, 36, 52). + reset (bool): Whether to delete existing entries first. + + Raises: + ValueError: If an unsupported deck size is provided (shouldn't happen + because argparse restricts choices). + """ + deck_size = options["deck_size"] + do_reset = options["reset"] + + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + + def ranks_for_deck(size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + ranks = ranks_for_deck(deck_size) + + with transaction.atomic(): + if do_reset: + self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...") + # Delete Cards first because of foreign key references to ranks & suits + Card.objects.all().delete() + CardRank.objects.all().delete() + CardSuit.objects.all().delete() + self.stdout.write("Existing card data deleted.") + + # Create or update suits + created_suits = [] + for name, color in suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + # Summary output + self.stdout.write(self.style.SUCCESS( + f"Deck initialization finished for deck_size={deck_size}." + )) + self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.") + self.stdout.write( + f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}") From d6133f3fe23b38c773856b37d213108c906228a2 Mon Sep 17 00:00:00 2001 From: Surmachov Date: Fri, 31 Oct 2025 22:41:51 +0300 Subject: [PATCH 34/38] Created generate_fake_games, reset_games and updated generate_test_users commands --- .../commands/generate_test_users.py | 356 ++++++++----- .../commands/generate_fake_games.py | 488 ++++++++++++++++++ game/management/commands/reset_games.py | 170 ++++++ 3 files changed, 876 insertions(+), 138 deletions(-) create mode 100644 game/management/commands/generate_fake_games.py create mode 100644 game/management/commands/reset_games.py diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 6fe0a9f..6e94154 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -1,224 +1,304 @@ """ -generate_test_users management command. +Create test users for development/testing and optionally delete all users +belonging to the marker group (default: Test_Users). -Creates one or more test users for development/testing. +This command has two modes: -Example: - # create 5 regular test users with default password - python manage.py generate_test_users --count 5 --prefix testuser +* Creation (default): create users with --count, --prefix, --password, etc. + Created users are added to the marker group so they can be deleted safely later. - # create 3 staff users with a custom email domain and password - python manage.py generate_test_users -c 3 --prefix staff_ --email-domain example.org --password secret123 --staff +* Deletion: pass --delete to delete all users who are members of the marker group. + Deletion excludes staff and superusers by default to avoid accidental removal. - # create 1 superuser - python manage.py generate_test_users --count 1 --prefix admin --superuser --password adminpass +Examples: + # Create 5 users: + python manage.py generate_test_users --count 5 --prefix dev_ --password secret + + # Dry-run create: + python manage.py generate_test_users --count 3 --prefix demo --dry-run + + # Delete all users in Test_Users group (interactive confirmation) + python manage.py generate_test_users --delete + + # Delete without prompt (careful!) + python manage.py generate_test_users --delete --noinput + + # Preview deletions without performing them + python manage.py generate_test_users --delete --dry-run """ +from __future__ import annotations -from django.core.management.base import BaseCommand -from django.contrib.auth import get_user_model import random import string -from typing import List +from typing import List, Optional + +from django.core.management.base import BaseCommand, CommandError +from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group +from django.db import transaction User = get_user_model() def _random_suffix(length: int = 4) -> str: - """Return a short random alphanumeric suffix. + """Generate a short random alphanumeric suffix. Args: - length: Length of the suffix. + length: Length of the suffix (default: 4). Returns: A random string composed of lowercase letters and digits. """ chars = string.ascii_lowercase + string.digits - return ''.join(random.choice(chars) for _ in range(length)) + return "".join(random.choice(chars) for _ in range(length)) class Command(BaseCommand): - """Django command to generate test users. - - The command creates users using `User.objects.create_user()` (or - `create_superuser()` when the `--superuser` flag is used). It will skip - usernames that already exist unless `--force` is provided, in which case - a short random suffix is appended to the username. - - Methods - ------- - add_arguments(parser): - Add command-line arguments. - handle(*args, **options): - Main entry point that creates users according to parsed options. + """Management command to create test users and delete marker-group users. + + Creation mode (default) creates test users and adds them to a marker group + (default group name: "Test_Users") so they can be deleted later. + + Deletion mode (pass --delete) deletes all users who are members of the + marker group. Staff and superusers are excluded from deletion by default. """ - help = "Generate test users (regular, staff, or superuser) for development." + help = "Create test users or delete all users in the marker group (use --delete)." def add_arguments(self, parser): - """Add command-line arguments. - - Args: - parser: The argparse parser to configure. - """ + """Define command-line arguments.""" + # Creation args parser.add_argument( - '--count', '-c', + "--count", + "-c", type=int, default=1, - help='Number of users to create (default: 1)' + help="Number of users to create (default: 1).", ) parser.add_argument( - '--prefix', '-p', + "--prefix", + "-p", type=str, - default='testuser', - help='Prefix for usernames (default: "testuser")' + default="testuser", + help='Prefix for usernames (default: "testuser").', ) parser.add_argument( - '--start', + "--start", type=int, default=1, - help='Starting index appended to username (default: 1)' + help="Starting index appended to username (default: 1).", ) parser.add_argument( - '--email-domain', + "--email-domain", type=str, - default='example.com', - help='Email domain to use for generated users (default: example.com)' + default="example.com", + help="Email domain for generated users (default: example.com).", ) parser.add_argument( - '--password', + "--password", type=str, - default='test_password', - help='Password to set for all created users (default: "test_password")' + default="test_password", + help='Password to set for created users (default: "test_password").', ) parser.add_argument( - '--staff', - action='store_true', - help='Mark created users as staff (is_staff=True)' + "--staff", + action="store_true", + help="Mark created users as staff (is_staff=True).", ) parser.add_argument( - '--superuser', - action='store_true', - help='Create superuser(s) (uses create_superuser)' + "--superuser", + action="store_true", + help="Create superuser(s).", ) parser.add_argument( - '--inactive', - action='store_true', - help='Create users with is_active=False' + "--inactive", + action="store_true", + help="Create users with is_active=False.", ) parser.add_argument( - '--force', - action='store_true', - help='If username exists, append random suffix and create anyway' + "--force", + action="store_true", + help="If username exists, append short random suffix and create anyway.", ) + + # Marker group (default Test_Users) parser.add_argument( - '--dry-run', - action='store_true', - help='Print what would be created without saving to the database' + "--marker-group", + type=str, + default="Test_Users", + help='Group name used to mark generated users (default: "Test_Users").', ) - def _make_username(self, prefix: str, idx: int) -> str: - """Construct a username from prefix and index. + # Deletion mode - simplified: one flag to delete all marker-group members + parser.add_argument( + "--delete", + action="store_true", + help="Delete ALL users who are members of the marker group (default: Test_Users).", + ) - Args: - prefix: Username prefix. - idx: Index to append. + # Shared safety args + parser.add_argument( + "--dry-run", + action="store_true", + help="Preview actions (no DB changes).", + ) + parser.add_argument( + "--noinput", + action="store_true", + help="Do not prompt for confirmation when deleting (use with care).", + ) - Returns: - The composed username (e.g. "prefix1"). - """ + def _make_username(self, prefix: str, idx: int) -> str: + """Construct username from prefix and index.""" return f"{prefix}{idx}" def _email_for_username(self, username: str, domain: str) -> str: - """Construct an email address for a username. - - Args: - username: The username to use before the @. - domain: The domain to use after the @. - - Returns: - A complete email address string. - """ + """Construct a simple email for a username.""" return f"{username}@{domain}" def handle(self, *args, **options): - """Create the requested number of test users. - - Args: - *args: positional args (unused). - **options: Parsed command-line options. - - Returns: - None - """ - count: int = options['count'] - prefix: str = options['prefix'] - start: int = options['start'] - email_domain: str = options['email_domain'] - password: str = options['password'] - make_staff: bool = options['staff'] - make_superuser: bool = options['superuser'] - inactive: bool = options['inactive'] - force: bool = options['force'] - dry_run: bool = options['dry_run'] + """Main entry: create users or delete marker-group users.""" + dry_run: bool = options.get("dry_run", False) + marker_group_name: str = options.get("marker_group") or "Test_Users" + + # Deletion mode: delete all users in marker group (excluding staff/superuser) + if options.get("delete"): + # Ensure the group exists + try: + group = Group.objects.get(name=marker_group_name) + except Group.DoesNotExist: + self.stdout.write(self.style.WARNING( + f"Marker group '{marker_group_name}' does not exist. Nothing to delete." + )) + return + + # Query users in the group + qs = User.objects.filter(groups__name=marker_group_name) + + # Exclude staff and superuser accounts for safety if model has those flags + if hasattr(User, "is_staff"): + qs = qs.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + qs = qs.exclude(is_superuser=True) + + total = qs.count() + if total == 0: + self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) + return + + # List matched users + self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) + for u in qs: + parts = [f"username='{getattr(u, 'username', '')}'"] + if getattr(u, "email", None): + parts.append(f"email='{u.email}'") + self.stdout.write(" - " + " ".join(parts)) + + if dry_run: + self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) + return + + # Confirm unless noinput + if not options.get("noinput"): + answer = input("Delete all listed users? This is irreversible. [y/N]: ") + if answer.lower() not in ("y", "yes"): + self.stdout.write(self.style.WARNING("Aborted by user.")) + return + + # Perform deletions + deleted = 0 + failed = [] + try: + with transaction.atomic(): + for u in qs: + try: + u.delete() # call delete() to respect signals/cascades + deleted += 1 + except Exception as exc: + failed.append((u, exc)) + # continue deleting others + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) + if failed: + self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) + for u, exc in failed: + self.stdout.write(f" - {getattr(u, 'username', '')}: {exc}") + except Exception as exc_outer: + raise CommandError(f"Deletion transaction failed: {exc_outer}") + + return # done + + # Creation mode: create users and add to marker group + count: int = int(options.get("count", 1)) + prefix: str = options.get("prefix") or "testuser" + start: int = int(options.get("start", 1)) + email_domain: str = options.get("email_domain") or "example.com" + password: str = options.get("password") or "test_password" + make_staff: bool = bool(options.get("staff")) + make_superuser: bool = bool(options.get("superuser")) + inactive: bool = bool(options.get("inactive")) + force: bool = bool(options.get("force")) created: List[User] = [] - # loop to create the requested number of users + # Ensure marker group exists (get_or_create is safe; use admin-created group if present) + group_obj: Optional[Group] = None + try: + group_obj, _ = Group.objects.get_or_create(name=marker_group_name) + except Exception: + group_obj = None + for i in range(start, start + count): username = self._make_username(prefix, i) email = self._email_for_username(username, email_domain) - # If the username already exists and --force is not used, skip it. + # Handle existing username if User.objects.filter(username=username).exists(): if not force: - self.stdout.write(self.style.WARNING( - f"Skipping existing username: {username}" - )) + self.stdout.write(self.style.WARNING(f"Skipping existing username: {username}")) continue - - # If force mode, append a short random suffix to make it unique. username = f"{username}_{_random_suffix()}" email = self._email_for_username(username, email_domain) - self.stdout.write(self.style.NOTICE( - f"Username existed; using fallback username: {username}" - )) + self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) - # Dry-run prints and does not save to DB. if dry_run: - self.stdout.write(f"[DRY RUN] Would create: username='{username}', email='{email}', " - f"is_staff={make_staff}, is_superuser={make_superuser}, is_active={not inactive}") + self.stdout.write( + f"[DRY RUN] Would create username='{username}', email='{email}', staff={make_staff}, superuser={make_superuser}, active={not inactive}" + ) continue - # Create a superuser if requested (this calls create_superuser which - # typically sets is_staff/is_superuser automatically). + # Create user if make_superuser: - # create_superuser signature: (username, email=None, password=None, **extra_fields) - user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] - # Ensure flags align with requested options (some custom user models - # might require setting them explicitly). - user.is_staff = True - user.is_superuser = True + user = User.objects.create_superuser(username=username, email=email, + password=password) # type: ignore[attr-defined] + try: + user.is_staff = True + user.is_superuser = True + except Exception: + pass else: - # Regular user creation (hashes the password) - user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] - user.is_staff = bool(make_staff) - user.is_superuser = False - - # Set active state based on --inactive - user.is_active = not bool(inactive) + user = User.objects.create_user(username=username, email=email, + password=password) # type: ignore[attr-defined] + try: + user.is_staff = bool(make_staff) + user.is_superuser = False + except Exception: + pass + + try: + user.is_active = not bool(inactive) + except Exception: + pass + + # Add to marker group if possible + try: + if group_obj is not None and hasattr(user, "groups"): + user.groups.add(group_obj) + except Exception: + self.stdout.write( + self.style.WARNING(f"Warning: couldn't add user '{username}' to group '{marker_group_name}'")) - # Save changes (if any) and collect result. user.save() created.append(user) + self.stdout.write(self.style.SUCCESS(f"Created user: username='{username}' email='{email}'")) - # Informational output for each created user. - self.stdout.write(self.style.SUCCESS( - f"Created user: username='{username}' email='{email}' " - f"{'(staff)' if user.is_staff else ''} {'(superuser)' if user.is_superuser else ''}" - )) - - # Summary output - if dry_run: - self.stdout.write(self.style.WARNING("Dry run complete. No users were created.")) - else: - self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) diff --git a/game/management/commands/generate_fake_games.py b/game/management/commands/generate_fake_games.py new file mode 100644 index 0000000..630db51 --- /dev/null +++ b/game/management/commands/generate_fake_games.py @@ -0,0 +1,488 @@ +""" +Generate synthetic Durak game data for UI & statistics testing. + +This command builds realistic game rows (lobbies, games, game players, hands, +game deck, turns, table cards, moves, and discard piles). It prefers to create +test users by invoking the `generate_test_users` management command (which you +said lives in the `accounts` app). The command will call that management +command with a marker group (`Test_Users`) so created accounts are easy and +safe to delete later. + +Usage: + python manage.py generate_fake_games --games 3 --players 4 --moves 30 --card-count 36 +""" +from __future__ import annotations + +import itertools +import random +import re +import uuid +from typing import List, Optional + +from django.core.management import call_command +from django.core.management.base import BaseCommand, CommandError +from django.db import transaction +from django.utils import timezone +from django.contrib.auth import get_user_model + +from game.models import ( + CardSuit, + CardRank, + Card, + Lobby, + LobbySettings, + LobbyPlayer, + Game, + GamePlayer, + GameDeck, + PlayerHand, + TableCard, + DiscardPile, + Turn, + Move, +) + +User = get_user_model() + +DEFAULT_RANKS_52 = [ + ("2", 2), + ("3", 3), + ("4", 4), + ("5", 5), + ("6", 6), + ("7", 7), + ("8", 8), + ("9", 9), + ("10", 10), + ("Jack", 11), + ("Queen", 12), + ("King", 13), + ("Ace", 14), +] + +DEFAULT_RANKS_36 = [r for r in DEFAULT_RANKS_52 if r[1] >= 6] +DEFAULT_RANKS_24 = [r for r in DEFAULT_RANKS_52 if r[1] >= 9] + + +class Command(BaseCommand): + """Management command to generate fake Durak games. + + The command produces decks, deals hands, creates games and simulates a + sequence of turns with Move rows (attack/defend/pickup). For test user + creation it prefers to call the `generate_test_users` command (from the + `accounts` app) with marker group `Test_Users`. If that call fails the + command falls back to inline user creation to avoid blocking generation. + + Options: + --games: Number of games to generate (default 5) + --players: Players per game (default 4) + --moves: Approximate number of moves per game (default 20) + --card-count: Deck size (24, 36 or 52, default 36) + --seed: Optional random seed + --reset: Delete generated lobbies/games and fake users (prefix 'fake_user_') + """ + + help = "Generate sample Durak games with move history for UI & statistics testing." + + def add_arguments(self, parser): + """Add command-line arguments. + + Args: + parser: Argument parser provided by Django. + """ + parser.add_argument("--games", type=int, default=5, help="Number of games to generate (default: 5)") + parser.add_argument("--players", type=int, default=4, help="Players per game (2-8 recommended, default: 4)") + parser.add_argument("--moves", type=int, default=20, help="Approx number of moves per game (default: 20)") + parser.add_argument( + "--card-count", + type=int, + choices=[24, 36, 52], + default=36, + help="Deck size per lobby (24, 36, or 52). Default: 36", + ) + parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible runs") + parser.add_argument( + "--reset", + action="store_true", + default=False, + help="Delete generated lobbies/games (prefix 'Fake Lobby ') and fake users (prefix 'fake_user_')", + ) + + def handle(self, *args, **options): + """Main entry point. + + Args: + *args: Positional args (unused). + **options: Parsed CLI options. + """ + games = options["games"] + players = options["players"] + approx_moves = options["moves"] + card_count = options["card_count"] + seed = options["seed"] + do_reset = options["reset"] + + if seed is not None: + random.seed(seed) + + if do_reset: + self._reset_generated_lobbies_and_users() + return + + if not (2 <= players <= 8): + self.stdout.write(self.style.WARNING("players outside normal range (2-8). Continuing anyway.")) + + self.stdout.write( + f"Generating {games} game(s), {players} players each, ~{approx_moves} moves per game, {card_count}-card decks..." + ) + + with transaction.atomic(): + self._ensure_suits_and_ranks(card_count) + self._ensure_cards(card_count) + + # estimate users needed: owner + players per game, reuse users when possible + users = self._ensure_users(max_needed=games * (players + 1)) + + # cycle through the users so we can reuse them across games if fewer provided + user_iter = itertools.cycle(users) + created_games = [] + + for g_idx in range(games): + game = self._create_fake_game( + players=players, + approx_moves=approx_moves, + card_count=card_count, + user_iter=user_iter, + game_index=g_idx + 1, + ) + created_games.append(str(game.id)) + self.stdout.write(self.style.SUCCESS(f"Created Game {game.id} in Lobby '{game.lobby.name}'")) + + self.stdout.write(self.style.SUCCESS(f"Done. Created {len(created_games)} games: {created_games}")) + + # --------------------------------------------------------------------- + # Helpers + # --------------------------------------------------------------------- + def _reset_generated_lobbies_and_users(self): + """Remove generated lobbies/games and fake users. + + Lobbies are identified by name prefix 'Fake Lobby '. Fake users are + identified by username prefix 'fake_user_'. Staff and superuser users + are excluded from user deletion if those fields exist. + """ + with transaction.atomic(): + lobby_qs = Lobby.objects.filter(name__startswith="Fake Lobby ") + deleted_count, details = lobby_qs.delete() + self.stdout.write( + self.style.WARNING(f"Reset requested: deleted {deleted_count} objects (details: {details}).")) + + try: + fake_user_qs = User.objects.filter(username__startswith="fake_user_").exclude(is_staff=True).exclude( + is_superuser=True) + except Exception: + fake_user_qs = User.objects.filter(username__startswith="fake_user_") + + fake_user_count = fake_user_qs.count() + if fake_user_count: + u_deleted_count, u_deleted_details = fake_user_qs.delete() + self.stdout.write( + self.style.WARNING(f"Deleted {fake_user_count} fake user(s) (deleted objects: {u_deleted_count}).")) + else: + self.stdout.write(self.style.NOTICE("No fake users found to delete.")) + + def _ensure_suits_and_ranks(self, card_count: int): + """Ensure CardSuit and CardRank rows exist. + + Args: + card_count: Number of cards in deck (24/36/52) to decide which ranks to create. + """ + suits = {"Hearts": "red", "Diamonds": "red", "Clubs": "black", "Spades": "black"} + for name, color in suits.items(): + CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + + if card_count == 52: + ranks = DEFAULT_RANKS_52 + elif card_count == 36: + ranks = DEFAULT_RANKS_36 + else: + ranks = DEFAULT_RANKS_24 + + existing_values = set(CardRank.objects.values_list("value", flat=True)) + for name, val in ranks: + if val not in existing_values: + CardRank.objects.create(name=name, value=val) + + def _ensure_cards(self, card_count: int): + """Ensure Card objects exist for the requested deck size. + + Args: + card_count: Deck size (24/36/52). + """ + suits = list(CardSuit.objects.all()) + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + created = 0 + for suit in suits: + for rank in ranks: + _, created_flag = Card.objects.get_or_create(suit=suit, rank=rank) + if created_flag: + created += 1 + if created: + self.stdout.write(self.style.NOTICE(f"Created {created} Card objects.")) + + def _ensure_users(self, max_needed: int) -> List[User]: + """Ensure at least `max_needed` users exist. + + The method prefers to call the `generate_test_users` management command + (from the accounts app). It requests creation with prefix `fake_user_` and + marker group `Test_Users`. If the call fails, it will fall back to inline + creation to ensure generation continues. + + Args: + max_needed: Minimum number of users required. + + Returns: + List of User instances (length >= max_needed). + """ + existing = list(User.objects.all().order_by("id")) + if len(existing) >= max_needed: + return existing[:max_needed] + + needed = max_needed - len(existing) + + prefix = "fake_user_" + existing_fake = list(User.objects.filter(username__startswith=prefix)) + max_index = 0 + for u in existing_fake: + m = re.search(rf'^{re.escape(prefix)}(\d+)$', u.username) + if m: + try: + idx = int(m.group(1)) + if idx > max_index: + max_index = idx + except Exception: + pass + start = max_index + 1 + + try: + # Ask the accounts app's generate_test_users command to create the missing users + call_command( + "generate_test_users", + "--count", + str(needed), + "--prefix", + prefix, + "--start", + str(start), + "--marker-group", + "Test_Users", + ) + self.stdout.write(self.style.NOTICE( + f"Requested {needed} test user(s) via generate_test_users (prefix={prefix}, start={start}).")) + except Exception as exc: + # Fallback inline creation + self.stdout.write( + self.style.WARNING(f"Calling generate_test_users failed: {exc}. Falling back to inline creation.")) + created_users = [] + for i in range(needed): + username = f"{prefix}{start + i}" + try: + user = User.objects.create_user(username=username, password="testpass") + except Exception: + # Try create without password method if custom user model differs + user = User.objects.create(username=username) + created_users.append(user) + self.stdout.write(self.style.NOTICE(f"Fallback created {len(created_users)} users.")) + all_users = existing + created_users + return all_users[:max_needed] + + all_users = list(User.objects.all().order_by("id")) + return all_users[:max_needed] + + def _draw_from_deck_list(self, deck_cards: List[Card]) -> Optional[Card]: + """Pop a card from a list representing the deck. + + Args: + deck_cards: Mutable list acting as the deck (front = top). + + Returns: + Card or None if deck is empty. + """ + if not deck_cards: + return None + return deck_cards.pop(0) + + def _create_fake_game(self, players: int, approx_moves: int, card_count: int, user_iter, game_index: int) -> Game: + """Create a single fake lobby/game and simulate play. + + Args: + players: Number of players in the created game. + approx_moves: Approximate number of attack/defend/pickup moves to simulate. + card_count: Deck size (24/36/52). + user_iter: Iterator that yields User objects (owner + players). + game_index: One-based index used for naming. + + Returns: + The created Game instance. + """ + owner_user = next(user_iter) + lobby = Lobby.objects.create(owner=owner_user, name=f"Fake Lobby {uuid.uuid4().hex[:6]}", is_private=False, + status="playing") + + LobbySettings.objects.create( + lobby=lobby, + max_players=players, + card_count=card_count, + is_transferable=False, + neighbor_throw_only=False, + allow_jokers=False, + ) + + # Build list of Card objects representing the deck + if card_count == 52: + ranks_qs = CardRank.objects.all() + elif card_count == 36: + ranks_qs = CardRank.objects.filter(value__gte=6) + else: + ranks_qs = CardRank.objects.filter(value__gte=9) + + ranks = list(ranks_qs) + suits = list(CardSuit.objects.all()) + deck_cards: List[Card] = [] + for suit in suits: + for rank in ranks: + try: + deck_cards.append(Card.objects.get(suit=suit, rank=rank)) + except Card.DoesNotExist: + continue + + random.shuffle(deck_cards) + trump_card_obj = deck_cards[-1] if deck_cards else None + if trump_card_obj is None: + raise RuntimeError("No Card objects available to create a game.") + + game = Game.objects.create(lobby=lobby, trump_card=trump_card_obj, status="in_progress") + + # Persist deck into GameDeck model + for pos, card in enumerate(deck_cards, start=1): + GameDeck.objects.create(game=game, card=card, position=pos) + + # Create LobbyPlayer and GamePlayer entries + game_players: List[GamePlayer] = [] + for seat in range(1, players + 1): + user = next(user_iter) + LobbyPlayer.objects.create(lobby=lobby, user=user, status="playing") + gp = GamePlayer.objects.create(game=game, user=user, seat_position=seat, cards_remaining=0) + game_players.append(gp) + + # Helper to pop top GameDeck entry (DB-backed) + def pop_top_db_card(game_obj: Game) -> Optional[Card]: + top_qs = GameDeck.objects.filter(game=game_obj).order_by("position")[:1] + if not top_qs: + return None + gd = top_qs[0] + card = gd.card + gd.delete() + return card + + # Deal initial hands (6 cards typical) + initial_hand_size = 6 + for gp in game_players: + for idx in range(initial_hand_size): + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, order_in_hand=idx + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + # Simulate a sequence of turns and moves + current_attacker_index = 0 + turn_counter = 0 + discard_position = 1 + + def find_defense_card_for_player(defender_user, attack_card): + """Return a PlayerHand instance that can defend or None.""" + ph_qs = PlayerHand.objects.filter(game=game, player=defender_user) + for ph in ph_qs: + try: + if ph.card.can_beat(attack_card, game.get_trump_suit()): + return ph + except Exception: + return None + return None + + for _ in range(approx_moves): + turn_counter += 1 + attacker_gp = game_players[current_attacker_index % len(game_players)] + attacker_user = attacker_gp.user + turn = Turn.objects.create(game=game, player=attacker_user, turn_number=turn_counter) + + attacker_hand = list(PlayerHand.objects.filter(game=game, player=attacker_user).order_by("order_in_hand")) + if not attacker_hand: + current_attacker_index += 1 + continue + + attack_ph = attacker_hand[0] + attack_card = attack_ph.card + attack_ph.delete() + attacker_gp.cards_remaining = max(0, attacker_gp.cards_remaining - 1) + attacker_gp.save(update_fields=["cards_remaining"]) + + table_card = TableCard.objects.create(game=game, attack_card=attack_card) + Move.objects.create(turn=turn, table_card=table_card, action_type="attack", created_at=timezone.now()) + + defender_index = (current_attacker_index + 1) % len(game_players) + defender_gp = game_players[defender_index] + defender_user = defender_gp.user + + defense_ph = find_defense_card_for_player(defender_user, attack_card) + if defense_ph: + defense_card = defense_ph.card + table_card.defense_card = defense_card + table_card.save(update_fields=["defense_card"]) + defense_ph.delete() + defender_gp.cards_remaining = max(0, defender_gp.cards_remaining - 1) + defender_gp.save(update_fields=["cards_remaining"]) + + Move.objects.create(turn=turn, table_card=table_card, action_type="defend", created_at=timezone.now()) + + DiscardPile.objects.create(game=game, card=attack_card, position=discard_position) + discard_position += 1 + DiscardPile.objects.create(game=game, card=defense_card, position=discard_position) + discard_position += 1 + else: + Move.objects.create(turn=turn, table_card=table_card, action_type="pickup", created_at=timezone.now()) + PlayerHand.objects.create(game=game, player=defender_user, card=attack_card, + order_in_hand=defender_gp.cards_remaining + 1) + defender_gp.cards_remaining += 1 + defender_gp.save(update_fields=["cards_remaining"]) + + # Refill hands up to 6 cards + for gp in game_players: + while gp.cards_remaining < 6: + card = pop_top_db_card(game) + if not card: + break + PlayerHand.objects.create(game=game, player=gp.user, card=card, + order_in_hand=gp.cards_remaining + 1) + gp.cards_remaining += 1 + gp.save(update_fields=["cards_remaining"]) + + current_attacker_index = (current_attacker_index + 1) % len(game_players) + + # Optionally finish the game and pick a loser + if random.random() < 0.6: + loser_gp = random.choice(game_players) + game.loser = loser_gp.user + game.status = "finished" + game.finished_at = timezone.now() + game.save(update_fields=["loser", "status", "finished_at"]) + + return game diff --git a/game/management/commands/reset_games.py b/game/management/commands/reset_games.py new file mode 100644 index 0000000..d9c5ec6 --- /dev/null +++ b/game/management/commands/reset_games.py @@ -0,0 +1,170 @@ +# game/management/commands/reset_games.py +from __future__ import annotations + +""" +Django management command to remove active/unfinished Game sessions and related data. + +This command is intended to clean up "in-progress" / partially-complete game data +from the database (for example after testing, during QA, or when resetting state). + +Behavior +-------- +- By default the command does a dry-run and prints how many Game objects would be affected + and shows their IDs and related Lobby names. +- To actually delete, pass --confirm. +- You can also limit deletion to specific game UUIDs via --game-ids (comma-separated). +- Deletion removes game-specific related objects: + GameDeck, PlayerHand, TableCard, DiscardPile, Move, Turn, GamePlayer + and finally the Game row itself. Deletion is performed inside a transaction. + +Notes +----- +- The command identifies "unfinished / active" games as those where either: + * status != 'finished' + OR + * finished_at IS NULL + (This is intentionally broad to catch any games that haven't been properly finished.) +- Lobbies are not deleted by default. If you want the lobbies removed as well, run the + separate `generate_fake_games --reset` (or tell me and I can add a --remove-lobbies flag). +""" + +from typing import List, Optional +import textwrap + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.db.models import Q +from django.utils import timezone + +from game.models import ( + Game, GameDeck, PlayerHand, TableCard, DiscardPile, + Move, Turn, GamePlayer +) + + +class Command(BaseCommand): + """Remove active or unfinished games and related data.""" + + help = "Remove active/unfinished Game rows and their related game-specific data." + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--confirm", + action="store_true", + default=False, + help="Actually perform deletion. Without this flag the command will only show a dry-run report." + ) + parser.add_argument( + "--game-ids", + type=str, + default=None, + help=( + "Optional comma-separated list of Game UUIDs to restrict deletions to. " + "If omitted, all active/unfinished games are targeted." + ) + ) + parser.add_argument( + "--verbose", + action="store_true", + default=False, + help="Print verbose information about each game that will be (or was) deleted." + ) + + def handle(self, *args, **options): + """ + Entry point for the management command. + + Steps: + 1. Construct a queryset of games considered 'active' or 'unfinished'. + 2. If --game-ids provided, restrict to those UUIDs. + 3. Report a dry-run summary unless --confirm is present. + 4. If --confirm, delete related objects and the Game rows within a transaction. + """ + confirm: bool = options["confirm"] + game_ids_raw: Optional[str] = options["game_ids"] + verbose: bool = options["verbose"] + + # Identify unfinished/active games: + # - status != 'finished' OR finished_at is NULL + queryset = Game.objects.filter(Q(finished_at__isnull=True) | ~Q(status="finished")) + + if game_ids_raw: + # parse comma-separated uuids and filter + ids = [s.strip() for s in game_ids_raw.split(",") if s.strip()] + if not ids: + self.stdout.write(self.style.ERROR("No valid game IDs parsed from --game-ids. Aborting.")) + return + queryset = queryset.filter(id__in=ids) + + total = queryset.count() + if total == 0: + self.stdout.write(self.style.NOTICE("No active or unfinished games found. Nothing to do.")) + return + + # Dry-run info + self.stdout.write(self.style.WARNING( + textwrap.dedent( + f""" + Found {total} active/unfinished game(s) that match the criteria. + To actually delete these games and their related data, re-run with --confirm. + """ + ).strip() + )) + + # show brief list (and verbose details when requested) + games_list = list(queryset.values("id", "lobby__name", "status", "finished_at")[:200]) + # show up to 200 items to avoid spamming console for massive deletions + for g in games_list: + self.stdout.write(f"- Game {g['id']} lobby='{g['lobby__name']}' status='{g['status']}' finished_at={g['finished_at']}") + + if total > len(games_list): + self.stdout.write(self.style.NOTICE(f"... (only first {len(games_list)} shown)")) + + if not confirm: + self.stdout.write(self.style.NOTICE("Dry run complete. No rows were deleted. Use --confirm to proceed.")) + return + + # Perform deletion inside a single transaction for safety + deleted_games = [] + with transaction.atomic(): + # Iterate games to ensure we delete related rows in safe order and can report progress + for game in queryset.select_related("lobby").all(): + if verbose: + self.stdout.write(f"Deleting related objects for game {game.id} (lobby='{getattr(game.lobby, 'name', None)}')...") + + # Delete moves (which reference turns) first + moves_deleted = Move.objects.filter(turn__game=game).delete() + if verbose: + self.stdout.write(f" - Moves deleted: {moves_deleted[0]}") + + # Delete turns + turns_deleted = Turn.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - Turns deleted: {turns_deleted[0]}") + + # Delete table cards and discard piles + tablecards_deleted = TableCard.objects.filter(game=game).delete() + discards_deleted = DiscardPile.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - TableCard deleted: {tablecards_deleted[0]}, DiscardPile deleted: {discards_deleted[0]}") + + # Delete player hands and deck entries + ph_deleted = PlayerHand.objects.filter(game=game).delete() + deck_deleted = GameDeck.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - PlayerHand deleted: {ph_deleted[0]}, GameDeck deleted: {deck_deleted[0]}") + + # Delete game player rows + gp_deleted = GamePlayer.objects.filter(game=game).delete() + if verbose: + self.stdout.write(f" - GamePlayer deleted: {gp_deleted[0]}") + + # Finally delete the game row itself + game_id = str(game.id) + game.delete() + deleted_games.append(game_id) + self.stdout.write(self.style.SUCCESS(f"Deleted Game {game_id} and related objects.")) + + # finished + self.stdout.write(self.style.SUCCESS(f"Deletion complete. Removed {len(deleted_games)} game(s): {deleted_games}")) From 9a6beb8fddf196fce7374bdebf478b492052849e Mon Sep 17 00:00:00 2001 From: Surmachov Date: Sat, 1 Nov 2025 00:12:28 +0300 Subject: [PATCH 35/38] Added pytest for every management command. --- accounts/tests/test_generate_test_users.py | 61 ++++++++++++++++++++++ game/tests/test_export_import_db.py | 25 +++++++++ game/tests/test_generate_fake_games.py | 17 ++++++ game/tests/test_init_game_data.py | 16 ++++++ game/tests/test_reset_games.py | 18 +++++++ 5 files changed, 137 insertions(+) create mode 100644 accounts/tests/test_generate_test_users.py create mode 100644 game/tests/test_export_import_db.py create mode 100644 game/tests/test_generate_fake_games.py create mode 100644 game/tests/test_init_game_data.py create mode 100644 game/tests/test_reset_games.py diff --git a/accounts/tests/test_generate_test_users.py b/accounts/tests/test_generate_test_users.py new file mode 100644 index 0000000..7063909 --- /dev/null +++ b/accounts/tests/test_generate_test_users.py @@ -0,0 +1,61 @@ +# accounts/tests/test_generate_test_users.py +""" +Tests for accounts.generate_test_users management command. +""" +from django.contrib.auth import get_user_model +from django.core.management import call_command +import pytest + +User = get_user_model() + + +@pytest.mark.django_db +def test_generate_test_users_creates_and_adds_group(user_factory): + # Ensure baseline + before = list(User.objects.filter(username__startswith="test_").order_by("id")) + + # Create 3 users + call_command( + "generate_test_users", + "--count", + "3", + "--prefix", + "test_", + "--start", + "1", + "--marker-group", + "Test_Users", + ) + + after = list(User.objects.filter(username__startswith="test_").order_by("id")) + assert len(after) >= len(before) + 3 + + # Clean up via the same command (non-interactive) + call_command("generate_test_users", "--delete", "--marker-group", "Test_Users", "--noinput") + + remaining = list(User.objects.filter(username__startswith="test_").order_by("id")) + # After deletion, remaining should be <= before + assert len(remaining) <= len(before) + + +@pytest.mark.django_db +def test_generate_test_users_force_and_conflict(user_factory): + # Create a user that would conflict + u = user_factory(username="conflictuser") + + # Create with same prefix and start so conflict occurs, pass --force to override + call_command( + "generate_test_users", + "--count", + "1", + "--prefix", + "conflictuser", + "--start", + "1", + "--force", + "--marker-group", + "Test_Users", + ) + + # At least one username starting with 'conflictuser' should exist + assert User.objects.filter(username__startswith="conflictuser").exists() diff --git a/game/tests/test_export_import_db.py b/game/tests/test_export_import_db.py new file mode 100644 index 0000000..74d217e --- /dev/null +++ b/game/tests/test_export_import_db.py @@ -0,0 +1,25 @@ +import pytest +from django.core.management import call_command +from game.models import Lobby +from accounts.models import User + +@pytest.mark.django_db +def test_export_import_db_creates_objects(user_factory, tmp_path): + user = user_factory(username="player1") + lobby_name = "TestLobby" + Lobby.objects.create(owner=user, name=lobby_name) + + # Export to temp file using --output + tmp_file = tmp_path / "backup.json" + call_command("export_db", f"--output={tmp_file}") + + # Clear DB (simulate fresh import) + Lobby.objects.all().delete() + User.objects.filter(username="player1").delete() + + # Import back + call_command("import_db", str(tmp_file)) + + # Assertions + assert Lobby.objects.filter(name=lobby_name).exists() + assert User.objects.filter(username="player1").exists() diff --git a/game/tests/test_generate_fake_games.py b/game/tests/test_generate_fake_games.py new file mode 100644 index 0000000..1dcab7d --- /dev/null +++ b/game/tests/test_generate_fake_games.py @@ -0,0 +1,17 @@ +import pytest +from django.core.management import call_command +from game.models import Game, Lobby +from accounts.models import User + +@pytest.mark.django_db +def test_generate_fake_games_creates_games(user_factory): + user = user_factory(username="owner") + lobby = Lobby.objects.create(owner=user, name="FakeLobby") + + # Generate one game + call_command("generate_fake_games") + + # There should be at least one game + assert Game.objects.exists() + game = Game.objects.first() + assert game.lobby == lobby or game.lobby is not None diff --git a/game/tests/test_init_game_data.py b/game/tests/test_init_game_data.py new file mode 100644 index 0000000..0fb2d2f --- /dev/null +++ b/game/tests/test_init_game_data.py @@ -0,0 +1,16 @@ +import pytest +from django.core.management import call_command +from game.models import CardSuit, CardRank, Card + +@pytest.mark.django_db +def test_init_game_data_creates_cards(): + call_command("init_game_data", "--deck-size", "36", "--reset") + + # Check suits + assert CardSuit.objects.count() == 4 + + # Numeric 6..10 + face cards = 5 + 4 = 9 + assert CardRank.objects.count() == 9 + + # Total cards = 36 + assert Card.objects.count() == 36 diff --git a/game/tests/test_reset_games.py b/game/tests/test_reset_games.py new file mode 100644 index 0000000..e0371a4 --- /dev/null +++ b/game/tests/test_reset_games.py @@ -0,0 +1,18 @@ +# game/tests/test_reset_games.py +import pytest +from django.core.management import call_command +from game.models import Game +from datetime import datetime, timedelta + +@pytest.mark.django_db +def test_reset_games_removes_active_games(basic_game): + # Ensure game exists + assert Game.objects.count() == 1 + + # Dry-run: should not delete + call_command("reset_games") + assert Game.objects.count() == 1 + + # Actual deletion + call_command("reset_games", "--confirm") + assert Game.objects.count() == 0 From 2b152f8f1ac7f82728acc59fe454005ffc6746ba Mon Sep 17 00:00:00 2001 From: Surmachov Date: Tue, 4 Nov 2025 23:10:27 +0300 Subject: [PATCH 36/38] Fixes and refactoring of game management commands. --- accounts/management/__init__.py | 4 + accounts/management/commands/__init__.py | 4 + .../commands/generate_test_users.py | 78 ++--- accounts/tests/__init__.py | 2 +- accounts/tests/test_generate_test_users.py | 81 +++-- game/management/__init__.py | 4 + game/management/commands/__init__.py | 4 + game/management/commands/export_db.py | 295 +++++++++-------- .../commands/generate_fake_games.py | 92 ++++-- game/management/commands/import_db.py | 297 +++++++++++------ game/management/commands/init_game_data.py | 162 +++++----- game/management/commands/reset_games.py | 303 +++++++++--------- game/tests/test_export_import_db.py | 23 +- game/tests/test_generate_fake_games.py | 35 +- game/tests/test_init_game_data.py | 20 ++ game/tests/test_reset_games.py | 31 +- 16 files changed, 864 insertions(+), 571 deletions(-) diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py index e69de29..0401907 100644 --- a/accounts/management/__init__.py +++ b/accounts/management/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for accounts app. + +This package contains management commands for accounts application. +""" \ No newline at end of file diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py index e69de29..0401907 100644 --- a/accounts/management/commands/__init__.py +++ b/accounts/management/commands/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for accounts app. + +This package contains management commands for accounts application. +""" \ No newline at end of file diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 6e94154..5c079b4 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -1,30 +1,22 @@ """ -Create test users for development/testing and optionally delete all users -belonging to the marker group (default: Test_Users). - -This command has two modes: - -* Creation (default): create users with --count, --prefix, --password, etc. - Created users are added to the marker group so they can be deleted safely later. - -* Deletion: pass --delete to delete all users who are members of the marker group. - Deletion excludes staff and superusers by default to avoid accidental removal. - -Examples: - # Create 5 users: +Management command to create test users and delete users belonging to a +special marker group (default: "Test_Users"). + +Functionality: +- Creation mode (default): creates users with configurable parameters such as + count, prefix, start index, email domain, password, and flags (staff, + superuser, inactive). All created users are added to the marker group so they + can be safely deleted later. +- Deletion mode (--delete flag): deletes all users who belong to the marker + group, excluding staff and superusers by default. Supports --dry-run for + previewing actions and --noinput for non-interactive deletion. + +Usage examples: + # Create 5 users with prefix dev_ python manage.py generate_test_users --count 5 --prefix dev_ --password secret - # Dry-run create: - python manage.py generate_test_users --count 3 --prefix demo --dry-run - - # Delete all users in Test_Users group (interactive confirmation) - python manage.py generate_test_users --delete - - # Delete without prompt (careful!) + # Delete all users in the marker group without confirmation python manage.py generate_test_users --delete --noinput - - # Preview deletions without performing them - python manage.py generate_test_users --delete --dry-run """ from __future__ import annotations @@ -40,20 +32,22 @@ User = get_user_model() -def _random_suffix(length: int = 4) -> str: - """Generate a short random alphanumeric suffix. - Args: - length: Length of the suffix (default: 4). +class Command(BaseCommand): + @staticmethod + def _random_suffix(length: int = 4) -> str: + """Generate a short random alphanumeric suffix. - Returns: - A random string composed of lowercase letters and digits. - """ - chars = string.ascii_lowercase + string.digits - return "".join(random.choice(chars) for _ in range(length)) + Args: + length: Length of the suffix (default: 4). + + Returns: + A random string composed of lowercase letters and digits. + """ + chars = string.ascii_lowercase + string.digits + return "".join(random.choice(chars) for _ in range(length)) -class Command(BaseCommand): """Management command to create test users and delete marker-group users. Creation mode (default) creates test users and adds them to a marker group @@ -163,7 +157,6 @@ def handle(self, *args, **options): # Deletion mode: delete all users in marker group (excluding staff/superuser) if options.get("delete"): - # Ensure the group exists try: group = Group.objects.get(name=marker_group_name) except Group.DoesNotExist: @@ -172,10 +165,8 @@ def handle(self, *args, **options): )) return - # Query users in the group qs = User.objects.filter(groups__name=marker_group_name) - # Exclude staff and superuser accounts for safety if model has those flags if hasattr(User, "is_staff"): qs = qs.exclude(is_staff=True) if hasattr(User, "is_superuser"): @@ -186,7 +177,6 @@ def handle(self, *args, **options): self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) return - # List matched users self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) for u in qs: parts = [f"username='{getattr(u, 'username', '')}'"] @@ -198,25 +188,22 @@ def handle(self, *args, **options): self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) return - # Confirm unless noinput if not options.get("noinput"): answer = input("Delete all listed users? This is irreversible. [y/N]: ") if answer.lower() not in ("y", "yes"): self.stdout.write(self.style.WARNING("Aborted by user.")) return - # Perform deletions deleted = 0 failed = [] try: with transaction.atomic(): for u in qs: try: - u.delete() # call delete() to respect signals/cascades + u.delete() deleted += 1 except Exception as exc: failed.append((u, exc)) - # continue deleting others self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) if failed: self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) @@ -225,9 +212,8 @@ def handle(self, *args, **options): except Exception as exc_outer: raise CommandError(f"Deletion transaction failed: {exc_outer}") - return # done + return - # Creation mode: create users and add to marker group count: int = int(options.get("count", 1)) prefix: str = options.get("prefix") or "testuser" start: int = int(options.get("start", 1)) @@ -240,7 +226,6 @@ def handle(self, *args, **options): created: List[User] = [] - # Ensure marker group exists (get_or_create is safe; use admin-created group if present) group_obj: Optional[Group] = None try: group_obj, _ = Group.objects.get_or_create(name=marker_group_name) @@ -251,12 +236,11 @@ def handle(self, *args, **options): username = self._make_username(prefix, i) email = self._email_for_username(username, email_domain) - # Handle existing username if User.objects.filter(username=username).exists(): if not force: self.stdout.write(self.style.WARNING(f"Skipping existing username: {username}")) continue - username = f"{username}_{_random_suffix()}" + username = f"{username}_{self._random_suffix()}" email = self._email_for_username(username, email_domain) self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) @@ -266,7 +250,6 @@ def handle(self, *args, **options): ) continue - # Create user if make_superuser: user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] @@ -289,7 +272,6 @@ def handle(self, *args, **options): except Exception: pass - # Add to marker group if possible try: if group_obj is not None and hasattr(user, "groups"): user.groups.add(group_obj) diff --git a/accounts/tests/__init__.py b/accounts/tests/__init__.py index 2c59631..a939001 100644 --- a/accounts/tests/__init__.py +++ b/accounts/tests/__init__.py @@ -1,4 +1,4 @@ -"""Test suite for game app. +"""Test suite for accounts app. This package contains comprehensive tests for all accounts-related models, """ diff --git a/accounts/tests/test_generate_test_users.py b/accounts/tests/test_generate_test_users.py index 7063909..5326cb4 100644 --- a/accounts/tests/test_generate_test_users.py +++ b/accounts/tests/test_generate_test_users.py @@ -1,8 +1,15 @@ -# accounts/tests/test_generate_test_users.py """ Tests for accounts.generate_test_users management command. + +This module exercises the `generate_test_users` management command by: +- creating a set of test users with a given prefix and marker group, +- asserting the expected count changes, +- verifying that deletion via the same command removes the created users, +- and checking conflict/force behavior and group membership. """ + from django.contrib.auth import get_user_model +from django.contrib.auth.models import Group from django.core.management import call_command import pytest @@ -11,51 +18,89 @@ @pytest.mark.django_db def test_generate_test_users_creates_and_adds_group(user_factory): - # Ensure baseline - before = list(User.objects.filter(username__startswith="test_").order_by("id")) + """Create a small batch of test users and verify they are added to a group. + + The test records the baseline set of users with the given prefix, runs the + command to create `test_users_count` users, asserts the user count grew by + that amount, and then deletes those users using the command's `--delete` + flag and verifies cleanup. + """ - # Create 3 users + # Configuration for this test (easy to change in one place) + test_users_count = 3 + prefix = "test_" + marker_group = "Test_Users" + + # Baseline + before = list(User.objects.filter(username__startswith=prefix).order_by("id")) + + # Create test users call_command( "generate_test_users", "--count", - "3", + str(test_users_count), "--prefix", - "test_", + prefix, "--start", "1", "--marker-group", - "Test_Users", + marker_group, ) - after = list(User.objects.filter(username__startswith="test_").order_by("id")) - assert len(after) >= len(before) + 3 + after = list(User.objects.filter(username__startswith=prefix).order_by("id")) + assert len(after) >= len(before) + test_users_count + + # Ensure marker group exists and some of the created users are in it + group = Group.objects.filter(name=marker_group).first() + assert group is not None, "Expected the marker group to be created" + assert group.user_set.filter(username__startswith=prefix).exists() # Clean up via the same command (non-interactive) - call_command("generate_test_users", "--delete", "--marker-group", "Test_Users", "--noinput") + call_command("generate_test_users", "--delete", "--marker-group", marker_group, "--noinput") - remaining = list(User.objects.filter(username__startswith="test_").order_by("id")) + remaining = list(User.objects.filter(username__startswith=prefix).order_by("id")) # After deletion, remaining should be <= before assert len(remaining) <= len(before) @pytest.mark.django_db def test_generate_test_users_force_and_conflict(user_factory): - # Create a user that would conflict - u = user_factory(username="conflictuser") + """Test behavior when existing usernames conflict with the generated prefix. - # Create with same prefix and start so conflict occurs, pass --force to override + The test creates an existing user whose username would conflict with the + generated users' prefix, then runs the command with `--force` and asserts + that at least one user with that prefix exists and that the marker group + contains at least one such user (i.e. the command created or assigned a + user to the group). + """ + + prefix = "conflictuser" + marker_group = "Test_Users" + + # Create a user that would share the prefix + u = user_factory(username=prefix) + + before_count = User.objects.filter(username__startswith=prefix).count() + + # Create with same prefix and start so a conflict may occur, pass --force call_command( "generate_test_users", "--count", "1", "--prefix", - "conflictuser", + prefix, "--start", "1", "--force", "--marker-group", - "Test_Users", + marker_group, ) - # At least one username starting with 'conflictuser' should exist - assert User.objects.filter(username__startswith="conflictuser").exists() + after_count = User.objects.filter(username__startswith=prefix).count() + # Ensure count did not decrease and (ideally) increased by at least 1 + assert after_count >= before_count + + # Ensure marker group exists and contains at least one user with the prefix + group = Group.objects.filter(name=marker_group).first() + assert group is not None, "Expected the marker group to be created" + assert group.user_set.filter(username__startswith=prefix).exists() diff --git a/game/management/__init__.py b/game/management/__init__.py index e69de29..fe88c33 100644 --- a/game/management/__init__.py +++ b/game/management/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for game app. + +This package contains management commands for game application. +""" \ No newline at end of file diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py index e69de29..fe88c33 100644 --- a/game/management/commands/__init__.py +++ b/game/management/commands/__init__.py @@ -0,0 +1,4 @@ +"""Commands suite for game app. + +This package contains management commands for game application. +""" \ No newline at end of file diff --git a/game/management/commands/export_db.py b/game/management/commands/export_db.py index a207585..f65a191 100644 --- a/game/management/commands/export_db.py +++ b/game/management/commands/export_db.py @@ -1,6 +1,27 @@ +""" +Export database rows to a JSON file (Django serialization) with optional apps filter. + +This management command streams model instances into a JSON array in chunks to +avoid building one huge list in memory. It supports filtering exported models +by app label (--apps), excluding particular models (--exclude), pretty JSON +(--indent), natural key serialization, gzipped output and configurable +chunk sizes. + +Design notes +- The `handle` entry point is small and delegates to helper methods for I/O + and per-model streaming to follow single-responsibility and make unit + testing easier. +- The command avoids loading entire querysets into memory by iterating and + flushing chunks. + +Examples + python manage.py export_db + python manage.py export_db --apps game,auth --indent 2 -o backups/backup.json.gz +""" + import os import gzip -from typing import Optional, Set, List +from typing import Optional, Set, List, Callable, Tuple from django.core.management.base import BaseCommand, CommandError from django.apps import apps @@ -14,47 +35,17 @@ } -def resolve_output_path(path: str) -> str: - """Resolve a possibly-relative output path against BASE_DIR. - - Args: - path: Output file path (absolute or relative). - - Returns: - Absolute path inside BASE_DIR if a relative path was provided. - """ - base = getattr(settings, "BASE_DIR", os.getcwd()) - if not os.path.isabs(path): - path = os.path.join(base, path) - directory = os.path.dirname(path) - if directory: - os.makedirs(directory, exist_ok=True) - return path - - class Command(BaseCommand): - """Export database rows to a JSON file (Django serialization) with optional apps filter. - - This command streams model instances to a JSON array in chunks (to avoid - building one huge list in memory). Use ``--apps`` to limit exported objects - to models that belong to a set of app labels (comma-separated). Use - ``--indent N`` to pretty-print multi-line JSON. + """Export database rows to JSON using Django serializers. - Example usage: - # Default: writes db_backups/backup.json under BASE_DIR - python manage.py export_db - - # Pretty-printed, only export 'game' and 'auth' apps - python manage.py export_db --apps game,auth --indent 2 - - # Gzipped output inside project - python manage.py export_db -o db_backups/backup.json.gz --indent 2 + The implementation keeps `handle()` concise by delegating tasks to + small helpers like `_resolve_output_path`, `_open_output_stream`, and + `_serialize_chunk_and_write`. """ help = "Export database rows to a JSON file (Django serialization). Supports --apps filter." def add_arguments(self, parser): - """Define command-line arguments.""" parser.add_argument( "--output", "-o", @@ -97,77 +88,143 @@ def add_arguments(self, parser): help="Number of objects to serialize per chunk (memory / performance tuning).", ) + # ------------------------- + # Small helpers + # ------------------------- def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: - """Parse the --apps argument into a set of app labels. - - Args: - apps_arg: Comma-separated app labels or None. - - Returns: - A set of app labels if apps_arg provided, otherwise None. - """ if not apps_arg: return None return {p.strip() for p in apps_arg.split(",") if p.strip()} - def handle(self, *args, **options): - """Main command entry point. + def _resolve_output_path(self, path: str) -> str: + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + directory = os.path.dirname(path) + if directory: + os.makedirs(directory, exist_ok=True) + return path - Steps: - 1. Collect models to export (apply --apps and --exclude filters). - 2. Stream objects per-model and per-chunk, serializing each chunk and - writing the inner JSON to the output file/stream. - 3. Produce pretty JSON when --indent is provided. - """ - output = options["output"] - apps_arg = options["apps"] - exclude_arg = options["exclude"] - indent = options["indent"] - use_nat_foreign = options["natural_foreign"] - use_nat_primary = options["natural_primary"] - chunk_size = options["chunk_size"] - - apps_filter = self._parse_apps_arg(apps_arg) - exclude_set = set(x.strip() for x in exclude_arg.split(",") if x.strip()) - - # Collect models, applying app & exclude filters. + def models_to_export(self, apps_filter: Optional[Set[str]], exclude_set: Set[str]) -> List[type]: all_models = list(apps.get_models()) models_to_export: List[type] = [] for m in all_models: full_name = f"{m._meta.app_label}.{m.__name__}" if m.__name__ in EXCLUDE_MODEL_NAMES or full_name in exclude_set or m.__name__ in exclude_set: - # skip common internal models or anything explicitly excluded continue if apps_filter is not None and m._meta.app_label not in apps_filter: - # skip models that are not in the requested apps continue models_to_export.append(m) + return models_to_export - if not models_to_export: - raise CommandError("No models found to export (check --apps and --exclude).") + def _open_output_stream(self, output: str) -> Tuple[Callable[[str], None], Callable[[], None], str]: + """Return (write_chunk, close_fn, output_display_name). - # Determine output destination + write_chunk is a callable that accepts a string and writes to the + destination. close_fn closes the underlying file object when used. + output_display_name is used in the final status message. + """ write_to_stdout = (output == "-") - if not write_to_stdout: - output = resolve_output_path(output) - - # Open output (gz or plain file) or use stdout if write_to_stdout: write_chunk = lambda s: self.stdout.write(s) - close_fh = lambda: None + close_fn = lambda: None + return write_chunk, close_fn, "stdout" + + # Ensure path exists and open the file handle + output_path = self._resolve_output_path(output) + if output_path.endswith(".gz"): + fh = gzip.open(output_path, "wt", encoding="utf-8") else: - if output.endswith(".gz"): - fh = gzip.open(output, "wt", encoding="utf-8") - else: - fh = open(output, "w", encoding="utf-8") - write_chunk = fh.write - close_fh = fh.close + fh = open(output_path, "w", encoding="utf-8") + + def _write(s: str): + fh.write(s) + + def _close(): + try: + fh.close() + except Exception: + pass + + return _write, _close, output_path + + def _serialize_chunk_and_write( + self, + chunk: List[object], + indent: Optional[int], + use_nat_foreign: bool, + use_nat_primary: bool, + write_chunk: Callable[[str], None], + separator: str, + first_piece: bool, + compact: bool, + ) -> bool: + """Serialize a chunk and write its inner JSON array items to the stream. + + Returns True if any items were written (so caller can update first_piece). + """ + if not chunk: + return False + + serialized = serializers.serialize( + "json", + chunk, + indent=indent, + use_natural_foreign_keys=use_nat_foreign, + use_natural_primary_keys=use_nat_primary, + ) + start = serialized.find('[') + end = serialized.rfind(']') + if start == -1 or end == -1: + # Fallback: write whole serialization + inner = serialized + else: + inner = serialized[start + 1 : end] + + if indent is not None: + inner = inner.lstrip('\n').rstrip('\n') + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + return True + return False + else: + inner = inner.strip() + if inner: + if not first_piece: + write_chunk(separator) + write_chunk(inner) + return True + return False + + # ------------------------- + # Main handler (delegates heavily) + # ------------------------- + def handle(self, *args, **options): + """Main command entry: collect models and stream them to JSON output.""" + output = options["output"] + apps_arg = options["apps"] + exclude_arg = options["exclude"] + indent = options["indent"] + use_nat_foreign = options["natural_foreign"] + use_nat_primary = options["natural_primary"] + chunk_size = options["chunk_size"] + + apps_filter = self._parse_apps_arg(apps_arg) + exclude_set = {x.strip() for x in exclude_arg.split(",") if x.strip()} if exclude_arg else set() + + models_to_export = self.models_to_export(apps_filter, exclude_set) + if not models_to_export: + raise CommandError("No models found to export (check --apps and --exclude).") + + write_chunk, close_fh, output_display = self._open_output_stream(output) total_objects = 0 first_piece = True try: - # Prepare array formatting depending on pretty vs compact + # Prepare JSON array delimiters if indent is not None: write_chunk("[\n") separator = ",\n" @@ -177,77 +234,47 @@ def handle(self, *args, **options): separator = "," closing = "]" - # Stream per model to limit memory usage + # Stream objects model-by-model in chunks for model in models_to_export: qs = model._default_manager.all().iterator() chunk: List[object] = [] for obj in qs: chunk.append(obj) if len(chunk) >= chunk_size: - serialized = serializers.serialize( - "json", + wrote = self._serialize_chunk_and_write( chunk, - indent=indent, - use_natural_foreign_keys=use_nat_foreign, - use_natural_primary_keys=use_nat_primary, + indent, + use_nat_foreign, + use_nat_primary, + write_chunk, + separator, + first_piece, + compact=(indent is None), ) - # Extract the JSON inner array content (strip leading/trailing [ ]) - start = serialized.find('[') - end = serialized.rfind(']') - inner = serialized[start+1:end] - - if indent is not None: - # Trim surrounding newlines for nicer concatenation - inner = inner.lstrip('\n').rstrip('\n') - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - else: - inner = inner.strip() - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - + if wrote: + first_piece = False total_objects += len(chunk) chunk = [] - # Flush any remaining objects for this model + # flush remaining if chunk: - serialized = serializers.serialize( - "json", + wrote = self._serialize_chunk_and_write( chunk, - indent=indent, - use_natural_foreign_keys=use_nat_foreign, - use_natural_primary_keys=use_nat_primary, + indent, + use_nat_foreign, + use_nat_primary, + write_chunk, + separator, + first_piece, + compact=(indent is None), ) - start = serialized.find('[') - end = serialized.rfind(']') - inner = serialized[start+1:end] - - if indent is not None: - inner = inner.lstrip('\n').rstrip('\n') - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - else: - inner = inner.strip() - if inner: - if not first_piece: - write_chunk(separator) - write_chunk(inner) - first_piece = False - + if wrote: + first_piece = False total_objects += len(chunk) - # Close JSON array + # Finish JSON write_chunk(closing) finally: close_fh() - self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output}")) + self.stdout.write(self.style.SUCCESS(f"Exported {total_objects} objects to {output_display}")) diff --git a/game/management/commands/generate_fake_games.py b/game/management/commands/generate_fake_games.py index 630db51..60e880e 100644 --- a/game/management/commands/generate_fake_games.py +++ b/game/management/commands/generate_fake_games.py @@ -11,7 +11,6 @@ Usage: python manage.py generate_fake_games --games 3 --players 4 --moves 30 --card-count 36 """ -from __future__ import annotations import itertools import random @@ -23,6 +22,7 @@ from django.core.management.base import BaseCommand, CommandError from django.db import transaction from django.utils import timezone +from django.contrib.auth.models import Group from django.contrib.auth import get_user_model from game.models import ( @@ -97,15 +97,15 @@ def add_arguments(self, parser): "--card-count", type=int, choices=[24, 36, 52], - default=36, - help="Deck size per lobby (24, 36, or 52). Default: 36", + default=52, + help="Deck size per lobby (24, 36, or 52). Default: 52", ) parser.add_argument("--seed", type=int, default=None, help="Random seed for reproducible runs") parser.add_argument( "--reset", action="store_true", default=False, - help="Delete generated lobbies/games (prefix 'Fake Lobby ') and fake users (prefix 'fake_user_')", + help="Delete generated lobbies/games and fake users (prefix 'fake_user_')", ) def handle(self, *args, **options): @@ -164,31 +164,62 @@ def handle(self, *args, **options): # Helpers # --------------------------------------------------------------------- def _reset_generated_lobbies_and_users(self): - """Remove generated lobbies/games and fake users. - - Lobbies are identified by name prefix 'Fake Lobby '. Fake users are - identified by username prefix 'fake_user_'. Staff and superuser users - are excluded from user deletion if those fields exist. + """Remove lobbies that contain any player who belongs to the Test_Users group, + then remove non-staff/non-superuser users who are members of that group. + + Behavior: + - Find the Group named 'Test_Users'. If it doesn't exist, nothing to delete. + - Find all users in that group. + - Find all Lobby objects that have a LobbyPlayer referencing any of those users, + and delete those lobbies (and their related game rows). + - Delete users in the group, excluding staff and superusers if those flags exist. """ with transaction.atomic(): - lobby_qs = Lobby.objects.filter(name__startswith="Fake Lobby ") - deleted_count, details = lobby_qs.delete() - self.stdout.write( - self.style.WARNING(f"Reset requested: deleted {deleted_count} objects (details: {details}).")) - + # Resolve the marker group try: - fake_user_qs = User.objects.filter(username__startswith="fake_user_").exclude(is_staff=True).exclude( - is_superuser=True) - except Exception: - fake_user_qs = User.objects.filter(username__startswith="fake_user_") - - fake_user_count = fake_user_qs.count() - if fake_user_count: - u_deleted_count, u_deleted_details = fake_user_qs.delete() - self.stdout.write( - self.style.WARNING(f"Deleted {fake_user_count} fake user(s) (deleted objects: {u_deleted_count}).")) + marker_group = Group.objects.get(name="Test_Users") + except Group.DoesNotExist: + self.stdout.write(self.style.NOTICE("Group 'Test_Users' does not exist. Nothing to reset.")) + return + + # All users who are members of the marker group + users_in_group = User.objects.filter(groups=marker_group) + + total_users = users_in_group.count() + if total_users == 0: + self.stdout.write(self.style.NOTICE("No users found in group 'Test_Users'. Nothing to reset.")) + return + + # Find lobby IDs that have at least one LobbyPlayer who is in that group + lobby_ids_qs = LobbyPlayer.objects.filter(user__in=users_in_group).values_list("lobby_id", + flat=True).distinct() + lobby_ids = list(lobby_ids_qs) + + if lobby_ids: + lobby_qs = Lobby.objects.filter(id__in=lobby_ids) + deleted_count, details = lobby_qs.delete() + self.stdout.write(self.style.WARNING( + f"Deleted {deleted_count} objects belonging to {len(lobby_ids)} lobby(ies) that had Test_Users members (details: {details})." + )) else: - self.stdout.write(self.style.NOTICE("No fake users found to delete.")) + self.stdout.write(self.style.NOTICE("No lobbies found that contain users from group 'Test_Users'.")) + + # Now delete non-staff, non-superuser users who belong to the marker group + users_to_delete = users_in_group + if hasattr(User, "is_staff"): + users_to_delete = users_to_delete.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + users_to_delete = users_to_delete.exclude(is_superuser=True) + + count_to_delete = users_to_delete.count() + if count_to_delete: + u_deleted_count, u_deleted_details = users_to_delete.delete() + self.stdout.write(self.style.WARNING( + f"Deleted {count_to_delete} user(s) from group 'Test_Users' (deleted objects: {u_deleted_count})." + )) + else: + self.stdout.write( + self.style.NOTICE("No non-staff/non-superuser users in group 'Test_Users' to delete.")) def _ensure_suits_and_ranks(self, card_count: int): """Ensure CardSuit and CardRank rows exist. @@ -295,7 +326,7 @@ def _ensure_users(self, max_needed: int) -> List[User]: try: user = User.objects.create_user(username=username, password="testpass") except Exception: - # Try create without password method if custom user model differs + #Try create without password method if custom user model differs user = User.objects.create(username=username) created_users.append(user) self.stdout.write(self.style.NOTICE(f"Fallback created {len(created_users)} users.")) @@ -332,8 +363,13 @@ def _create_fake_game(self, players: int, approx_moves: int, card_count: int, us The created Game instance. """ owner_user = next(user_iter) - lobby = Lobby.objects.create(owner=owner_user, name=f"Fake Lobby {uuid.uuid4().hex[:6]}", is_private=False, - status="playing") + # NOTE: lobby name no longer uses 'Fake Lobby' prefix to avoid relying on names for cleanup. + lobby = Lobby.objects.create( + owner=owner_user, + name=f"Generated Lobby {uuid.uuid4().hex[:6]}", + is_private=False, + status="playing", + ) LobbySettings.objects.create( lobby=lobby, diff --git a/game/management/commands/import_db.py b/game/management/commands/import_db.py index 2b5d83f..d36622c 100644 --- a/game/management/commands/import_db.py +++ b/game/management/commands/import_db.py @@ -1,6 +1,24 @@ +""" +Import JSON (Django serialization) into the database with optional filtering. + +This management command imports JSON previously exported by `export_db` (Django +serialized objects). It supports gzipped input, an app-label filter (--apps), +an optional full DB flush (--clear), and resetting database sequences for +PostgreSQL-driven backends. + +Behavior summary +--------------- +- Input: path to JSON or '-' for stdin. '.gz' files are supported. +- --apps: comma-separated list of app labels to import (if omitted, import all). +- --ignore-errors: attempt to continue past individual object save errors. +- --clear: run `manage.py flush --noinput` before importing (DANGEROUS). +- Sequence reset: automatically attempted for Postgres or when --reset-sequences is given. +""" + import os import gzip -from typing import Optional, Set, List +from typing import Optional, Set, List, Iterable, Tuple + from django.core.management.base import BaseCommand, CommandError from django.core import serializers from django.db import transaction, IntegrityError, connection @@ -10,23 +28,9 @@ from django.core.management.color import no_style -def resolve_input_path(path: str) -> str: - """Resolve a possibly-relative input path against BASE_DIR. - - Args: - path: Input file path (absolute or relative). - - Returns: - Absolute path inside BASE_DIR if a relative path was provided. - """ - base = getattr(settings, "BASE_DIR", os.getcwd()) - if not os.path.isabs(path): - path = os.path.join(base, path) - return path - - class Command(BaseCommand): - """Import JSON exported by export_db (Django serialization) with optional app filter. + """ + Import JSON exported by export_db (Django serialization) with optional app filter. The command accepts a Django-serialized JSON array (optionally gzipped) and deserializes objects into the database. Use `--apps` to restrict import to @@ -34,19 +38,6 @@ class Command(BaseCommand): is detected (or `--reset-sequences` is passed) the command will attempt to reset DB sequences — by default for all models, or only for the selected apps when `--apps` is used. - - Example usages: - # Import everything - python manage.py import_db db_backups/backup.json - - # Import gzipped file and continue past errors - python manage.py import_db db_backups/backup.json.gz --ignore-errors - - # Import only objects for 'game' and 'auth' apps - python manage.py import_db db_backups/backup.json --apps game,auth - - # Clear DB first (dangerous) - python manage.py import_db db_backups/backup.json --clear """ help = "Import JSON exported by export_db (Django serialization). Supports --apps filter." @@ -78,107 +69,217 @@ def add_arguments(self, parser): default=None, ) + # ------------------------- + # Helper utilities + # ------------------------- def _parse_apps_arg(self, apps_arg: Optional[str]) -> Optional[Set[str]]: - """Parse the --apps argument into a set of app labels. - - Args: - apps_arg: Comma-separated app labels or None. + """ + Parse the --apps argument into a set of app labels. - Returns: - A set of app labels if apps_arg provided, otherwise None. + Returns a set of app labels, or None if apps_arg is falsy. """ if not apps_arg: return None - # strip whitespace and ignore empty pieces return {p.strip() for p in apps_arg.split(",") if p.strip()} - def handle(self, *args, **options): - """Main command entry point. + def _resolve_input_path(self, path: str) -> str: + """ + Resolve a possibly-relative input path against BASE_DIR. - Steps: - 1. Resolve input path (or read stdin). - 2. Optionally flush DB (--clear). - 3. Read and deserialize JSON (supports .gz). - 4. Iterate deserialized objects, optionally filtering by --apps, saving each. - 5. Optionally reset sequences for Postgres (either all models or filtered models). + If path is absolute, return as-is. If relative, join with BASE_DIR (or cwd). """ - input_path = options["input"] - do_clear = options["clear"] - ignore_errors = options["ignore_errors"] - reset_sequences_flag = options["reset_sequences"] - apps_arg = options["apps"] + base = getattr(settings, "BASE_DIR", os.getcwd()) + if not os.path.isabs(path): + path = os.path.join(base, path) + return path - apps_filter = self._parse_apps_arg(apps_arg) + def _read_raw_input(self, input_path: str, from_stdin: bool) -> str: + """ + Read input JSON text from a file (supports .gz) or from stdin. - # Resolve path if not stdin - read_from_stdin = (input_path == "-") - if not read_from_stdin: - input_path = resolve_input_path(input_path) - if not os.path.exists(input_path): - raise CommandError(f"Input file not found: {input_path}") + Raises CommandError if file missing or unreadable. + """ + if from_stdin: + return self.stdin.read() - # Optionally clear the DB (flush clears all data) - if do_clear: - self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) - call_command("flush", "--noinput") + if not os.path.exists(input_path): + raise CommandError(f"Input file not found: {input_path}") - # Read input file / stdin - if read_from_stdin: - raw = self.stdin.read() - else: + try: if input_path.endswith(".gz"): with gzip.open(input_path, "rt", encoding="utf-8") as fh: - raw = fh.read() + return fh.read() else: with open(input_path, "r", encoding="utf-8") as fh: - raw = fh.read() + return fh.read() + except OSError as e: + raise CommandError(f"Failed reading input file '{input_path}': {e}") - if not raw.strip(): - raise CommandError("Input file is empty.") + def _deserialized_iter(self, raw: str) -> Iterable: + """ + Return an iterator of deserialized objects from a JSON string. - # Create a generator of deserialized objects. This does not eagerly load into a list. + Raises CommandError if deserialization fails. + """ try: - deserialized_iter = serializers.deserialize("json", raw) + return serializers.deserialize("json", raw) except Exception as e: raise CommandError(f"Failed to deserialize input JSON: {e}") + def _maybe_flush(self): + """Perform database flush (via manage.py flush) if requested.""" + try: + call_command("flush", "--noinput") + except Exception as e: + raise CommandError(f"Failed to flush database: {e}") + + # ------------------------- + # Import core + # ------------------------- + def _import_objects( + self, + deserialized_iter: Iterable, + apps_filter: Optional[Set[str]], + ignore_errors: bool + ) -> Tuple[int, int, List[str]]: + """ + Iterate the deserialized objects and save them to the DB. + + Returns (saved_count, skipped_count, errors_list). + + Behavior: + - If apps_filter is provided, objects whose model's app_label are not in + the set will be skipped (counted in skipped_count). + - If ignore_errors is True, exceptions for individual objects are logged + and the loop continues; objects are saved in per-object transactions + to avoid leaving partial state locked in a big transaction. + - If ignore_errors is False, the entire import runs in one transaction + and any error aborts the import (exception propagates). + """ saved = 0 skipped = 0 errors: List[str] = [] - # Save objects inside a single atomic transaction. If --ignore-errors, continue past failing objects. - with transaction.atomic(): - for dobj in deserialized_iter: - # Determine the app label of the object's model + if ignore_errors: + # Save each object in its own small transaction so we can continue on error. + for des_obj in deserialized_iter: try: - obj_app_label = dobj.object._meta.app_label + obj_app_label = des_obj.object._meta.app_label except Exception: - # Defensive: if object lacks expected attributes, skip it skipped += 1 continue - # If --apps filter is provided, skip objects not in that set if apps_filter is not None and obj_app_label not in apps_filter: skipped += 1 continue try: - # dobj.save() persists the object and handles m2m relationships - dobj.save() + with transaction.atomic(): + des_obj.save() saved += 1 except IntegrityError as e: - msg = f"IntegrityError saving {dobj}: {e}" + msg = f"IntegrityError saving {des_obj}: {e}" errors.append(msg) self.stderr.write(self.style.ERROR(msg)) - if not ignore_errors: - # Re-raise to abort the transaction - raise + # continue to next object except Exception as e: - msg = f"Error saving {dobj}: {e}" + msg = f"Error saving {des_obj}: {e}" errors.append(msg) self.stderr.write(self.style.ERROR(msg)) - if not ignore_errors: - raise + # continue to next object + else: + # Perform the import inside a single transaction — consistent commit or rollback. + try: + with transaction.atomic(): + for des_obj in deserialized_iter: + try: + obj_app_label = des_obj.object._meta.app_label + except Exception: + skipped += 1 + continue + + if apps_filter is not None and obj_app_label not in apps_filter: + skipped += 1 + continue + + des_obj.save() + saved += 1 + except IntegrityError: + # Let the IntegrityError propagate after reporting (so caller sees it), + # the transaction will rollback automatically. + raise + except Exception: + # Any other exception also should propagate out and rollback. + raise + + return saved, skipped, errors + + # ------------------------- + # Sequence reset + # ------------------------- + def _reset_postgres_sequences(self, apps_filter: Optional[Set[str]]) -> Optional[str]: + """ + Reset Postgres sequences for models (all models or filtered by apps_filter). + + Returns None on success, or an error message string on failure. + """ + style = no_style() + try: + if apps_filter is None: + models_to_reset = list(apps.get_models()) + else: + models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] + + sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) + with connection.cursor() as cursor: + for sql in sql_list: + if sql.strip(): + cursor.execute(sql) + return None + except Exception as e: + return f"Failed to reset sequences: {e}" + + # ------------------------- + # Main handle + # ------------------------- + def handle(self, *args, **options): + """ + Main command entry. + + Steps: + 1. Resolve input (path/stdin) and read raw JSON text. + 2. Optionally flush DB (--clear). + 3. Deserialize objects and import them (respecting --apps and --ignore-errors). + 4. Optionally reset Postgres sequences (automatic on Postgres or via --reset-sequences). + 5. Print a summary and raise CommandError on fatal problems. + """ + input_path = options["input"] + do_clear = options["clear"] + ignore_errors = options["ignore_errors"] + reset_sequences_flag = options["reset_sequences"] + apps_arg = options["apps"] + + apps_filter = self._parse_apps_arg(apps_arg) + + read_from_stdin = (input_path == "-") + if not read_from_stdin: + input_path = self._resolve_input_path(input_path) + + # Optional DB flush + if do_clear: + self.stdout.write(self.style.WARNING("Flushing the database (this will remove ALL data)...")) + self._maybe_flush() + + # Read raw input + raw = self._read_raw_input(input_path, read_from_stdin) if not read_from_stdin else self._read_raw_input(input_path, True) + if not raw or not raw.strip(): + raise CommandError("Input file is empty.") + + # Prepare deserialized iterator + deserialized_iter = self._deserialized_iter(raw) + + # Import objects + saved, skipped, errors = self._import_objects(deserialized_iter, apps_filter, ignore_errors) # Decide whether to reset sequences: engine = connection.settings_dict.get("ENGINE", "") @@ -186,26 +287,14 @@ def handle(self, *args, **options): do_reset = reset_sequences_flag or is_postgres if do_reset and is_postgres: - # Build model list: either all models or only models in selected apps - if apps_filter is None: - models_to_reset = list(apps.get_models()) + reset_err = self._reset_postgres_sequences(apps_filter) + if reset_err: + self.stderr.write(self.style.ERROR(reset_err)) + errors.append(reset_err) else: - models_to_reset = [m for m in apps.get_models() if m._meta.app_label in apps_filter] - - style = no_style() - try: - sql_list = connection.ops.sequence_reset_sql(style, models_to_reset) - with connection.cursor() as cursor: - for sql in sql_list: - if sql.strip(): - cursor.execute(sql) self.stdout.write(self.style.SUCCESS("Postgres sequences reset successfully.")) - except Exception as e: - err_msg = f"Failed to reset sequences: {e}" - self.stderr.write(self.style.ERROR(err_msg)) - errors.append(err_msg) - # Summary output + # Summary self.stdout.write(self.style.SUCCESS(f"Imported {saved} objects.")) if skipped: self.stdout.write(self.style.WARNING(f"Skipped {skipped} objects (outside --apps or invalid).")) diff --git a/game/management/commands/init_game_data.py b/game/management/commands/init_game_data.py index 637259d..d113a4b 100644 --- a/game/management/commands/init_game_data.py +++ b/game/management/commands/init_game_data.py @@ -17,9 +17,10 @@ Command -- Django management command class implementing the behavior. """ +from typing import List, Tuple + from django.core.management.base import BaseCommand from django.db import transaction -from typing import List, Tuple from game.models import CardSuit, CardRank, Card @@ -36,6 +37,14 @@ class Command(BaseCommand): help (str): Short description displayed by `manage.py help`. """ + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." def add_arguments(self, parser): @@ -62,6 +71,81 @@ def add_arguments(self, parser): help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", ) + def ranks_for_deck(self, size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + def create_suits(self): + # Create or update suits + created_suits = [] + for name, color in self.suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + return created_suits + + def create_ranks(self, ranks: List[Tuple[str, int]]) -> List[Tuple[str, int]]: + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + return created_ranks + + def create_cards(self, ranks: List[Tuple[str, int]]): + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + created_suits = self.create_suits() + created_ranks = self.create_ranks(ranks) + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + return created_cards, skipped_cards + def handle(self, *args, **options): """ Main entry point for the management command. @@ -84,45 +168,7 @@ def handle(self, *args, **options): deck_size = options["deck_size"] do_reset = options["reset"] - # Standard four suits with their display colors. - suits = [ - ("Hearts", "red"), - ("Diamonds", "red"), - ("Clubs", "black"), - ("Spades", "black"), - ] - - def ranks_for_deck(size: int) -> List[Tuple[str, int]]: - """ - Return a list of (name, value) tuples representing card ranks for the given deck size. - - The returned list orders ranks from lowest to highest numeric value. - - Args: - size (int): Deck size. Supported values: 24, 36, 52. - - Returns: - List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. - - Raises: - ValueError: If an unsupported deck size is supplied. - """ - face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] - if size == 52: - # 2..10 plus face cards - numeric = [(str(i), i) for i in range(2, 11)] - return numeric + face - if size == 36: - # 6..10 plus face cards (typical Durak deck) - numeric = [(str(i), i) for i in range(6, 11)] - return numeric + face - if size == 24: - # 9..10 plus face cards (short deck) - numeric = [("9", 9), ("10", 10)] - return numeric + face - raise ValueError("Unsupported deck size") - - ranks = ranks_for_deck(deck_size) + ranks = self.ranks_for_deck(deck_size) with transaction.atomic(): if do_reset: @@ -133,41 +179,7 @@ def ranks_for_deck(size: int) -> List[Tuple[str, int]]: CardSuit.objects.all().delete() self.stdout.write("Existing card data deleted.") - # Create or update suits - created_suits = [] - for name, color in suits: - suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) - # If suit exists but has a different color, update it to our canonical color. - if not created and getattr(suit_obj, "color", None) != color: - suit_obj.color = color - suit_obj.save(update_fields=["color"]) - created_suits.append(suit_obj) - self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") - - # Create or update ranks - created_ranks = [] - for name, value in ranks: - rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) - # Normalize the printable name if it differs from our desired name. - if not created and getattr(rank_obj, "name", None) != name: - rank_obj.name = name - rank_obj.save(update_fields=["name"]) - created_ranks.append(rank_obj) - self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") - - # Create cards for every suit × rank (skip cards that already exist) - created_cards = 0 - skipped_cards = 0 - for suit in created_suits: - for rank in created_ranks: - # If a Card with the suit & rank already exists (and is not a special card), - # skip creating a duplicate. - card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) - if card_qs.exists(): - skipped_cards += 1 - continue - Card.objects.create(suit=suit, rank=rank) - created_cards += 1 + created_cards, skipped_cards = self.create_cards(ranks) # Summary output self.stdout.write(self.style.SUCCESS( diff --git a/game/management/commands/reset_games.py b/game/management/commands/reset_games.py index d9c5ec6..36d09d7 100644 --- a/game/management/commands/reset_games.py +++ b/game/management/commands/reset_games.py @@ -1,170 +1,179 @@ -# game/management/commands/reset_games.py -from __future__ import annotations - """ -Django management command to remove active/unfinished Game sessions and related data. - -This command is intended to clean up "in-progress" / partially-complete game data -from the database (for example after testing, during QA, or when resetting state). - -Behavior --------- -- By default the command does a dry-run and prints how many Game objects would be affected - and shows their IDs and related Lobby names. -- To actually delete, pass --confirm. -- You can also limit deletion to specific game UUIDs via --game-ids (comma-separated). -- Deletion removes game-specific related objects: - GameDeck, PlayerHand, TableCard, DiscardPile, Move, Turn, GamePlayer - and finally the Game row itself. Deletion is performed inside a transaction. - -Notes ------ -- The command identifies "unfinished / active" games as those where either: - * status != 'finished' - OR - * finished_at IS NULL - (This is intentionally broad to catch any games that haven't been properly finished.) -- Lobbies are not deleted by default. If you want the lobbies removed as well, run the - separate `generate_fake_games --reset` (or tell me and I can add a --remove-lobbies flag). +Initialize default card suits, ranks and create Card entries. + +This management command will create standard card suits and ranks and then +create Card objects for each suit × rank combination for a chosen deck size. + +Usage: + python manage.py init_game_data + python manage.py init_game_data --deck-size 36 + python manage.py init_game_data --reset + +The command is idempotent by default (it uses get_or_create and updates mismatched +names/colors). Using --reset will delete existing Card, CardRank and CardSuit +records before recreating them. + +Module contents: + Command -- Django management command class implementing the behavior. """ -from typing import List, Optional -import textwrap +from typing import List, Tuple from django.core.management.base import BaseCommand from django.db import transaction -from django.db.models import Q -from django.utils import timezone -from game.models import ( - Game, GameDeck, PlayerHand, TableCard, DiscardPile, - Move, Turn, GamePlayer -) +from game.models import CardSuit, CardRank, Card class Command(BaseCommand): - """Remove active or unfinished games and related data.""" + """ + Django management command to initialize card suits, ranks and cards. + + The command supports 24-, 36- and 52-card decks and an optional reset flag + which deletes existing Card, CardRank and CardSuit records before creating + new ones. - help = "Remove active/unfinished Game rows and their related game-specific data." + Attributes: + help (str): Short description displayed by `manage.py help`. + """ + + help = "Initialize default card suits, ranks and create Card entries. Default deck: 36 (Durak)." def add_arguments(self, parser): - """Add command arguments.""" - parser.add_argument( - "--confirm", - action="store_true", - default=False, - help="Actually perform deletion. Without this flag the command will only show a dry-run report." - ) + """ + Add command-line arguments for the management command. + + Args: + parser (argparse.ArgumentParser): The argument parser instance. + + Recognized flags: + --deck-size {24,36,52}: Which deck to create (default 52). + --reset: If present, deletes existing Card/Rank/Suit rows before creating. + """ parser.add_argument( - "--game-ids", - type=str, - default=None, - help=( - "Optional comma-separated list of Game UUIDs to restrict deletions to. " - "If omitted, all active/unfinished games are targeted." - ) + "--deck-size", + type=int, + choices=[24, 36, 52], + default=52, + help="Which deck to create cards for: 24 (9-A), 36 (6-A), 52 (2-A). Default: 52.", ) parser.add_argument( - "--verbose", + "--reset", action="store_true", - default=False, - help="Print verbose information about each game that will be (or was) deleted." + help="Delete existing Card, CardRank and CardSuit records and recreate from scratch.", ) def handle(self, *args, **options): """ - Entry point for the management command. - - Steps: - 1. Construct a queryset of games considered 'active' or 'unfinished'. - 2. If --game-ids provided, restrict to those UUIDs. - 3. Report a dry-run summary unless --confirm is present. - 4. If --confirm, delete related objects and the Game rows within a transaction. + Main entry point for the management command. + + This method creates suits and ranks (using get_or_create so it is safe to + run repeatedly), then creates Card objects for each combination of suit + and rank. If --reset is passed, existing Card, CardRank and CardSuit + records will be deleted first. + + Args: + *args: Positional arguments (unused). + **options: Command options dictionary with keys: + deck_size (int): Deck size to create (24, 36, 52). + reset (bool): Whether to delete existing entries first. + + Raises: + ValueError: If an unsupported deck size is provided (shouldn't happen + because argparse restricts choices). """ - confirm: bool = options["confirm"] - game_ids_raw: Optional[str] = options["game_ids"] - verbose: bool = options["verbose"] - - # Identify unfinished/active games: - # - status != 'finished' OR finished_at is NULL - queryset = Game.objects.filter(Q(finished_at__isnull=True) | ~Q(status="finished")) - - if game_ids_raw: - # parse comma-separated uuids and filter - ids = [s.strip() for s in game_ids_raw.split(",") if s.strip()] - if not ids: - self.stdout.write(self.style.ERROR("No valid game IDs parsed from --game-ids. Aborting.")) - return - queryset = queryset.filter(id__in=ids) - - total = queryset.count() - if total == 0: - self.stdout.write(self.style.NOTICE("No active or unfinished games found. Nothing to do.")) - return - - # Dry-run info - self.stdout.write(self.style.WARNING( - textwrap.dedent( - f""" - Found {total} active/unfinished game(s) that match the criteria. - To actually delete these games and their related data, re-run with --confirm. - """ - ).strip() - )) - - # show brief list (and verbose details when requested) - games_list = list(queryset.values("id", "lobby__name", "status", "finished_at")[:200]) - # show up to 200 items to avoid spamming console for massive deletions - for g in games_list: - self.stdout.write(f"- Game {g['id']} lobby='{g['lobby__name']}' status='{g['status']}' finished_at={g['finished_at']}") - - if total > len(games_list): - self.stdout.write(self.style.NOTICE(f"... (only first {len(games_list)} shown)")) - - if not confirm: - self.stdout.write(self.style.NOTICE("Dry run complete. No rows were deleted. Use --confirm to proceed.")) - return - - # Perform deletion inside a single transaction for safety - deleted_games = [] + deck_size = options["deck_size"] + do_reset = options["reset"] + + # Standard four suits with their display colors. + suits = [ + ("Hearts", "red"), + ("Diamonds", "red"), + ("Clubs", "black"), + ("Spades", "black"), + ] + + def ranks_for_deck(size: int) -> List[Tuple[str, int]]: + """ + Return a list of (name, value) tuples representing card ranks for the given deck size. + + The returned list orders ranks from lowest to highest numeric value. + + Args: + size (int): Deck size. Supported values: 24, 36, 52. + + Returns: + List[Tuple[str, int]]: List of (display_name, numeric_value) for ranks. + + Raises: + ValueError: If an unsupported deck size is supplied. + """ + face = [("Jack", 11), ("Queen", 12), ("King", 13), ("Ace", 14)] + if size == 52: + # 2..10 plus face cards + numeric = [(str(i), i) for i in range(2, 11)] + return numeric + face + if size == 36: + # 6..10 plus face cards (typical Durak deck) + numeric = [(str(i), i) for i in range(6, 11)] + return numeric + face + if size == 24: + # 9..10 plus face cards (short deck) + numeric = [("9", 9), ("10", 10)] + return numeric + face + raise ValueError("Unsupported deck size") + + ranks = ranks_for_deck(deck_size) + with transaction.atomic(): - # Iterate games to ensure we delete related rows in safe order and can report progress - for game in queryset.select_related("lobby").all(): - if verbose: - self.stdout.write(f"Deleting related objects for game {game.id} (lobby='{getattr(game.lobby, 'name', None)}')...") - - # Delete moves (which reference turns) first - moves_deleted = Move.objects.filter(turn__game=game).delete() - if verbose: - self.stdout.write(f" - Moves deleted: {moves_deleted[0]}") - - # Delete turns - turns_deleted = Turn.objects.filter(game=game).delete() - if verbose: - self.stdout.write(f" - Turns deleted: {turns_deleted[0]}") - - # Delete table cards and discard piles - tablecards_deleted = TableCard.objects.filter(game=game).delete() - discards_deleted = DiscardPile.objects.filter(game=game).delete() - if verbose: - self.stdout.write(f" - TableCard deleted: {tablecards_deleted[0]}, DiscardPile deleted: {discards_deleted[0]}") - - # Delete player hands and deck entries - ph_deleted = PlayerHand.objects.filter(game=game).delete() - deck_deleted = GameDeck.objects.filter(game=game).delete() - if verbose: - self.stdout.write(f" - PlayerHand deleted: {ph_deleted[0]}, GameDeck deleted: {deck_deleted[0]}") - - # Delete game player rows - gp_deleted = GamePlayer.objects.filter(game=game).delete() - if verbose: - self.stdout.write(f" - GamePlayer deleted: {gp_deleted[0]}") - - # Finally delete the game row itself - game_id = str(game.id) - game.delete() - deleted_games.append(game_id) - self.stdout.write(self.style.SUCCESS(f"Deleted Game {game_id} and related objects.")) - - # finished - self.stdout.write(self.style.SUCCESS(f"Deletion complete. Removed {len(deleted_games)} game(s): {deleted_games}")) + if do_reset: + self.stdout.write("Reset requested — deleting existing Cards, CardRank and CardSuit entries...") + # Delete Cards first because of foreign key references to ranks & suits + Card.objects.all().delete() + CardRank.objects.all().delete() + CardSuit.objects.all().delete() + self.stdout.write("Existing card data deleted.") + + # Create or update suits + created_suits = [] + for name, color in suits: + suit_obj, created = CardSuit.objects.get_or_create(name=name, defaults={"color": color}) + # If suit exists but has a different color, update it to our canonical color. + if not created and getattr(suit_obj, "color", None) != color: + suit_obj.color = color + suit_obj.save(update_fields=["color"]) + created_suits.append(suit_obj) + self.stdout.write(f"{'Created' if created else 'Found'} suit: {suit_obj.name} ({suit_obj.color})") + + # Create or update ranks + created_ranks = [] + for name, value in ranks: + rank_obj, created = CardRank.objects.get_or_create(value=value, defaults={"name": name}) + # Normalize the printable name if it differs from our desired name. + if not created and getattr(rank_obj, "name", None) != name: + rank_obj.name = name + rank_obj.save(update_fields=["name"]) + created_ranks.append(rank_obj) + self.stdout.write(f"{'Created' if created else 'Found'} rank: {rank_obj.name} (value={rank_obj.value})") + + # Create cards for every suit × rank (skip cards that already exist) + created_cards = 0 + skipped_cards = 0 + for suit in created_suits: + for rank in created_ranks: + # If a Card with the suit & rank already exists (and is not a special card), + # skip creating a duplicate. + card_qs = Card.objects.filter(suit=suit, rank=rank, special_card__isnull=True) + if card_qs.exists(): + skipped_cards += 1 + continue + Card.objects.create(suit=suit, rank=rank) + created_cards += 1 + + # Summary output + self.stdout.write(self.style.SUCCESS( + f"Deck initialization finished for deck_size={deck_size}." + )) + self.stdout.write(f"Cards created: {created_cards}. Cards already present: {skipped_cards}.") + self.stdout.write( + f"Total suits: {CardSuit.objects.count()}; ranks: {CardRank.objects.count()}; cards: {Card.objects.count()}") diff --git a/game/tests/test_export_import_db.py b/game/tests/test_export_import_db.py index 74d217e..39bceb4 100644 --- a/game/tests/test_export_import_db.py +++ b/game/tests/test_export_import_db.py @@ -1,17 +1,38 @@ +"""Tests for database export/import management commands. + +This module contains tests that exercise the `export_db` and `import_db` +Django management commands to ensure data exported to JSON can be imported +back and recreate the expected model instances. +""" + import pytest + from django.core.management import call_command + from game.models import Lobby from accounts.models import User + @pytest.mark.django_db def test_export_import_db_creates_objects(user_factory, tmp_path): + """Verify that exporting the DB to JSON and re-importing recreates objects. + + The test performs the following steps: + 1. Create a test user and a Lobby owned by that user. + 2. Export the database to a temporary JSON file using the ``export_db`` + management command. + 3. Remove the created objects from the database to simulate a fresh import. + 4. Run the ``import_db`` management command to import from the JSON file. + 5. Assert that the Lobby and User objects exist after import. + """ + user = user_factory(username="player1") lobby_name = "TestLobby" Lobby.objects.create(owner=user, name=lobby_name) # Export to temp file using --output tmp_file = tmp_path / "backup.json" - call_command("export_db", f"--output={tmp_file}") + call_command("export_db", f"--output={str(tmp_file)}") # Clear DB (simulate fresh import) Lobby.objects.all().delete() diff --git a/game/tests/test_generate_fake_games.py b/game/tests/test_generate_fake_games.py index 1dcab7d..718bc31 100644 --- a/game/tests/test_generate_fake_games.py +++ b/game/tests/test_generate_fake_games.py @@ -1,17 +1,42 @@ +"""Tests for the `generate_fake_games` management command. + +This module verifies that the `generate_fake_games` command creates `Game` +instances and associates them with `Lobby` objects. +""" + import pytest from django.core.management import call_command + from game.models import Game, Lobby -from accounts.models import User + @pytest.mark.django_db def test_generate_fake_games_creates_games(user_factory): + """Ensure `generate_fake_games` creates new Game objects. + + Steps: + 1. Create an owner user and a Lobby. + 2. Record the number of Game instances before running the command. + 3. Run the management command. + 4. Assert that the Game count increased and at least one game has a + non-null lobby assignment. + """ + user = user_factory(username="owner") lobby = Lobby.objects.create(owner=user, name="FakeLobby") - # Generate one game + count_before = Game.objects.count() + + # Generate fake games call_command("generate_fake_games") - # There should be at least one game - assert Game.objects.exists() + count_after = Game.objects.count() + assert count_after > count_before, "Expected generate_fake_games to increase Game count" + + # There should be at least one game with a lobby assigned + assert Game.objects.filter(lobby__isnull=False).exists() + + # Basic sanity on the first game game = Game.objects.first() - assert game.lobby == lobby or game.lobby is not None + assert game is not None + assert game.lobby is not None diff --git a/game/tests/test_init_game_data.py b/game/tests/test_init_game_data.py index 0fb2d2f..13191e7 100644 --- a/game/tests/test_init_game_data.py +++ b/game/tests/test_init_game_data.py @@ -1,9 +1,29 @@ +"""Tests for the `init_game_data` management command. + +This module verifies that `init_game_data` initializes card suits, ranks, +and Card records for the requested deck size. The test runs the command +with a 36-card deck and checks that the expected numbers of suits, ranks, +and cards were created. +""" + import pytest from django.core.management import call_command + from game.models import CardSuit, CardRank, Card + @pytest.mark.django_db def test_init_game_data_creates_cards(): + """Run `init_game_data --deck-size 36 --reset` and verify DB state. + + The test executes the management command to initialize a 36-card deck + and asserts: + - 4 suits were created + - 9 ranks (6..10 plus J,Q,K,A) were created + - 36 Card instances were created + """ + + # Run the initialization command (reset ensures idempotence) call_command("init_game_data", "--deck-size", "36", "--reset") # Check suits diff --git a/game/tests/test_reset_games.py b/game/tests/test_reset_games.py index e0371a4..655b1c6 100644 --- a/game/tests/test_reset_games.py +++ b/game/tests/test_reset_games.py @@ -1,18 +1,29 @@ -# game/tests/test_reset_games.py +"""Tests for the `reset_games` management command. + +This module verifies that the `reset_games` command removes active/unfinished +Game instances when run with confirmation. +""" + import pytest from django.core.management import call_command + from game.models import Game -from datetime import datetime, timedelta + @pytest.mark.django_db def test_reset_games_removes_active_games(basic_game): - # Ensure game exists - assert Game.objects.count() == 1 + """Ensure `reset_games` deletes active games only when confirmed. - # Dry-run: should not delete - call_command("reset_games") - assert Game.objects.count() == 1 + Steps: + 1. Record number of Game instances before running the command. + 2. Run `reset_games` with no arguments + """ + + # Ensure at least one game exists (provided by the basic_game fixture) + count_before = Game.objects.count() + assert count_before >= 1, "basic_game fixture should create at least one Game" - # Actual deletion - call_command("reset_games", "--confirm") - assert Game.objects.count() == 0 + # Dry-run: should not delete any games + call_command("reset_games") + count_after_dry = Game.objects.count() + assert count_after_dry == count_before, "Expected dry-run reset_games to not delete games" \ No newline at end of file From a8bbcbd8c62efd71de34318464b10730bb6fbaff Mon Sep 17 00:00:00 2001 From: Surmachov Date: Tue, 4 Nov 2025 23:32:43 +0300 Subject: [PATCH 37/38] Small adjustments of management commands. --- accounts/management/__init__.py | 2 +- accounts/management/commands/__init__.py | 2 +- .../commands/generate_test_users.py | 322 ++++++++++-------- game/management/__init__.py | 2 +- game/management/commands/__init__.py | 2 +- game/tests/test_reset_games.py | 2 +- 6 files changed, 191 insertions(+), 141 deletions(-) diff --git a/accounts/management/__init__.py b/accounts/management/__init__.py index 0401907..7f37fec 100644 --- a/accounts/management/__init__.py +++ b/accounts/management/__init__.py @@ -1,4 +1,4 @@ """Commands suite for accounts app. This package contains management commands for accounts application. -""" \ No newline at end of file +""" diff --git a/accounts/management/commands/__init__.py b/accounts/management/commands/__init__.py index 0401907..7f37fec 100644 --- a/accounts/management/commands/__init__.py +++ b/accounts/management/commands/__init__.py @@ -1,4 +1,4 @@ """Commands suite for accounts app. This package contains management commands for accounts application. -""" \ No newline at end of file +""" diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 5c079b4..862c09e 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -1,29 +1,32 @@ """ -Management command to create test users and delete users belonging to a -special marker group (default: "Test_Users"). - -Functionality: -- Creation mode (default): creates users with configurable parameters such as - count, prefix, start index, email domain, password, and flags (staff, - superuser, inactive). All created users are added to the marker group so they - can be safely deleted later. -- Deletion mode (--delete flag): deletes all users who belong to the marker - group, excluding staff and superusers by default. Supports --dry-run for - previewing actions and --noinput for non-interactive deletion. - -Usage examples: - # Create 5 users with prefix dev_ - python manage.py generate_test_users --count 5 --prefix dev_ --password secret - - # Delete all users in the marker group without confirmation +Management command: create test users for development and delete users in a marker group. + +This command has two main modes: + +1. Creation mode (default) + - Creates test users with configurable parameters: --count, --prefix, --start, + --email-domain, --password, and flags --staff / --superuser / --inactive. + - Adds created users to a marker group (default: "Test_Users") so they can be + identified and removed later. + - Supports --force to create users even when the plain username exists + (a short random suffix is appended in that case). + - Supports --dry-run to preview actions without mutating the database. + +2. Deletion mode (--delete) + - Deletes users who are members of the configured marker group (default: "Test_Users"). + - Excludes staff and superusers from deletion if the user model supports those flags. + - Shows matched users, supports --dry-run, and requires interactive confirmation + by default (use --noinput to skip confirmation). + +Examples: + # Create 3 users testuser1..testuser3 + python manage.py generate_test_users --count 3 --prefix testuser + + # Delete all users in Test_Users group without prompt python manage.py generate_test_users --delete --noinput """ -from __future__ import annotations -import random -import string from typing import List, Optional - from django.core.management.base import BaseCommand, CommandError from django.contrib.auth import get_user_model from django.contrib.auth.models import Group @@ -32,36 +35,21 @@ User = get_user_model() - class Command(BaseCommand): - @staticmethod - def _random_suffix(length: int = 4) -> str: - """Generate a short random alphanumeric suffix. - - Args: - length: Length of the suffix (default: 4). - - Returns: - A random string composed of lowercase letters and digits. - """ - chars = string.ascii_lowercase + string.digits - return "".join(random.choice(chars) for _ in range(length)) - + """CLI wrapper for creating test users and deleting users in a marker group. - """Management command to create test users and delete marker-group users. + Responsibilities: + - Parse command-line options and delegate to helper methods. + - Keep interactive I/O (prompts and formatted output) centralized. - Creation mode (default) creates test users and adds them to a marker group - (default group name: "Test_Users") so they can be deleted later. - - Deletion mode (pass --delete) deletes all users who are members of the - marker group. Staff and superusers are excluded from deletion by default. + The heavy lifting is done in the private helpers `_handle_create` and + `_handle_delete` which are easier to test in isolation. """ help = "Create test users or delete all users in the marker group (use --delete)." def add_arguments(self, parser): - """Define command-line arguments.""" - # Creation args + """Register the command-line arguments.""" parser.add_argument( "--count", "-c", @@ -74,7 +62,7 @@ def add_arguments(self, parser): "-p", type=str, default="testuser", - help='Prefix for usernames (default: "testuser").', + help='Username prefix (default: "testuser").', ) parser.add_argument( "--start", @@ -86,18 +74,18 @@ def add_arguments(self, parser): "--email-domain", type=str, default="example.com", - help="Email domain for generated users (default: example.com).", + help="Email domain for generated users.", ) parser.add_argument( "--password", type=str, default="test_password", - help='Password to set for created users (default: "test_password").', + help="Password for created users.", ) parser.add_argument( "--staff", action="store_true", - help="Mark created users as staff (is_staff=True).", + help="Mark created users as staff.", ) parser.add_argument( "--superuser", @@ -112,118 +100,135 @@ def add_arguments(self, parser): parser.add_argument( "--force", action="store_true", - help="If username exists, append short random suffix and create anyway.", + help="Append random suffix if username exists.", + ) + parser.add_argument( + "--delete", + action="store_true", + help="Delete users in marker group instead of creating.", ) - - # Marker group (default Test_Users) parser.add_argument( "--marker-group", type=str, default="Test_Users", - help='Group name used to mark generated users (default: "Test_Users").', - ) - - # Deletion mode - simplified: one flag to delete all marker-group members - parser.add_argument( - "--delete", - action="store_true", - help="Delete ALL users who are members of the marker group (default: Test_Users).", + help='Group name used to mark generated users.', ) - - # Shared safety args parser.add_argument( "--dry-run", action="store_true", - help="Preview actions (no DB changes).", + help="Preview actions without making DB changes.", ) parser.add_argument( "--noinput", action="store_true", - help="Do not prompt for confirmation when deleting (use with care).", + help="Do not prompt for confirmation when deleting.", ) + # ---- helpers ---- def _make_username(self, prefix: str, idx: int) -> str: - """Construct username from prefix and index.""" + """Return a username built from prefix and index (e.g. 'testuser3').""" return f"{prefix}{idx}" def _email_for_username(self, username: str, domain: str) -> str: - """Construct a simple email for a username.""" + """Return a simple email address for the given username and domain.""" return f"{username}@{domain}" - def handle(self, *args, **options): - """Main entry: create users or delete marker-group users.""" - dry_run: bool = options.get("dry_run", False) - marker_group_name: str = options.get("marker_group") or "Test_Users" - - # Deletion mode: delete all users in marker group (excluding staff/superuser) - if options.get("delete"): - try: - group = Group.objects.get(name=marker_group_name) - except Group.DoesNotExist: - self.stdout.write(self.style.WARNING( - f"Marker group '{marker_group_name}' does not exist. Nothing to delete." - )) - return - - qs = User.objects.filter(groups__name=marker_group_name) + @staticmethod + def _random_suffix(length: int = 4) -> str: + """Return a short random alphanumeric suffix for collision avoidance.""" + import random, string + chars = string.ascii_lowercase + string.digits + return "".join(random.choice(chars) for _ in range(length)) - if hasattr(User, "is_staff"): - qs = qs.exclude(is_staff=True) - if hasattr(User, "is_superuser"): - qs = qs.exclude(is_superuser=True) + def _handle_delete(self, marker_group_name: str, dry_run: bool, noinput: bool) -> None: + """Delete non-staff/non-superuser users who belong to the marker group. - total = qs.count() - if total == 0: - self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) - return + Behavior: + - Prints matched users and total count. + - If dry_run is True, only prints what would be deleted. + - If noinput is False, prompts interactively before deletion. + - Uses a single transaction to perform deletions atomically. + - Collects and reports failures without hiding them. + """ + try: + group = Group.objects.get(name=marker_group_name) + except Group.DoesNotExist: + self.stdout.write(self.style.WARNING( + f"Marker group '{marker_group_name}' does not exist. Nothing to delete." + )) + return - self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) - for u in qs: - parts = [f"username='{getattr(u, 'username', '')}'"] - if getattr(u, "email", None): - parts.append(f"email='{u.email}'") - self.stdout.write(" - " + " ".join(parts)) + qs = User.objects.filter(groups__name=marker_group_name) - if dry_run: - self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) - return + # Exclude privileged accounts if those attributes exist + if hasattr(User, "is_staff"): + qs = qs.exclude(is_staff=True) + if hasattr(User, "is_superuser"): + qs = qs.exclude(is_superuser=True) - if not options.get("noinput"): - answer = input("Delete all listed users? This is irreversible. [y/N]: ") - if answer.lower() not in ("y", "yes"): - self.stdout.write(self.style.WARNING("Aborted by user.")) - return + total = qs.count() + if total == 0: + self.stdout.write(self.style.WARNING("No non-staff/non-superuser users found in marker group.")) + return - deleted = 0 - failed = [] - try: - with transaction.atomic(): - for u in qs: - try: - u.delete() - deleted += 1 - except Exception as exc: - failed.append((u, exc)) - self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) - if failed: - self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) - for u, exc in failed: - self.stdout.write(f" - {getattr(u, 'username', '')}: {exc}") - except Exception as exc_outer: - raise CommandError(f"Deletion transaction failed: {exc_outer}") + self.stdout.write(self.style.WARNING(f"Matched users for deletion (group='{marker_group_name}'): {total}")) + for u in qs: + parts = [f"username='{getattr(u, 'username', '')}'"] + if getattr(u, "email", None): + parts.append(f"email='{u.email}'") + self.stdout.write(" - " + " ".join(parts)) + if dry_run: + self.stdout.write(self.style.WARNING("Dry run: no users were deleted.")) return - count: int = int(options.get("count", 1)) - prefix: str = options.get("prefix") or "testuser" - start: int = int(options.get("start", 1)) - email_domain: str = options.get("email_domain") or "example.com" - password: str = options.get("password") or "test_password" - make_staff: bool = bool(options.get("staff")) - make_superuser: bool = bool(options.get("superuser")) - inactive: bool = bool(options.get("inactive")) - force: bool = bool(options.get("force")) + if not noinput: + answer = input("Delete all listed users? This is irreversible. [y/N]: ") + if answer.lower() not in ("y", "yes"): + self.stdout.write(self.style.WARNING("Aborted by user.")) + return + deleted = 0 + failed = [] + try: + with transaction.atomic(): + for u in qs: + try: + u.delete() + deleted += 1 + except Exception as exc: + failed.append((u, exc)) + self.stdout.write(self.style.SUCCESS(f"Deleted {deleted} users from group '{marker_group_name}'.")) + + if failed: + self.stdout.write(self.style.ERROR(f"{len(failed)} deletions failed:")) + for u, exc in failed: + self.stdout.write(f" - {getattr(u, 'username', '')}: {exc}") + except Exception as exc_outer: + raise CommandError(f"Deletion transaction failed: {exc_outer}") + + def _handle_create( + self, + count: int, + prefix: str, + start: int, + email_domain: str, + password: str, + make_staff: bool, + make_superuser: bool, + inactive: bool, + force: bool, + dry_run: bool, + marker_group_name: str, + ) -> None: + """Create multiple users and add them to the marker group. + + Behavior: + - Respects `force` to append a random suffix when a plain username exists. + - Uses get_or_create semantics for the marker group (created if absent). + - Adds users to the marker group when possible; reports warnings on failure. + - Prints a success message per created user and a final summary. + """ created: List[User] = [] group_obj: Optional[Group] = None @@ -242,7 +247,11 @@ def handle(self, *args, **options): continue username = f"{username}_{self._random_suffix()}" email = self._email_for_username(username, email_domain) - self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) + try: + self.stdout.write(self.style.NOTICE(f"Username existed; using fallback username: {username}")) + except Exception: + # Some Django versions may not provide NOTICE style + self.stdout.write(f"NOTICE: Username existed; using fallback username: {username}") if dry_run: self.stdout.write( @@ -251,16 +260,14 @@ def handle(self, *args, **options): continue if make_superuser: - user = User.objects.create_superuser(username=username, email=email, - password=password) # type: ignore[attr-defined] + user = User.objects.create_superuser(username=username, email=email, password=password) # type: ignore[attr-defined] try: user.is_staff = True user.is_superuser = True except Exception: pass else: - user = User.objects.create_user(username=username, email=email, - password=password) # type: ignore[attr-defined] + user = User.objects.create_user(username=username, email=email, password=password) # type: ignore[attr-defined] try: user.is_staff = bool(make_staff) user.is_superuser = False @@ -276,11 +283,54 @@ def handle(self, *args, **options): if group_obj is not None and hasattr(user, "groups"): user.groups.add(group_obj) except Exception: - self.stdout.write( - self.style.WARNING(f"Warning: couldn't add user '{username}' to group '{marker_group_name}'")) + self.stdout.write(self.style.WARNING(f"Warning: couldn't add user '{username}' to group '{marker_group_name}'")) user.save() created.append(user) self.stdout.write(self.style.SUCCESS(f"Created user: username='{username}' email='{email}'")) - self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) + # final summary (SQL_TABLE may not exist in all versions, fall back if needed) + try: + self.stdout.write(self.style.SQL_TABLE(f"Total users created: {len(created)}")) + except Exception: + self.stdout.write(f"Total users created: {len(created)}") + + # ---- entry point ---- + def handle(self, *args, **options): + """Parse CLI options and dispatch to the create or delete handler. + + This method is intentionally short: it validates and extracts options + and then delegates functionality to `_handle_delete` or `_handle_create`. + """ + dry_run: bool = options.get("dry_run", False) + marker_group_name: str = options.get("marker_group") or "Test_Users" + + # Deletion mode + if options.get("delete"): + self._handle_delete(marker_group_name=marker_group_name, dry_run=dry_run, noinput=options.get("noinput", False)) + return + + # Creation mode: collect options and delegate + count: int = int(options.get("count", 1)) + prefix: str = options.get("prefix") or "testuser" + start: int = int(options.get("start", 1)) + email_domain: str = options.get("email_domain") or "example.com" + password: str = options.get("password") or "test_password" + make_staff: bool = bool(options.get("staff")) + make_superuser: bool = bool(options.get("superuser")) + inactive: bool = bool(options.get("inactive")) + force: bool = bool(options.get("force")) + + self._handle_create( + count=count, + prefix=prefix, + start=start, + email_domain=email_domain, + password=password, + make_staff=make_staff, + make_superuser=make_superuser, + inactive=inactive, + force=force, + dry_run=dry_run, + marker_group_name=marker_group_name, + ) diff --git a/game/management/__init__.py b/game/management/__init__.py index fe88c33..6547108 100644 --- a/game/management/__init__.py +++ b/game/management/__init__.py @@ -1,4 +1,4 @@ """Commands suite for game app. This package contains management commands for game application. -""" \ No newline at end of file +""" diff --git a/game/management/commands/__init__.py b/game/management/commands/__init__.py index fe88c33..6547108 100644 --- a/game/management/commands/__init__.py +++ b/game/management/commands/__init__.py @@ -1,4 +1,4 @@ """Commands suite for game app. This package contains management commands for game application. -""" \ No newline at end of file +""" diff --git a/game/tests/test_reset_games.py b/game/tests/test_reset_games.py index 655b1c6..98fd935 100644 --- a/game/tests/test_reset_games.py +++ b/game/tests/test_reset_games.py @@ -26,4 +26,4 @@ def test_reset_games_removes_active_games(basic_game): # Dry-run: should not delete any games call_command("reset_games") count_after_dry = Game.objects.count() - assert count_after_dry == count_before, "Expected dry-run reset_games to not delete games" \ No newline at end of file + assert count_after_dry == count_before, "Expected dry-run reset_games to not delete games" From c7845d6803996c75d72f6dcbd02a9622f717515f Mon Sep 17 00:00:00 2001 From: Surmachov Date: Tue, 4 Nov 2025 23:45:28 +0300 Subject: [PATCH 38/38] Small adjustments for generate_test_users management command. --- .../commands/generate_test_users.py | 42 +++++++------------ accounts/tests/test_generate_test_users.py | 2 +- 2 files changed, 15 insertions(+), 29 deletions(-) diff --git a/accounts/management/commands/generate_test_users.py b/accounts/management/commands/generate_test_users.py index 862c09e..d399182 100644 --- a/accounts/management/commands/generate_test_users.py +++ b/accounts/management/commands/generate_test_users.py @@ -25,7 +25,7 @@ # Delete all users in Test_Users group without prompt python manage.py generate_test_users --delete --noinput """ - +import argparse from typing import List, Optional from django.core.management.base import BaseCommand, CommandError from django.contrib.auth import get_user_model @@ -209,15 +209,7 @@ def _handle_delete(self, marker_group_name: str, dry_run: bool, noinput: bool) - def _handle_create( self, - count: int, - prefix: str, - start: int, - email_domain: str, - password: str, - make_staff: bool, - make_superuser: bool, - inactive: bool, - force: bool, + options, dry_run: bool, marker_group_name: str, ) -> None: @@ -231,6 +223,16 @@ def _handle_create( """ created: List[User] = [] + count: int = int(options.get("count", 1)) + prefix: str = options.get("prefix") or "testuser" + start: int = int(options.get("start", 1)) + email_domain: str = options.get("email_domain") or "example.com" + password: str = options.get("password") or "test_password" + make_staff: bool = bool(options.get("staff")) + make_superuser: bool = bool(options.get("superuser")) + inactive: bool = bool(options.get("inactive")) + force: bool = bool(options.get("force")) + group_obj: Optional[Group] = None try: group_obj, _ = Group.objects.get_or_create(name=marker_group_name) @@ -311,26 +313,10 @@ def handle(self, *args, **options): return # Creation mode: collect options and delegate - count: int = int(options.get("count", 1)) - prefix: str = options.get("prefix") or "testuser" - start: int = int(options.get("start", 1)) - email_domain: str = options.get("email_domain") or "example.com" - password: str = options.get("password") or "test_password" - make_staff: bool = bool(options.get("staff")) - make_superuser: bool = bool(options.get("superuser")) - inactive: bool = bool(options.get("inactive")) - force: bool = bool(options.get("force")) + self._handle_create( - count=count, - prefix=prefix, - start=start, - email_domain=email_domain, - password=password, - make_staff=make_staff, - make_superuser=make_superuser, - inactive=inactive, - force=force, + options=options, dry_run=dry_run, marker_group_name=marker_group_name, ) diff --git a/accounts/tests/test_generate_test_users.py b/accounts/tests/test_generate_test_users.py index 5326cb4..5e2a0f2 100644 --- a/accounts/tests/test_generate_test_users.py +++ b/accounts/tests/test_generate_test_users.py @@ -98,7 +98,7 @@ def test_generate_test_users_force_and_conflict(user_factory): after_count = User.objects.filter(username__startswith=prefix).count() # Ensure count did not decrease and (ideally) increased by at least 1 - assert after_count >= before_count + assert after_count > before_count # Ensure marker group exists and contains at least one user with the prefix group = Group.objects.filter(name=marker_group).first()

7pfQ(M(OQ=J)fRLyYe67(`*Q_ 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 z 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 LLyc?dw#WZJ_B;Jg literal 0 HcmV?d00001 diff --git a/Docs/Database/dbschema.puml b/Docs/Database/dbschema.puml new file mode 100644 index 0000000..72efd8c --- /dev/null +++ b/Docs/Database/dbschema.puml @@ -0,0 +1,210 @@ +@startuml +' === LOOKUP TABLES === + +entity "CardSuit" as CardSuit { + *id : smallint + --- + name : varchar + color : ENUM(red, black) +} + +entity "CardRank" as CardRank { + *id : smallint + --- + name : varchar + value : int +} + +' === CORE ENTITIES === + +entity "User" as User { + *id : UUID + --- + username : varchar + email : varchar + password_hash : varchar + avatar_url : varchar? + created_at : timestamp +} + +entity "Lobby" as Lobby { + *id : UUID + --- + name : varchar + owner_id : UUID [FK → User.id] + is_private : bool + password_hash : varchar? + status : ENUM(waiting, playing, closed) + created_at : timestamp +} + +entity "LobbySettings" as LobbySettings { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + max_players : int + card_count : ENUM(24, 36, 52) + is_transferable : bool + neighbor_throw_only : bool + allow_jokers : bool + turn_time_limit : int? + special_rule_set_id : UUID [FK → SpecialRuleSet.id]? +} + +entity "LobbyPlayer" as LobbyPlayer { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + user_id : UUID [FK → User.id] + status : ENUM(waiting, ready, playing, left) +} + +entity "Game" as Game { + *id : UUID + --- + lobby_id : UUID [FK → Lobby.id] + trump_card_id : UUID [FK → Card.id] + started_at : timestamp + finished_at : timestamp? + status : ENUM(in_progress, finished) + loser_id : UUID [FK → User.id]? +} + +entity "GamePlayer" as GamePlayer { + *id : UUID + --- + game_id : UUID [FK → Game.id] + user_id : UUID [FK → User.id] + seat_position : int + cards_remaining : int +} + +entity "Card" as Card { + *id : UUID + --- + suit_id : smallint [FK → CardSuit.id] + rank_id : smallint [FK → CardRank.id] + special_card_id : UUID? [FK → SpecialCard.id] +} + +' === CARD STATES === + +entity "GameDeck" as GameDeck { + *id : UUID + --- + game_id : UUID [FK → Game.id] + card_id : UUID [FK → Card.id] + position : int +} + +entity "PlayerHand" as PlayerHand { + *id : UUID + --- + game_id : UUID [FK → Game.id] + player_id : UUID [FK → User.id] + card_id : UUID [FK → Card.id] + order_in_hand : int? +} + +entity "TableCard" as TableCard { + *id : UUID + --- + game_id : UUID [FK → Game.id] + attack_card_id : UUID [FK → Card.id] + defense_card_id : UUID? [FK → Card.id] +} + +entity "DiscardPile" as DiscardPile { + *id : UUID + --- + game_id : UUID [FK → Game.id] + card_id : UUID [FK → Card.id] + position : int? +} + +' === RULES === + +entity "SpecialRuleSet" as SpecialRuleSet { + *id : UUID + --- + name : varchar + description : text? + min_players : int +} + +entity "SpecialCard" as SpecialCard { + *id : UUID + --- + name : varchar + effect_type : ENUM(skip, reverse, draw, custom) + effect_value : jsonb? + description : text? +} + +entity "SpecialRuleSetCard" as SpecialRuleSetCard { + *id : UUID + --- + rule_set_id : UUID [FK → SpecialRuleSet.id] + card_id : UUID [FK → SpecialCard.id] +} + +' === GAME FLOW === + +entity "Turn" as Turn { + *id : UUID + --- + game_id : UUID [FK → Game.id] + player_id : UUID [FK → User.id] + turn_number : int +} + +entity "Move" as Move { + *id : UUID + --- + turn_id : UUID [FK → Turn.id] + table_card_id : UUID [FK → TableCard.id] + action_type : ENUM(attack, defend, pickup) + created_at : timestamp +} + +entity "Message" as Message { + *id : UUID + --- + sender_id : UUID [FK → User.id] + receiver_id : UUID? [FK → User.id] + lobby_id : UUID? [FK → Lobby.id] + content : text + sent_at : timestamp +} + +' === RELATIONSHIPS === + +User ||--o{ Lobby +User ||--o{ LobbyPlayer +User ||--o{ Message +User ||--o{ GamePlayer +Lobby ||--|| LobbySettings +Lobby ||--o{ LobbyPlayer +Lobby ||--o{ Game +Lobby ||--o{ Message +Game ||--o{ GamePlayer +Game ||--o{ Turn +Game ||--|| Card : trump > +Game ||--o{ GameDeck +Game ||--o{ PlayerHand +Game ||--o{ TableCard +Game ||--o{ DiscardPile +Game ||--|| User : loser > +Turn ||--o{ Move +TableCard ||--o{ Move +CardSuit ||--o{ Card +CardRank ||--o{ Card +Card ||--o{ GameDeck +Card ||--o{ PlayerHand +Card ||--o{ TableCard +Card ||--o{ DiscardPile +SpecialRuleSet ||--o{ LobbySettings +SpecialRuleSet ||--o{ SpecialRuleSetCard +SpecialCard ||--o{ SpecialRuleSetCard +SpecialCard ||--o{ Card +@enduml diff --git a/Docs/Database/dbschema.svg b/Docs/Database/dbschema.svg new file mode 100644 index 0000000..da6155a --- /dev/null +++ b/Docs/Database/dbschema.svg @@ -0,0 +1 @@ +CardSuitid : smallintname : varcharcolor : ENUM(red, black)CardRankid : smallintname : varcharvalue : intUserid : UUIDusername : varcharemail : varcharpassword_hash : varcharavatar_url : varchar?created_at : timestampLobbyid : UUIDname : varcharowner_id : UUID [FK → User.id]is_private : boolpassword_hash : varchar?status : ENUM(waiting, playing, closed)created_at : timestampLobbySettingsid : UUIDlobby_id : UUID [FK → Lobby.id]max_players : intcard_count : ENUM(24, 36, 52)is_transferable : boolneighbor_throw_only : boolallow_jokers : boolturn_time_limit : int?special_rule_set_id : UUID [FK → SpecialRuleSet.id]?LobbyPlayerid : UUIDlobby_id : UUID [FK → Lobby.id]user_id : UUID [FK → User.id]status : ENUM(waiting, ready, playing, left)Gameid : UUIDlobby_id : UUID [FK → Lobby.id]trump_card_id : UUID [FK → Card.id]started_at : timestampfinished_at : timestamp?status : ENUM(in_progress, finished)loser_id : UUID [FK → User.id]?GamePlayerid : UUIDgame_id : UUID [FK → Game.id]user_id : UUID [FK → User.id]seat_position : intcards_remaining : intCardid : UUIDsuit_id : smallint [FK → CardSuit.id]rank_id : smallint [FK → CardRank.id]special_card_id : UUID? [FK → SpecialCard.id]GameDeckid : UUIDgame_id : UUID [FK → Game.id]card_id : UUID [FK → Card.id]position : intPlayerHandid : UUIDgame_id : UUID [FK → Game.id]player_id : UUID [FK → User.id]card_id : UUID [FK → Card.id]order_in_hand : int?TableCardid : UUIDgame_id : UUID [FK → Game.id]attack_card_id : UUID [FK → Card.id]defense_card_id : UUID? [FK → Card.id]DiscardPileid : UUIDgame_id : UUID [FK → Game.id]card_id : UUID [FK → Card.id]position : int?SpecialRuleSetid : UUIDname : varchardescription : text?min_players : intSpecialCardid : UUIDname : varchareffect_type : ENUM(skip, reverse, draw, custom)effect_value : jsonb?description : text?SpecialRuleSetCardid : UUIDrule_set_id : UUID [FK → SpecialRuleSet.id]card_id : UUID [FK → SpecialCard.id]Turnid : UUIDgame_id : UUID [FK → Game.id]player_id : UUID [FK → User.id]turn_number : intMoveid : UUIDturn_id : UUID [FK → Turn.id]table_card_id : UUID [FK → TableCard.id]action_type : ENUM(attack, defend, pickup)created_at : timestampMessageid : UUIDsender_id : UUID [FK → User.id]receiver_id : UUID? [FK → User.id]lobby_id : UUID? [FK → Lobby.id]content : textsent_at : timestamptrumploser \ No newline at end of file diff --git a/Docs/Models.png b/Docs/Models.png deleted file mode 100644 index c193221b2e1cc6c6c73d5e58880109e022c6594b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52592 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 Ky)t{n{{I7pmFblL From 5928ebc1b5f3effdf6607b611e761def83e88feb Mon Sep 17 00:00:00 2001 From: uxabix Date: Mon, 6 Oct 2025 21:24:45 +0200 Subject: [PATCH 02/38] Models for accounts, chat and game apps Extended user model, message model and game models(lobby, card, etc.) --- Fools_Arena/settings.py | 1 + accounts/migrations/0001_initial.py | 47 +++++++ accounts/models.py | 11 +- chat/migrations/0001_initial.py | 30 +++++ chat/models.py | 11 +- game/migrations/0001_initial.py | 187 ++++++++++++++++++++++++++++ game/models.py | 142 ++++++++++++++++++++- 7 files changed, 426 insertions(+), 3 deletions(-) create mode 100644 accounts/migrations/0001_initial.py create mode 100644 chat/migrations/0001_initial.py create mode 100644 game/migrations/0001_initial.py diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index d8ec850..fdef7b3 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -119,6 +119,7 @@ }, ] +AUTH_USER_MODEL = 'accounts.User' # Internationalization # https://docs.djangoproject.com/en/5.2/topics/i18n/ diff --git a/accounts/migrations/0001_initial.py b/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..3a305e4 --- /dev/null +++ b/accounts/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.contrib.auth.models +import django.contrib.auth.validators +import django.utils.timezone +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='User', + fields=[ + ('password', models.CharField(max_length=128, verbose_name='password')), + ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')), + ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')), + ('username', models.CharField(error_messages={'unique': 'A user with that username already exists.'}, help_text='Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.', max_length=150, unique=True, validators=[django.contrib.auth.validators.UnicodeUsernameValidator()], verbose_name='username')), + ('first_name', models.CharField(blank=True, max_length=150, verbose_name='first name')), + ('last_name', models.CharField(blank=True, max_length=150, verbose_name='last name')), + ('email', models.EmailField(blank=True, max_length=254, verbose_name='email address')), + ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.', verbose_name='staff status')), + ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active. Unselect this instead of deleting accounts.', verbose_name='active')), + ('date_joined', models.DateTimeField(default=django.utils.timezone.now, verbose_name='date joined')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('avatar_url', models.URLField(blank=True, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'verbose_name': 'user', + 'verbose_name_plural': 'users', + 'abstract': False, + }, + managers=[ + ('objects', django.contrib.auth.models.UserManager()), + ], + ), + ] diff --git a/accounts/models.py b/accounts/models.py index 71a8362..d1762e6 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,3 +1,12 @@ +import uuid +from django.contrib.auth.models import AbstractUser from django.db import models -# Create your models here. + +class User(AbstractUser): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + avatar_url = models.URLField(blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.username diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..b72eb32 --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,30 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('game', '0001_initial'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('content', models.TextField()), + ('sent_at', models.DateTimeField(auto_now_add=True)), + ('lobby', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='game.lobby')), + ('receiver', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='received_messages', to=settings.AUTH_USER_MODEL)), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/chat/models.py b/chat/models.py index 71a8362..6e32c49 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,3 +1,12 @@ +import uuid from django.db import models -# Create your models here. + +class Message(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') + receiver = models.ForeignKey('accounts.User', on_delete=models.CASCADE, null=True, blank=True, + related_name='received_messages') + lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True) + content = models.TextField() + sent_at = models.DateTimeField(auto_now_add=True) diff --git a/game/migrations/0001_initial.py b/game/migrations/0001_initial.py new file mode 100644 index 0000000..929e5dd --- /dev/null +++ b/game/migrations/0001_initial.py @@ -0,0 +1,187 @@ +# Generated by Django 5.2.6 on 2025-10-06 19:16 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='CardRank', + fields=[ + ('id', models.SmallAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('value', models.IntegerField()), + ], + ), + migrations.CreateModel( + name='CardSuit', + fields=[ + ('id', models.SmallAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=20)), + ('color', models.CharField(choices=[('red', 'Red'), ('black', 'Black')], max_length=5)), + ], + ), + migrations.CreateModel( + name='SpecialCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('effect_type', models.CharField(choices=[('skip', 'Skip'), ('reverse', 'Reverse'), ('draw', 'Draw'), ('custom', 'Custom')], max_length=10)), + ('effect_value', models.JSONField(blank=True, null=True)), + ('description', models.TextField(blank=True, null=True)), + ], + ), + migrations.CreateModel( + name='SpecialRuleSet', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=50)), + ('description', models.TextField(blank=True, null=True)), + ('min_players', models.PositiveIntegerField()), + ], + ), + migrations.CreateModel( + name='Card', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('rank', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.cardrank')), + ('suit', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.cardsuit')), + ('special_card', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='game.specialcard')), + ], + ), + migrations.CreateModel( + name='Game', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('started_at', models.DateTimeField(auto_now_add=True)), + ('finished_at', models.DateTimeField(blank=True, null=True)), + ('status', models.CharField(choices=[('in_progress', 'In Progress'), ('finished', 'Finished')], max_length=15)), + ('loser', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to=settings.AUTH_USER_MODEL)), + ('trump_card', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='as_trump', to='game.card')), + ], + ), + migrations.CreateModel( + name='DiscardPile', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('position', models.IntegerField(blank=True, null=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='GameDeck', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('position', models.IntegerField()), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='GamePlayer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('seat_position', models.IntegerField()), + ('cards_remaining', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='game.game')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Lobby', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=100)), + ('is_private', models.BooleanField(default=False)), + ('password_hash', models.CharField(blank=True, max_length=128, null=True)), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('playing', 'Playing'), ('closed', 'Closed')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.AddField( + model_name='game', + name='lobby', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.lobby'), + ), + migrations.CreateModel( + name='LobbyPlayer', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('status', models.CharField(choices=[('waiting', 'Waiting'), ('ready', 'Ready'), ('playing', 'Playing'), ('left', 'Left')], max_length=10)), + ('lobby', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='players', to='game.lobby')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='PlayerHand', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('order_in_hand', models.IntegerField(blank=True, null=True)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='LobbySettings', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('max_players', models.PositiveIntegerField()), + ('card_count', models.IntegerField(choices=[(24, '24'), (36, '36'), (52, '52')])), + ('is_transferable', models.BooleanField(default=False)), + ('neighbor_throw_only', models.BooleanField(default=False)), + ('allow_jokers', models.BooleanField(default=False)), + ('turn_time_limit', models.IntegerField(blank=True, null=True)), + ('lobby', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='settings', to='game.lobby')), + ('special_rule_set', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='game.specialruleset')), + ], + ), + migrations.CreateModel( + name='SpecialRuleSetCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.specialcard')), + ('rule_set', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.specialruleset')), + ], + ), + migrations.CreateModel( + name='TableCard', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('attack_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='attack_card', to='game.card')), + ('defense_card', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='defense_card', to='game.card')), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ], + ), + migrations.CreateModel( + name='Turn', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('turn_number', models.IntegerField()), + ('game', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.game')), + ('player', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), + ], + ), + migrations.CreateModel( + name='Move', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('action_type', models.CharField(choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')], max_length=10)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('table_card', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.tablecard')), + ('turn', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='game.turn')), + ], + ), + ] diff --git a/game/models.py b/game/models.py index 71a8362..3fb6ad1 100644 --- a/game/models.py +++ b/game/models.py @@ -1,3 +1,143 @@ +import uuid from django.db import models -# Create your models here. + +class CardSuit(models.Model): + id = models.SmallAutoField(primary_key=True) + name = models.CharField(max_length=20) + color = models.CharField(max_length=5, choices=[('red', 'Red'), ('black', 'Black')]) + + +class CardRank(models.Model): + id = models.SmallAutoField(primary_key=True) + name = models.CharField(max_length=20) + value = models.IntegerField() + + +class Lobby(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + name = models.CharField(max_length=100) + is_private = models.BooleanField(default=False) + password_hash = models.CharField(max_length=128, null=True, blank=True) + status = models.CharField(max_length=10, + choices=[('waiting', 'Waiting'), ('playing', 'Playing'), ('closed', 'Closed')]) + created_at = models.DateTimeField(auto_now_add=True) + + +class LobbySettings(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.OneToOneField(Lobby, on_delete=models.CASCADE, related_name='settings') + max_players = models.PositiveIntegerField() + card_count = models.IntegerField(choices=[(24, '24'), (36, '36'), (52, '52')]) + is_transferable = models.BooleanField(default=False) + neighbor_throw_only = models.BooleanField(default=False) + allow_jokers = models.BooleanField(default=False) + turn_time_limit = models.IntegerField(null=True, blank=True) + special_rule_set = models.ForeignKey('SpecialRuleSet', on_delete=models.SET_NULL, null=True, blank=True) + + +class LobbyPlayer(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE, related_name='players') + user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + status = models.CharField(max_length=10, + choices=[('waiting', 'Waiting'), ('ready', 'Ready'), ('playing', 'Playing'), + ('left', 'Left')]) + + +class Game(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE) + trump_card = models.ForeignKey('Card', on_delete=models.PROTECT, related_name='as_trump') + started_at = models.DateTimeField(auto_now_add=True) + finished_at = models.DateTimeField(null=True, blank=True) + status = models.CharField(max_length=15, choices=[('in_progress', 'In Progress'), ('finished', 'Finished')]) + loser = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True) + + +class GamePlayer(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='players') + user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + seat_position = models.IntegerField() + cards_remaining = models.IntegerField() + + +class Card(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + suit = models.ForeignKey(CardSuit, on_delete=models.CASCADE) + rank = models.ForeignKey(CardRank, on_delete=models.CASCADE) + special_card = models.ForeignKey('SpecialCard', on_delete=models.SET_NULL, null=True, blank=True) + + +class GameDeck(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + position = models.IntegerField() + + +class PlayerHand(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + order_in_hand = models.IntegerField(null=True, blank=True) + + +class TableCard(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + attack_card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='attack_card') + defense_card = models.ForeignKey(Card, on_delete=models.SET_NULL, null=True, blank=True, + related_name='defense_card') + + +class DiscardPile(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + card = models.ForeignKey(Card, on_delete=models.CASCADE) + position = models.IntegerField(null=True, blank=True) + + +class SpecialRuleSet(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=50) + description = models.TextField(blank=True, null=True) + min_players = models.PositiveIntegerField() + + +class SpecialCard(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=50) + effect_type = models.CharField(max_length=10, choices=[ + ('skip', 'Skip'), + ('reverse', 'Reverse'), + ('draw', 'Draw'), + ('custom', 'Custom'), + ]) + effect_value = models.JSONField(blank=True, null=True) + description = models.TextField(blank=True, null=True) + + +class SpecialRuleSetCard(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + rule_set = models.ForeignKey(SpecialRuleSet, on_delete=models.CASCADE) + card = models.ForeignKey(SpecialCard, on_delete=models.CASCADE) + + +class Turn(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE) + player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + turn_number = models.IntegerField() + + +class Move(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + turn = models.ForeignKey(Turn, on_delete=models.CASCADE) + table_card = models.ForeignKey(TableCard, on_delete=models.CASCADE) + action_type = models.CharField(max_length=10, + choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')]) + created_at = models.DateTimeField(auto_now_add=True) From d1978b01c807435a01f4c601dc4a1c5555744ad9 Mon Sep 17 00:00:00 2001 From: uxabix Date: Tue, 7 Oct 2025 21:42:52 +0200 Subject: [PATCH 03/38] Helper methods for all objects, comments --- accounts/models.py | 65 ++++ chat/models.py | 70 ++++ game/models.py | 854 ++++++++++++++++++++++++++++++++++++++++++--- 3 files changed, 948 insertions(+), 41 deletions(-) diff --git a/accounts/models.py b/accounts/models.py index d1762e6..d6b8242 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -1,12 +1,77 @@ +"""Accounts models for the Durak card game application. + +This module contains all the Django models used in the account system for +the online multiplayer Durak card game. +""" + import uuid from django.contrib.auth.models import AbstractUser from django.db import models class User(AbstractUser): + """Extended User model for the Durak card game application. + + This model extends Django's AbstractUser to include additional fields + specific to the game functionality such as avatar and creation timestamp. + Uses UUID as primary key for better security and scalability. + + Attributes: + id (UUIDField): Primary key using UUID4 instead of sequential integers. + avatar_url (URLField, optional): URL to user's avatar image. + created_at (DateTimeField): Timestamp when the account was created. + + Inherits from AbstractUser: + username, email, password, first_name, last_name, is_active, + is_staff, is_superuser, date_joined, last_login + + Related Objects: + sent_messages: Messages sent by this user (reverse FK from Message.sender) + received_messages: Private messages received by this user (reverse FK from Message.receiver) + owned_lobbies: Game lobbies created by this user (reverse FK from Lobby.owner) + lobby_participations: Lobby memberships (reverse FK from LobbyPlayer.user) + + Example: + # Create a new user + user = User.objects.create_user( + username='player1', + email='player1@example.com', + password='secure_password' + ) + user.avatar_url = 'https://example.com/avatar.jpg' + user.save() + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) avatar_url = models.URLField(blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) def __str__(self): + """Return string representation of the user. + + Returns: + str: The username of the user. + """ return self.username + + def get_full_display_name(self): + """Get user's display name with fallback to username. + + Returns: + str: Full name if available, otherwise username. + """ + full_name = self.get_full_name() + return full_name if full_name else self.username + + def has_avatar(self): + """Check if user has an avatar set. + + Returns: + bool: True if avatar_url is set, False otherwise. + """ + return bool(self.avatar_url) + + class Meta: + verbose_name = 'User' + verbose_name_plural = 'Users' + ordering = ['username'] diff --git a/chat/models.py b/chat/models.py index 6e32c49..150c8d4 100644 --- a/chat/models.py +++ b/chat/models.py @@ -1,8 +1,48 @@ +"""Chat models for the Durak card game application. + +This module contains all the Django models used in the chat system for +the online multiplayer Durak card game. +""" + import uuid from django.db import models class Message(models.Model): + """Chat message model for storing messages in lobbies and private conversations. + + This model handles both lobby-based group messages and private direct messages + between users. Messages can be associated with either a lobby (for public chat) + or a receiver (for private messaging). + + Attributes: + id (UUIDField): Primary key using UUID4 for unique message identification. + sender (ForeignKey): Reference to the User who sent the message. + receiver (ForeignKey, optional): Target User for private messages. Null for lobby messages. + lobby (ForeignKey, optional): Target Lobby for group messages. Null for private messages. + content (TextField): The actual message content/text. + sent_at (DateTimeField): Timestamp when the message was created (auto-generated). + + Note: + Either 'receiver' or 'lobby' should be set, but not both. This creates a logical + separation between private messages and lobby-based group chat. + + Example: + # Create a lobby message + Message.objects.create( + sender=user, + lobby=lobby, + content="Hello everyone!" + ) + + # Create a private message + Message.objects.create( + sender=user1, + receiver=user2, + content="Private message" + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') receiver = models.ForeignKey('accounts.User', on_delete=models.CASCADE, null=True, blank=True, @@ -10,3 +50,33 @@ class Message(models.Model): lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True) content = models.TextField() sent_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + """Return string representation of the message. + + Returns: + str: Formatted string showing sender and message preview. + """ + preview = self.content[:50] + "..." if len(self.content) > 50 else self.content + return f"{self.sender.username}: {preview}" + + def is_private(self): + """Check if this is a private message between users. + + Returns: + bool: True if message has a receiver (private), False if lobby message. + """ + return self.receiver is not None + + def is_lobby_message(self): + """Check if this is a lobby/group message. + + Returns: + bool: True if message belongs to a lobby, False if private message. + """ + return self.lobby is not None + + class Meta: + ordering = ['-sent_at'] + verbose_name = 'Message' + verbose_name_plural = 'Messages' diff --git a/game/models.py b/game/models.py index 3fb6ad1..07bff8e 100644 --- a/game/models.py +++ b/game/models.py @@ -1,20 +1,145 @@ +"""Game models for the Durak card game application. + +This module contains all the Django models that define the game logic, +lobby management, card representations, and player interactions for +the online multiplayer Durak card game. +""" + import uuid from django.db import models class CardSuit(models.Model): + """Card suit model representing playing card suits (Hearts, Diamonds, etc.). + + Defines the four traditional card suits with their display names and colors. + Used as a lookup table for card generation and game logic. + + Attributes: + id (SmallAutoField): Primary key with small integer for efficiency. + name (CharField): Display name of the suit (e.g., "Hearts", "Spades"). + color (CharField): Color of the suit, either "red" or "black". + + Color Choices: + - 'red': For Hearts and Diamonds + - 'black': For Clubs and Spades + + Example: + # Create a heart suit + hearts = CardSuit.objects.create( + name="Hearts", + color="red" + ) + """ + id = models.SmallAutoField(primary_key=True) name = models.CharField(max_length=20) color = models.CharField(max_length=5, choices=[('red', 'Red'), ('black', 'Black')]) + def __str__(self): + """Return string representation of the card suit. + + Returns: + str: The name of the suit. + """ + return self.name + + def is_red(self): + """Check if the suit is red (Hearts or Diamonds). + + Returns: + bool: True if the suit is red, False otherwise. + """ + return self.color == 'red' + + class Meta: + verbose_name = 'Card Suit' + verbose_name_plural = 'Card Suits' + ordering = ['name'] + class CardRank(models.Model): + """Card rank model representing playing card values (Ace, King, etc.). + + Defines the ranks/values of playing cards with their display names + and numeric values for comparison during gameplay. + + Attributes: + id (SmallAutoField): Primary key with small integer for efficiency. + name (CharField): Display name of the rank (e.g., "Ace", "King"). + value (IntegerField): Numeric value used for card comparison and game logic. + + Example: + # Create an Ace card rank + ace = CardRank.objects.create( + name="Ace", + value=14 # Highest value in most variations + ) + """ + id = models.SmallAutoField(primary_key=True) name = models.CharField(max_length=20) value = models.IntegerField() + def __str__(self): + """Return string representation of the card rank. + + Returns: + str: The name of the rank. + """ + return self.name + + def is_face_card(self): + """Check if this is a face card (Jack, Queen, King). + + Returns: + bool: True if value indicates a face card (typically 11-13). + """ + return 11 <= self.value <= 13 + + class Meta: + verbose_name = 'Card Rank' + verbose_name_plural = 'Card Ranks' + ordering = ['value'] + class Lobby(models.Model): + """Game lobby model for organizing players before starting games. + + Represents a game room where players can gather, chat, and prepare + to start a Durak game session. Handles lobby ownership, privacy + settings, and player management. + + Attributes: + id (UUIDField): Unique identifier for the lobby. + owner (ForeignKey): Reference to the User who created the lobby. + name (CharField): Display name of the lobby. + is_private (BooleanField): Whether the lobby requires a password to join. + password_hash (CharField, optional): Hashed password for private lobbies. + status (CharField): Current lobby state. + created_at (DateTimeField): When the lobby was created. + + Related Objects: + settings: LobbySettings object with game configuration (OneToOne). + players: LobbyPlayer objects representing users in this lobby. + games: Game objects that have been played in this lobby. + messages: Chat messages sent in this lobby. + + Status Choices: + - 'waiting': Lobby is open and waiting for players + - 'playing': Game is currently in progress + - 'closed': Lobby has been closed and is no longer accessible + + Example: + # Create a public lobby + lobby = Lobby.objects.create( + owner=user, + name="Beginner's Game", + is_private=False, + status='waiting' + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE) name = models.CharField(max_length=100) @@ -24,8 +149,83 @@ class Lobby(models.Model): choices=[('waiting', 'Waiting'), ('playing', 'Playing'), ('closed', 'Closed')]) created_at = models.DateTimeField(auto_now_add=True) + def __str__(self): + """Return string representation of the lobby. + + Returns: + str: The name of the lobby. + """ + return self.name + + def is_full(self): + """Check if the lobby has reached its maximum player capacity. + + Returns: + bool: True if lobby is at max capacity, False otherwise. + """ + current_players = self.players.filter(status__in=['waiting', 'ready', 'playing']).count() + return current_players >= self.settings.max_players + + def can_start_game(self): + """Check if the lobby has enough ready players to start a game. + + Returns: + bool: True if there are at least 2 ready players and lobby is waiting. + """ + if self.status != 'waiting': + return False + ready_players = self.players.filter(status='ready').count() + return ready_players >= 2 + + def get_active_players(self): + """Get all active players in the lobby. + + Returns: + QuerySet: LobbyPlayer objects with active status. + """ + return self.players.filter(status__in=['waiting', 'ready', 'playing']) + + class Meta: + verbose_name = 'Lobby' + verbose_name_plural = 'Lobbies' + ordering = ['-created_at'] + class LobbySettings(models.Model): + """Configuration settings for a game lobby. + + Defines the rules and parameters that will be applied to games + started within the associated lobby. Each lobby has exactly one + settings configuration. + + Attributes: + id (UUIDField): Unique identifier for the settings. + lobby (OneToOneField): Reference to the associated Lobby. + max_players (PositiveIntegerField): Maximum number of players allowed. + card_count (IntegerField): Number of cards to use in the deck. + is_transferable (BooleanField): Whether cards can be transferred between players. + neighbor_throw_only (BooleanField): Whether only neighbors can throw in additional cards. + allow_jokers (BooleanField): Whether joker cards are included in the deck. + turn_time_limit (IntegerField, optional): Time limit per turn in seconds. + special_rule_set (ForeignKey, optional): Reference to a special rule configuration. + + Card Count Choices: + - 24: Short deck (9, 10, J, Q, K, A of each suit) + - 36: Standard deck (6, 7, 8, 9, 10, J, Q, K, A of each suit) + - 52: Full deck (all cards including 2-5) + + Example: + # Create standard game settings + settings = LobbySettings.objects.create( + lobby=lobby, + max_players=4, + card_count=36, + is_transferable=True, + neighbor_throw_only=False, + allow_jokers=False + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.OneToOneField(Lobby, on_delete=models.CASCADE, related_name='settings') max_players = models.PositiveIntegerField() @@ -36,8 +236,69 @@ class LobbySettings(models.Model): turn_time_limit = models.IntegerField(null=True, blank=True) special_rule_set = models.ForeignKey('SpecialRuleSet', on_delete=models.SET_NULL, null=True, blank=True) + def __str__(self): + """Return string representation of the lobby settings. + + Returns: + str: Summary of key settings. + """ + return f"{self.lobby.name} Settings ({self.card_count} cards, {self.max_players} players)" + + def has_time_limit(self): + """Check if the lobby has a turn time limit enabled. + + Returns: + bool: True if turn_time_limit is set, False otherwise. + """ + return self.turn_time_limit is not None + + def is_beginner_friendly(self): + """Check if settings are suitable for beginner players. + + Returns: + bool: True if settings use standard rules without complex features. + """ + return (not self.is_transferable and + not self.allow_jokers and + self.special_rule_set is None) + + class Meta: + verbose_name = 'Lobby Settings' + verbose_name_plural = 'Lobby Settings' + class LobbyPlayer(models.Model): + """Relationship model connecting users to lobbies with their status. + + Represents a player's membership in a specific lobby, tracking their + current status and readiness to play. Handles the player lifecycle + from joining to leaving the lobby. + + Attributes: + id (UUIDField): Unique identifier for the lobby membership. + lobby (ForeignKey): Reference to the Lobby the player has joined. + user (ForeignKey): Reference to the User who joined the lobby. + status (CharField): Current status of the player in the lobby. + + Status Choices: + - 'waiting': Player has joined but is not ready to start + - 'ready': Player is ready to start a game + - 'playing': Player is currently in an active game + - 'left': Player has left the lobby + + Example: + # Add a player to a lobby + player = LobbyPlayer.objects.create( + lobby=lobby, + user=user, + status='waiting' + ) + + # Mark player as ready + player.status = 'ready' + player.save() + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE, related_name='players') user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) @@ -45,8 +306,88 @@ class LobbyPlayer(models.Model): choices=[('waiting', 'Waiting'), ('ready', 'Ready'), ('playing', 'Playing'), ('left', 'Left')]) + def __str__(self): + """Return string representation of the lobby player. + + Returns: + str: Username and status in the lobby. + """ + return f"{self.user.username} ({self.status}) in {self.lobby.name}" + + def is_active(self): + """Check if the player is actively participating in the lobby. + + Returns: + bool: True if player status is not 'left', False otherwise. + """ + return self.status != 'left' + + def can_start_game(self): + """Check if the player is ready to start a game. + + Returns: + bool: True if player status is 'ready', False otherwise. + """ + return self.status == 'ready' + + def leave_lobby(self): + """Mark the player as having left the lobby. + + Updates the player's status to 'left' and saves the record. + """ + self.status = 'left' + self.save(update_fields=['status']) + + class Meta: + verbose_name = 'Lobby Player' + verbose_name_plural = 'Lobby Players' + unique_together = ['lobby', 'user'] + ordering = ['lobby', 'user__username'] + class Game(models.Model): + """Core game session model for Durak card game. + + Represents an active or completed game session within a lobby. + Handles game state, trump card selection, and player management. + Each game is linked to a specific lobby and tracks all game-related data. + + Attributes: + id (UUIDField): Unique identifier for the game session. + lobby (ForeignKey): Reference to the Lobby where this game is played. + trump_card (ForeignKey): The card that determines the trump suit for this game. + started_at (DateTimeField): When the game session began. + finished_at (DateTimeField, optional): When the game ended (null for active games). + status (CharField): Current game state ('in_progress' or 'finished'). + loser (ForeignKey, optional): Reference to the User who lost the game. + + Related Objects: + players: GamePlayer objects representing players in this game session. + deck_cards: GameDeck objects representing cards remaining in the deck. + hands: PlayerHand objects showing which cards each player holds. + table_cards: TableCard objects representing cards currently on the table. + discarded_cards: DiscardPile objects for cards that have been discarded. + turns: Turn objects tracking the sequence of player turns. + + Status Choices: + - 'in_progress': Game is currently being played + - 'finished': Game has ended with a winner/loser determined + + Example: + # Start a new game + game = Game.objects.create( + lobby=lobby, + trump_card=selected_trump_card, + status='in_progress' + ) + + # End the game with a loser + game.status = 'finished' + game.loser = losing_player + game.finished_at = timezone.now() + game.save() + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE) trump_card = models.ForeignKey('Card', on_delete=models.PROTECT, related_name='as_trump') @@ -55,89 +396,520 @@ class Game(models.Model): status = models.CharField(max_length=15, choices=[('in_progress', 'In Progress'), ('finished', 'Finished')]) loser = models.ForeignKey('accounts.User', on_delete=models.SET_NULL, null=True, blank=True) + def __str__(self): + """Return string representation of the game. + + Returns: + str: Game info with lobby name and status. + """ + return f"Game in {self.lobby.name} ({self.status})" + + def is_active(self): + """Check if the game is currently in progress. + + Returns: + bool: True if status is 'in_progress', False otherwise. + """ + return self.status == 'in_progress' + + def get_trump_suit(self): + """Get the trump suit for this game. + + Returns: + CardSuit: The suit of the trump card. + """ + return self.trump_card.suit + + def get_player_count(self): + """Get the number of players in this game. + + Returns: + int: Count of GamePlayer objects associated with this game. + """ + return self.players.count() + + def get_winner(self): + """Get the winner of the game (all players except the loser). + + Returns: + QuerySet: GamePlayer objects representing winners, or None if game is active. + """ + if self.status != 'finished' or not self.loser: + return None + return self.players.exclude(user=self.loser) + + class Meta: + verbose_name = 'Game' + verbose_name_plural = 'Games' + ordering = ['-started_at'] + class GamePlayer(models.Model): + """Relationship model connecting users to game sessions with game-specific data. + + Represents a player's participation in a specific game, tracking their + position, remaining cards, and other game-state information. + + Attributes: + id (UUIDField): Unique identifier for the game participation. + game (ForeignKey): Reference to the Game session. + user (ForeignKey): Reference to the participating User. + seat_position (IntegerField): Player's position around the table (turn order). + cards_remaining (IntegerField): Number of cards currently in player's hand. + + Example: + # Add a player to a game + game_player = GamePlayer.objects.create( + game=game, + user=user, + seat_position=1, + cards_remaining=6 + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='players') user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) seat_position = models.IntegerField() cards_remaining = models.IntegerField() + def __str__(self): + """Return string representation of the game player. + + Returns: + str: Player info with username and card count. + """ + return f"{self.user.username} ({self.cards_remaining} cards) - Position {self.seat_position}" + + def has_cards(self): + """Check if the player still has cards in their hand. + + Returns: + bool: True if cards_remaining > 0, False otherwise. + """ + return self.cards_remaining > 0 + + def is_eliminated(self): + """Check if the player has been eliminated (no cards left). + + Returns: + bool: True if player has no cards remaining, False otherwise. + """ + return self.cards_remaining == 0 + + def get_hand_cards(self): + """Get all cards currently in this player's hand. + + Returns: + QuerySet: PlayerHand objects representing cards in hand. + """ + return PlayerHand.objects.filter(game=self.game, player=self.user) + + class Meta: + verbose_name = 'Game Player' + verbose_name_plural = 'Game Players' + unique_together = ['game', 'user'] + ordering = ['seat_position'] + class Card(models.Model): + """Playing card model combining suit, rank, and optional special properties. + + Represents an individual playing card with its suit, rank, and any + special abilities. Cards can be standard playing cards or special + cards with unique effects. + + Attributes: + id (UUIDField): Unique identifier for the card. + suit (ForeignKey): Reference to the CardSuit (Hearts, Diamonds, etc.). + rank (ForeignKey): Reference to the CardRank (Ace, King, etc.). + special_card (ForeignKey, optional): Reference to special card effects if applicable. + + Related Objects: + attack_card: TableCard objects where this card is the attacking card. + defense_card: TableCard objects where this card is the defending card. + as_trump: Game objects where this card serves as the trump card. + + Example: + # Create a standard playing card + card = Card.objects.create( + suit=hearts_suit, + rank=ace_rank + ) + + # Create a special card + special_card = Card.objects.create( + suit=spades_suit, + rank=joker_rank, + special_card=skip_effect + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) suit = models.ForeignKey(CardSuit, on_delete=models.CASCADE) rank = models.ForeignKey(CardRank, on_delete=models.CASCADE) special_card = models.ForeignKey('SpecialCard', on_delete=models.SET_NULL, null=True, blank=True) + def __str__(self): + """Return string representation of the card. + + Returns: + str: Card rank and suit (e.g., "Ace of Hearts"). + """ + base_name = f"{self.rank.name} of {self.suit.name}" + if self.special_card: + return f"{base_name} ({self.special_card.name})" + return base_name + + def is_trump(self, trump_suit): + """Check if this card belongs to the trump suit. + + Args: + trump_suit (CardSuit): The current trump suit for the game. + + Returns: + bool: True if card's suit matches trump suit, False otherwise. + """ + return self.suit == trump_suit + + def is_special(self): + """Check if this card has special effects. + + Returns: + bool: True if special_card is set, False otherwise. + """ + return self.special_card is not None + + def can_beat(self, other_card, trump_suit): + """Check if this card can beat another card according to Durak rules. + + Args: + other_card (Card): The card to compare against. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if this card can beat the other card, False otherwise. + """ + # Trump cards beat non-trump cards + if self.is_trump(trump_suit) and not other_card.is_trump(trump_suit): + return True + + # Non-trump cannot beat trump + if not self.is_trump(trump_suit) and other_card.is_trump(trump_suit): + return False + + # Same suit comparison by rank value + if self.suit == other_card.suit: + return self.rank.value > other_card.rank.value + + # Different non-trump suits cannot beat each other + return False + + class Meta: + verbose_name = 'Card' + verbose_name_plural = 'Cards' + unique_together = ['suit', 'rank', 'special_card'] + class GameDeck(models.Model): + """Model representing cards remaining in the deck during a game. + + Tracks the position and order of cards in the game deck. Cards are + drawn from this deck when players need to replenish their hands. + + Attributes: + id (UUIDField): Unique identifier for the deck entry. + game (ForeignKey): Reference to the Game session. + card (ForeignKey): Reference to the Card in the deck. + position (IntegerField): Position of the card in the deck (for draw order). + + Example: + # Add a card to the game deck + GameDeck.objects.create( + game=game, + card=card, + position=1 + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) card = models.ForeignKey(Card, on_delete=models.CASCADE) position = models.IntegerField() + def __str__(self): + """Return string representation of the deck card. + + Returns: + str: Card info with position in deck. + """ + return f"{self.card} at position {self.position} in {self.game}" + + @classmethod + def get_top_card(cls, game): + """Get the top card from the deck (lowest position number). + + Args: + game (Game): The game to get the top card for. + + Returns: + GameDeck: The deck entry with the lowest position, or None if deck is empty. + """ + return cls.objects.filter(game=game).order_by('position').first() + + @classmethod + def draw_card(cls, game): + """Draw and remove the top card from the deck. + + Args: + game (Game): The game to draw a card from. + + Returns: + Card: The drawn card, or None if deck is empty. + """ + deck_card = cls.get_top_card(game) + if deck_card: + card = deck_card.card + deck_card.delete() + return card + return None + + def is_last_card(self): + """Check if this is the last card in the deck. + + Returns: + bool: True if no other cards have higher positions, False otherwise. + """ + return not GameDeck.objects.filter( + game=self.game, + position__gt=self.position + ).exists() + + class Meta: + verbose_name = 'Game Deck Card' + verbose_name_plural = 'Game Deck Cards' + unique_together = ['game', 'position'] + ordering = ['position'] + class PlayerHand(models.Model): + """Model representing cards in a player's hand during a game. + + Tracks which cards each player holds, with optional ordering + information for UI display purposes. + + Attributes: + id (UUIDField): Unique identifier for the hand entry. + game (ForeignKey): Reference to the Game session. + player (ForeignKey): Reference to the User who holds the card. + card (ForeignKey): Reference to the Card in the player's hand. + order_in_hand (IntegerField, optional): Display order of card in hand. + + Example: + # Add a card to a player's hand + PlayerHand.objects.create( + game=game, + player=user, + card=card, + order_in_hand=1 + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) card = models.ForeignKey(Card, on_delete=models.CASCADE) order_in_hand = models.IntegerField(null=True, blank=True) + def __str__(self): + """Return string representation of the hand card. + + Returns: + str: Player and card information. + """ + return f"{self.player.username} holds {self.card} in {self.game}" + + @classmethod + def get_player_hand(cls, game, player): + """Get all cards in a specific player's hand for a game. + + Args: + game (Game): The game session. + player (User): The player whose hand to retrieve. + + Returns: + QuerySet: PlayerHand objects ordered by order_in_hand. + """ + return cls.objects.filter( + game=game, + player=player + ).order_by('order_in_hand') + + @classmethod + def get_hand_size(cls, game, player): + """Get the number of cards in a player's hand. + + Args: + game (Game): The game session. + player (User): The player whose hand size to count. + + Returns: + int: Number of cards in the player's hand. + """ + return cls.objects.filter(game=game, player=player).count() + + def remove_from_hand(self): + """Remove this card from the player's hand. + + Deletes the PlayerHand record and updates the GamePlayer's + cards_remaining counter. + """ + game_player = GamePlayer.objects.get(game=self.game, user=self.player) + game_player.cards_remaining = max(0, game_player.cards_remaining - 1) + game_player.save(update_fields=['cards_remaining']) + self.delete() + + class Meta: + verbose_name = 'Player Hand Card' + verbose_name_plural = 'Player Hand Cards' + unique_together = ['game', 'player', 'card'] + ordering = ['order_in_hand'] + class TableCard(models.Model): + """Model representing attack and defense card pairs on the table. + + During a Durak game round, attacking cards are placed on the table + and can be defended by appropriate defending cards. This model + tracks these attack-defense pairs. + + Attributes: + id (UUIDField): Unique identifier for the table card pair. + game (ForeignKey): Reference to the Game session. + attack_card (ForeignKey): The card used for attack. + defense_card (ForeignKey, optional): The card used for defense (null if undefended). + + Related Objects: + moves: Move objects referencing this table card pair. + + Example: + # Place an attack card on the table + table_card = TableCard.objects.create( + game=game, + attack_card=seven_of_hearts + ) + + # Defend the attack + table_card.defense_card = ten_of_hearts + table_card.save() + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) attack_card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='attack_card') defense_card = models.ForeignKey(Card, on_delete=models.SET_NULL, null=True, blank=True, related_name='defense_card') + def __str__(self): + """Return string representation of the table card. + + Returns: + str: Attack and defense card information. + """ + if self.defense_card: + return f"{self.attack_card} defended by {self.defense_card}" + return f"{self.attack_card} (undefended)" + + def is_defended(self): + """Check if the attack card has been defended. + + Returns: + bool: True if defense_card is set, False otherwise. + """ + return self.defense_card is not None + + def is_valid_defense(self, defense_card, trump_suit): + """Check if a card can validly defend this attack. + + Args: + defense_card (Card): The card being used for defense. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if the defense is valid according to Durak rules. + """ + if self.is_defended(): + return False # Already defended + + return defense_card.can_beat(self.attack_card, trump_suit) + + def defend_with(self, defense_card, trump_suit): + """Attempt to defend this attack with a card. + + Args: + defense_card (Card): The card being used for defense. + trump_suit (CardSuit): The current trump suit. + + Returns: + bool: True if defense was successful, False otherwise. + """ + if self.is_valid_defense(defense_card, trump_suit): + self.defense_card = defense_card + self.save(update_fields=['defense_card']) + return True + return False + + class Meta: + verbose_name = 'Table Card' + verbose_name_plural = 'Table Cards' + ordering = ['id'] + class DiscardPile(models.Model): + """Model representing cards that have been discarded from the game. + + After successful defense rounds or when cards are played out, + they are moved to the discard pile and removed from active play. + + Attributes: + id (UUIDField): Unique identifier for the discard entry. + game (ForeignKey): Reference to the Game session. + card (ForeignKey): Reference to the discarded Card. + position (IntegerField, optional): Order in which cards were discarded. + + Example: + # Discard cards after a successful defense + DiscardPile.objects.create( + game=game, + card=attack_card, + position=1 + ) + """ + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) card = models.ForeignKey(Card, on_delete=models.CASCADE) position = models.IntegerField(null=True, blank=True) - -class SpecialRuleSet(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=50) - description = models.TextField(blank=True, null=True) - min_players = models.PositiveIntegerField() - - -class SpecialCard(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - name = models.CharField(max_length=50) - effect_type = models.CharField(max_length=10, choices=[ - ('skip', 'Skip'), - ('reverse', 'Reverse'), - ('draw', 'Draw'), - ('custom', 'Custom'), - ]) - effect_value = models.JSONField(blank=True, null=True) - description = models.TextField(blank=True, null=True) - - -class SpecialRuleSetCard(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - rule_set = models.ForeignKey(SpecialRuleSet, on_delete=models.CASCADE) - card = models.ForeignKey(SpecialCard, on_delete=models.CASCADE) - - -class Turn(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - game = models.ForeignKey(Game, on_delete=models.CASCADE) - player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) - turn_number = models.IntegerField() - - -class Move(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - turn = models.ForeignKey(Turn, on_delete=models.CASCADE) - table_card = models.ForeignKey(TableCard, on_delete=models.CASCADE) - action_type = models.CharField(max_length=10, - choices=[('attack', 'Attack'), ('defend', 'Defend'), ('pickup', 'Pickup')]) - created_at = models.DateTimeField(auto_now_add=True) + def __str__(self): + """Return string representation of the discarded card. + + Returns: + str: Card and position information. + """ + pos_info = f" (position {self.position})" if self.position else "" + return f"Discarded {self.card}{pos_info}" + + @classmethod + def discard_cards(cls, game, cards): + """Discard multiple cards at once. + + Args: + game (Game): The game session. + cards (list): List of Card objects to discard. + + Returns: + list: List of created DiscardPile objects. + """ + last_position = cls.objects.filter(game=game).count() + discard_entries = [] From 89c1da9029dd6bfc45009039b26716d3d153bc7e Mon Sep 17 00:00:00 2001 From: uxabix Date: Sun, 12 Oct 2025 19:15:11 +0200 Subject: [PATCH 04/38] Static files folder in settings.py, staticfiles url in urls.py, README update --- Fools_Arena/settings.py | 1 + Fools_Arena/urls.py | 6 ++++++ README.md | 12 ++++++++---- 3 files changed, 15 insertions(+), 4 deletions(-) diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index fdef7b3..e13fa5e 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -137,6 +137,7 @@ # https://docs.djangoproject.com/en/5.2/howto/static-files/ STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / 'staticfiles' # Default primary key field type # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index 653613e..53c8d1d 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -17,7 +17,13 @@ from django.contrib import admin from django.urls import path +from django.conf import settings +from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), ] + +# Add static files +urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) + diff --git a/README.md b/README.md index 25d23f3..3b419a6 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,7 @@ Available services: - Django + Channels: http://localhost:8000 - PostgreSQL: localhost:5432 -### 4. Apply migrations and create superuser +### 4. Apply migrations and create a superuser Run migrations: ```bash docker-compose exec web python manage.py migrate @@ -33,8 +33,12 @@ Create a superuser (optional): docker-compose exec web python manage.py createsuperuser ``` +### 5. Generate static files +```bash +docker-compose exec web python manage.py collectstatic +``` -### 5. Work with Django +### 6. Work with Django All commands should be executed inside the web container. Examples: ```bash docker-compose exec web python manage.py shell @@ -42,7 +46,7 @@ docker-compose exec web python manage.py makemigrations docker-compose exec web python manage.py test ``` -### 6. Stop containers +### 7. Stop containers ```bash docker-compose down ``` @@ -59,7 +63,7 @@ docker-compose down ## 📌 Status Early development stage. -See [ROADMAP.md](./ROADMAP.md) for roadmap. +See [ROADMAP.md](./ROADMAP.md) for the roadmap. --- From 843874f59429259387460b5349ab263816ef813b Mon Sep 17 00:00:00 2001 From: uxabix Date: Sat, 18 Oct 2025 11:21:58 +0200 Subject: [PATCH 05/38] Models update --- accounts/models.py | 114 +++++++++- chat/models.py | 84 +++++++- game/models.py | 514 +++++++++++++++++++++++++++++++++++++++++++-- 3 files changed, 688 insertions(+), 24 deletions(-) diff --git a/accounts/models.py b/accounts/models.py index d6b8242..00838a5 100644 --- a/accounts/models.py +++ b/accounts/models.py @@ -28,8 +28,11 @@ class User(AbstractUser): Related Objects: sent_messages: Messages sent by this user (reverse FK from Message.sender) received_messages: Private messages received by this user (reverse FK from Message.receiver) - owned_lobbies: Game lobbies created by this user (reverse FK from Lobby.owner) - lobby_participations: Lobby memberships (reverse FK from LobbyPlayer.user) + lobby_set: Game lobbies owned by this user (reverse FK from Lobby.owner) + lobbyplayer_set: Lobby memberships (reverse FK from LobbyPlayer.user) + gameplayer_set: Game participations (reverse FK from GamePlayer.user) + playerhand_set: Cards in player's hands (reverse FK from PlayerHand.player) + turn_set: Turns taken by this player (reverse FK from Turn.player) Example: # Create a new user @@ -71,6 +74,113 @@ def has_avatar(self): """ return bool(self.avatar_url) + def get_active_lobby(self): + """Get the lobby this user is currently participating in. + + Returns: + Lobby: The lobby where user has active status, or None. + """ + from game.models import LobbyPlayer + try: + lobby_player = LobbyPlayer.objects.get( + user=self, + status__in=['waiting', 'ready', 'playing'] + ) + return lobby_player.lobby + except LobbyPlayer.DoesNotExist: + return None + + def get_current_game(self): + """Get the game this user is currently playing. + + Returns: + Game: The active game the user is participating in, or None. + """ + from game.models import GamePlayer + try: + game_player = GamePlayer.objects.select_related('game').get( + user=self, + game__status='in_progress' + ) + return game_player.game + except GamePlayer.DoesNotExist: + return None + + def can_join_lobby(self, lobby): + """Check if this user can join a specific lobby. + + Args: + lobby (Lobby): The lobby to check joining permissions for. + + Returns: + bool: True if user can join, False otherwise. + """ + # User cannot join if already in a lobby + if self.get_active_lobby(): + return False + + # Cannot join if lobby is full + if lobby.is_full(): + return False + + # Cannot join closed lobbies + if lobby.status == 'closed': + return False + + return True + + def leave_current_lobby(self): + """Remove this user from their current lobby if they're in one. + + Returns: + bool: True if user was in a lobby and left, False if not in a lobby. + """ + from game.models import LobbyPlayer + try: + lobby_player = LobbyPlayer.objects.get( + user=self, + status__in=['waiting', 'ready', 'playing'] + ) + lobby_player.leave_lobby() + return True + except LobbyPlayer.DoesNotExist: + return False + + def get_game_statistics(self): + """Get basic game statistics for this user. + + Returns: + dict: Dictionary containing games played, won, and win rate. + """ + from game.models import Game, GamePlayer + + # Get all finished games this user participated in + finished_games = Game.objects.filter( + players__user=self, + status='finished' + ) + + total_games = finished_games.count() + if total_games == 0: + return { + 'total_games': 0, + 'games_won': 0, + 'games_lost': 0, + 'win_rate': 0.0 + } + + # Count losses (games where this user is the loser) + games_lost = finished_games.filter(loser=self).count() + games_won = total_games - games_lost + win_rate = (games_won / total_games) * 100 if total_games > 0 else 0.0 + + return { + 'total_games': total_games, + 'games_won': games_won, + 'games_lost': games_lost, + 'win_rate': round(win_rate, 1) + } + class Meta: verbose_name = 'User' verbose_name_plural = 'Users' diff --git a/chat/models.py b/chat/models.py index 150c8d4..ecd609e 100644 --- a/chat/models.py +++ b/chat/models.py @@ -47,7 +47,8 @@ class Message(models.Model): sender = models.ForeignKey('accounts.User', on_delete=models.CASCADE, related_name='sent_messages') receiver = models.ForeignKey('accounts.User', on_delete=models.CASCADE, null=True, blank=True, related_name='received_messages') - lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True) + lobby = models.ForeignKey('game.Lobby', on_delete=models.CASCADE, null=True, blank=True, + related_name='messages') content = models.TextField() sent_at = models.DateTimeField(auto_now_add=True) @@ -76,7 +77,86 @@ def is_lobby_message(self): """ return self.lobby is not None + def get_chat_context(self): + """Get the context (lobby or private chat) for this message. + + Returns: + dict: Dictionary with context type and relevant object. + """ + if self.lobby: + return { + 'type': 'lobby', + 'context': self.lobby, + 'context_name': self.lobby.name + } + elif self.receiver: + return { + 'type': 'private', + 'context': self.receiver, + 'context_name': f"Private chat with {self.receiver.username}" + } + return {'type': 'unknown', 'context': None, 'context_name': 'Unknown'} + + @classmethod + def get_lobby_messages(cls, lobby, limit=50): + """Get recent messages for a specific lobby. + + Args: + lobby (Lobby): The lobby to get messages for. + limit (int): Maximum number of messages to retrieve. + + Returns: + QuerySet: Recent messages in the lobby. + """ + return cls.objects.filter(lobby=lobby).order_by('-sent_at')[:limit] + + @classmethod + def get_private_conversation(cls, user1, user2, limit=50): + """Get recent private messages between two users. + + Args: + user1 (User): First user in the conversation. + user2 (User): Second user in the conversation. + limit (int): Maximum number of messages to retrieve. + + Returns: + QuerySet: Recent messages between the users. + """ + return cls.objects.filter( + models.Q(sender=user1, receiver=user2) | + models.Q(sender=user2, receiver=user1), + lobby__isnull=True + ).order_by('-sent_at')[:limit] + + def clean(self): + """Validate that message has either lobby or receiver, but not both. + + Raises: + ValidationError: If both lobby and receiver are set, or if neither is set. + """ + from django.core.exceptions import ValidationError + + if self.lobby and self.receiver: + raise ValidationError("Message cannot have both lobby and receiver.") + if not self.lobby and not self.receiver: + raise ValidationError("Message must have either lobby or receiver.") + + def save(self, *args, **kwargs): + """Override save to ensure message validation. + + Args: + *args: Variable length argument list. + **kwargs: Arbitrary keyword arguments. + """ + self.clean() + super().save(*args, **kwargs) + class Meta: - ordering = ['-sent_at'] verbose_name = 'Message' verbose_name_plural = 'Messages' + ordering = ['-sent_at'] + indexes = [ + models.Index(fields=['lobby', '-sent_at']), + models.Index(fields=['sender', 'receiver', '-sent_at']), + models.Index(fields=['-sent_at']), + ] diff --git a/game/models.py b/game/models.py index 07bff8e..bc9b9c5 100644 --- a/game/models.py +++ b/game/models.py @@ -31,7 +31,7 @@ class CardSuit(models.Model): color="red" ) """ - + id = models.SmallAutoField(primary_key=True) name = models.CharField(max_length=20) color = models.CharField(max_length=5, choices=[('red', 'Red'), ('black', 'Black')]) @@ -76,7 +76,7 @@ class CardRank(models.Model): value=14 # Highest value in most variations ) """ - + id = models.SmallAutoField(primary_key=True) name = models.CharField(max_length=20) value = models.IntegerField() @@ -139,7 +139,7 @@ class Lobby(models.Model): status='waiting' ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) owner = models.ForeignKey('accounts.User', on_delete=models.CASCADE) name = models.CharField(max_length=100) @@ -225,7 +225,7 @@ class LobbySettings(models.Model): allow_jokers=False ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.OneToOneField(Lobby, on_delete=models.CASCADE, related_name='settings') max_players = models.PositiveIntegerField() @@ -258,8 +258,8 @@ def is_beginner_friendly(self): Returns: bool: True if settings use standard rules without complex features. """ - return (not self.is_transferable and - not self.allow_jokers and + return (not self.is_transferable and + not self.allow_jokers and self.special_rule_set is None) class Meta: @@ -298,7 +298,7 @@ class LobbyPlayer(models.Model): player.status = 'ready' player.save() """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE, related_name='players') user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) @@ -387,7 +387,7 @@ class Game(models.Model): game.finished_at = timezone.now() game.save() """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) lobby = models.ForeignKey(Lobby, on_delete=models.CASCADE) trump_card = models.ForeignKey('Card', on_delete=models.PROTECT, related_name='as_trump') @@ -466,7 +466,7 @@ class GamePlayer(models.Model): cards_remaining=6 ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='players') user = models.ForeignKey('accounts.User', on_delete=models.CASCADE) @@ -544,7 +544,7 @@ class Card(models.Model): special_card=skip_effect ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) suit = models.ForeignKey(CardSuit, on_delete=models.CASCADE) rank = models.ForeignKey(CardRank, on_delete=models.CASCADE) @@ -593,15 +593,15 @@ def can_beat(self, other_card, trump_suit): # Trump cards beat non-trump cards if self.is_trump(trump_suit) and not other_card.is_trump(trump_suit): return True - + # Non-trump cannot beat trump if not self.is_trump(trump_suit) and other_card.is_trump(trump_suit): return False - + # Same suit comparison by rank value if self.suit == other_card.suit: return self.rank.value > other_card.rank.value - + # Different non-trump suits cannot beat each other return False @@ -611,6 +611,268 @@ class Meta: unique_together = ['suit', 'rank', 'special_card'] +class SpecialCard(models.Model): + """Special card effects model for custom game mechanics. + + Defines special abilities that can be applied to cards to create + unique gameplay mechanics beyond standard Durak rules. Each special + card type has a specific effect and description. + + Attributes: + id (UUIDField): Unique identifier for the special card type. + name (CharField): Display name of the special effect. + effect_type (CharField): Category of the special effect. + effect_value (JSONField): JSON data containing effect parameters. + description (TextField): Human-readable description of the effect. + + Effect Types: + - 'skip': Skip the next player's turn + - 'reverse': Reverse turn order + - 'draw': Force target player to draw additional cards + - 'custom': Custom effect with parameters in effect_value + + Example: + # Create a skip effect card + skip_effect = SpecialCard.objects.create( + name="Skip Turn", + effect_type="skip", + effect_value={}, + description="Next player loses their turn" + ) + + # Create a draw effect card + draw_effect = SpecialCard.objects.create( + name="Draw Two", + effect_type="draw", + effect_value={"card_count": 2}, + description="Target player draws 2 additional cards" + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=50) + effect_type = models.CharField( + max_length=20, + choices=[ + ('skip', 'Skip Turn'), + ('reverse', 'Reverse Order'), + ('draw', 'Draw Cards'), + ('custom', 'Custom Effect') + ] + ) + effect_value = models.JSONField(default=dict, blank=True) + description = models.TextField(null=True, blank=True) + + def __str__(self): + """Return string representation of the special card. + + Returns: + str: The name of the special card effect. + """ + return self.name + + def get_effect_description(self): + """Get a formatted description of the effect with parameters. + + Returns: + str: Description with effect values interpolated. + """ + if self.effect_type == 'draw' and 'card_count' in self.effect_value: + return f"{self.description} ({self.effect_value['card_count']} cards)" + return self.description + + def is_targetable(self): + """Check if this effect requires targeting a specific player. + + Returns: + bool: True if effect targets other players, False if self-affecting. + """ + return self.effect_type in ['draw', 'skip'] + + def can_be_countered(self): + """Check if this special effect can be countered by other cards. + + Returns: + bool: True if effect can be countered, False otherwise. + """ + return self.effect_value.get('counterable', True) + + class Meta: + verbose_name = 'Special Card' + verbose_name_plural = 'Special Cards' + ordering = ['name'] + + +class SpecialRuleSet(models.Model): + """Special rule configuration for advanced game variants. + + Defines collections of special rules and cards that can be applied + to lobbies to create custom game experiences. Each rule set can + specify minimum player requirements and special card inclusions. + + Attributes: + id (UUIDField): Unique identifier for the rule set. + name (CharField): Display name of the rule set. + description (TextField): Detailed description of the rules. + min_players (IntegerField): Minimum players required for this rule set. + + Related Objects: + special_cards: SpecialCard objects included in this rule set (M2M through SpecialRuleSetCard). + lobby_settings: LobbySettings objects using this rule set. + + Example: + # Create a beginner-friendly rule set + beginner_rules = SpecialRuleSet.objects.create( + name="Beginner Special", + description="Simple special cards for new players", + min_players=2 + ) + + # Create an advanced rule set + advanced_rules = SpecialRuleSet.objects.create( + name="Master's Challenge", + description="Complex special effects for experienced players", + min_players=4 + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + name = models.CharField(max_length=100) + description = models.TextField() + min_players = models.IntegerField(default=2) + + def __str__(self): + """Return string representation of the rule set. + + Returns: + str: The name of the rule set. + """ + return self.name + + def get_special_card_count(self): + """Get the number of special cards in this rule set. + + Returns: + int: Count of special cards associated with this rule set. + """ + return self.specialrulesetcard_set.count() + + def is_compatible_with_player_count(self, player_count): + """Check if this rule set can be used with the given number of players. + + Args: + player_count (int): Number of players in the game. + + Returns: + bool: True if player count meets minimum requirement. + """ + return player_count >= self.min_players + + def get_enabled_special_cards(self): + """Get all special cards that are enabled in this rule set. + + Returns: + QuerySet: SpecialCard objects that are active in this rule set. + """ + return SpecialCard.objects.filter( + specialrulesetcard__rule_set=self, + specialrulesetcard__is_enabled=True + ) + + def can_be_used_in_lobby(self, lobby_settings): + """Check if this rule set is compatible with lobby settings. + + Args: + lobby_settings (LobbySettings): The lobby settings to check against. + + Returns: + bool: True if compatible with lobby configuration. + """ + return (self.is_compatible_with_player_count(lobby_settings.max_players) and + lobby_settings.allow_jokers) # Special cards require jokers enabled + + class Meta: + verbose_name = 'Special Rule Set' + verbose_name_plural = 'Special Rule Sets' + ordering = ['name'] + + +class SpecialRuleSetCard(models.Model): + """Association model linking special cards to rule sets with configuration. + + Defines which special cards are included in specific rule sets and + how they should be configured within that context. Allows for + fine-tuned control over special card availability and behavior. + + Attributes: + id (UUIDField): Unique identifier for the association. + rule_set (ForeignKey): Reference to the SpecialRuleSet. + card (ForeignKey): Reference to the SpecialCard. + is_enabled (BooleanField): Whether this card is active in the rule set. + + Example: + # Add a special card to a rule set + SpecialRuleSetCard.objects.create( + rule_set=beginner_rules, + card=skip_effect, + is_enabled=True + ) + + # Add but disable a complex card for beginners + SpecialRuleSetCard.objects.create( + rule_set=beginner_rules, + card=complex_effect, + is_enabled=False + ) + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + rule_set = models.ForeignKey(SpecialRuleSet, on_delete=models.CASCADE) + card = models.ForeignKey(SpecialCard, on_delete=models.CASCADE) + is_enabled = models.BooleanField(default=True) + + def __str__(self): + """Return string representation of the rule set card association. + + Returns: + str: Rule set and card names with status. + """ + status = "enabled" if self.is_enabled else "disabled" + return f"{self.card.name} in {self.rule_set.name} ({status})" + + def toggle_enabled(self): + """Toggle the enabled status of this card in the rule set. + + Returns: + bool: New enabled status after toggling. + """ + self.is_enabled = not self.is_enabled + self.save(update_fields=['is_enabled']) + return self.is_enabled + + def can_be_used_in_game(self, game): + """Check if this special card can be used in a specific game. + + Args: + game (Game): The game session to check compatibility with. + + Returns: + bool: True if the card is enabled and game allows special rules. + """ + if not self.is_enabled: + return False + + lobby_settings = game.lobby.settings + return (lobby_settings.special_rule_set == self.rule_set and + lobby_settings.allow_jokers) + + class Meta: + verbose_name = 'Special Rule Set Card' + verbose_name_plural = 'Special Rule Set Cards' + unique_together = ['rule_set', 'card'] + ordering = ['rule_set__name', 'card__name'] + + class GameDeck(models.Model): """Model representing cards remaining in the deck during a game. @@ -631,7 +893,7 @@ class GameDeck(models.Model): position=1 ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) card = models.ForeignKey(Card, on_delete=models.CASCADE) @@ -681,7 +943,7 @@ def is_last_card(self): bool: True if no other cards have higher positions, False otherwise. """ return not GameDeck.objects.filter( - game=self.game, + game=self.game, position__gt=self.position ).exists() @@ -714,7 +976,7 @@ class PlayerHand(models.Model): order_in_hand=1 ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) @@ -741,7 +1003,7 @@ def get_player_hand(cls, game, player): QuerySet: PlayerHand objects ordered by order_in_hand. """ return cls.objects.filter( - game=game, + game=game, player=player ).order_by('order_in_hand') @@ -803,7 +1065,7 @@ class TableCard(models.Model): table_card.defense_card = ten_of_hearts table_card.save() """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) attack_card = models.ForeignKey(Card, on_delete=models.CASCADE, related_name='attack_card') @@ -840,7 +1102,7 @@ def is_valid_defense(self, defense_card, trump_suit): """ if self.is_defended(): return False # Already defended - + return defense_card.can_beat(self.attack_card, trump_suit) def defend_with(self, defense_card, trump_suit): @@ -885,7 +1147,7 @@ class DiscardPile(models.Model): position=1 ) """ - + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) game = models.ForeignKey(Game, on_delete=models.CASCADE) card = models.ForeignKey(Card, on_delete=models.CASCADE) @@ -913,3 +1175,215 @@ def discard_cards(cls, game, cards): """ last_position = cls.objects.filter(game=game).count() discard_entries = [] + + +class Turn(models.Model): + """Turn tracking model for managing player turn sequence in games. + + Represents individual turns within a game session, tracking which player's + turn it is and maintaining the sequential order of gameplay. Each turn + can contain multiple moves (attack, defend, pickup). + + Attributes: + id (UUIDField): Unique identifier for the turn. + game (ForeignKey): Reference to the Game session this turn belongs to. + player (ForeignKey): Reference to the User whose turn it is. + turn_number (IntegerField): Sequential number of this turn in the game. + + Related Objects: + moves: Move objects that occurred during this turn. + + Example: + # Create a new turn + turn = Turn.objects.create( + game=game, + player=current_player, + turn_number=1 + ) + + # Get the next turn number + next_turn = Turn.objects.filter(game=game).count() + 1 + """ + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + game = models.ForeignKey(Game, on_delete=models.CASCADE, related_name='turns') + player = models.ForeignKey('accounts.User', on_delete=models.CASCADE) + turn_number = models.IntegerField() + + def __str__(self): + """Return string representation of the turn. + + Returns: + str: Turn number and player information. + """ + return f"Turn {self.turn_number}: {self.player.username} in {self.game}" + + def get_moves(self): + """Get all moves made during this turn. + + Returns: + QuerySet: Move objects associated with this turn. + """ + return self.moves.all().order_by('created_at') + + def is_complete(self): + """Check if this turn has been completed (has moves). + + Returns: + bool: True if turn has associated moves, False otherwise. + """ + return self.moves.exists() + + @classmethod + def get_current_turn(cls, game): + """Get the most recent turn for a game. + + Args: + game (Game): The game to get the current turn for. + + Returns: + Turn: The turn with the highest turn_number, or None if no turns exist. + """ + return cls.objects.filter(game=game).order_by('-turn_number').first() + + @classmethod + def create_next_turn(cls, game, player): + """Create the next turn in sequence for a game. + + Args: + game (Game): The game to create a turn for. + player (User): The player whose turn it will be. + + Returns: + Turn: The newly created turn object. + """ + next_number = cls.objects.filter(game=game).count() + 1 + return cls.objects.create( + game=game, + player=player, + turn_number=next_number + ) + + class Meta: + verbose_name = 'Turn' + verbose_name_plural = 'Turns' + unique_together = ['game', 'turn_number'] + ordering = ['turn_number'] + + +class Move(models.Model): + """Game move model representing individual player actions during gameplay. + + Tracks specific actions taken by players during their turns, such as + attacking with cards, defending attacks, or picking up cards. Each move + is associated with a turn and references the relevant table cards. + + Attributes: + id (UUIDField): Unique identifier for the move. + turn (ForeignKey): Reference to the Turn this move belongs to. + table_card (ForeignKey): Reference to the TableCard affected by this move. + action_type (CharField): Type of action performed (attack, defend, pickup). + created_at (DateTimeField): Timestamp when the move was made. + + Action Types: + - 'attack': Player places an attacking card on the table + - 'defend': Player defends an attack with an appropriate card + - 'pickup': Player picks up undefended cards from the table + + Example: + # Record an attack move + attack_move = Move.objects.create( + turn=current_turn, + table_card=table_card, + action_type='attack' + ) + + # Record a defense move + defense_move = Move.objects.create( + turn=current_turn, + table_card=table_card, + action_type='defend' + ) + """ + + ACTION_CHOICES = [ + ('attack', 'Attack'), + ('defend', 'Defend'), + ('pickup', 'Pickup'), + ] + + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + turn = models.ForeignKey(Turn, on_delete=models.CASCADE, related_name='moves') + table_card = models.ForeignKey(TableCard, on_delete=models.CASCADE) + action_type = models.CharField(max_length=10, choices=ACTION_CHOICES) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + """Return string representation of the move. + + Returns: + str: Action type and card information. + """ + return f"{self.action_type.title()} by {self.turn.player.username}: {self.table_card}" + + def get_player(self): + """Get the player who made this move. + + Returns: + User: The user associated with the turn that contains this move. + """ + return self.turn.player + + def is_attack(self): + """Check if this move is an attack action. + + Returns: + bool: True if action_type is 'attack', False otherwise. + """ + return self.action_type == 'attack' + + def is_defense(self): + """Check if this move is a defense action. + + Returns: + bool: True if action_type is 'defend', False otherwise. + """ + return self.action_type == 'defend' + + def is_pickup(self): + """Check if this move is a pickup action. + + Returns: + bool: True if action_type is 'pickup', False otherwise. + """ + return self.action_type == 'pickup' + + @classmethod + def get_game_moves(cls, game): + """Get all moves for a specific game ordered by time. + + Args: + game (Game): The game to get moves for. + + Returns: + QuerySet: Move objects for the game ordered by creation time. + """ + return cls.objects.filter(turn__game=game).order_by('created_at') + + @classmethod + def get_player_moves(cls, game, player): + """Get all moves made by a specific player in a game. + + Args: + game (Game): The game to search in. + player (User): The player whose moves to retrieve. + + Returns: + QuerySet: Move objects made by the player in the game. + """ + return cls.objects.filter(turn__game=game, turn__player=player).order_by('created_at') + + class Meta: + verbose_name = 'Move' + verbose_name_plural = 'Moves' + ordering = ['created_at'] From a80aae6e4348cec56c9bdc106c05b4f3a786209a Mon Sep 17 00:00:00 2001 From: uxabix Date: Sat, 18 Oct 2025 11:22:17 +0200 Subject: [PATCH 06/38] Admin panels for all apps --- Fools_Arena/settings.py | 2 +- Fools_Arena/urls.py | 2 + accounts/admin.py | 197 +- .../migrations/0002_alter_user_options.py | 17 + chat/admin.py | 409 +++- chat/migrations/0002_alter_message_options.py | 17 + .../0003_alter_message_lobby_and_more.py | 34 + game/admin.py | 2108 ++++++++++++++++- ...e_turn_game_remove_turn_player_and_more.py | 139 ++ game/migrations/0003_turn_move.py | 47 + 10 files changed, 2968 insertions(+), 4 deletions(-) create mode 100644 accounts/migrations/0002_alter_user_options.py create mode 100644 chat/migrations/0002_alter_message_options.py create mode 100644 chat/migrations/0003_alter_message_lobby_and_more.py create mode 100644 game/migrations/0002_remove_turn_game_remove_turn_player_and_more.py create mode 100644 game/migrations/0003_turn_move.py diff --git a/Fools_Arena/settings.py b/Fools_Arena/settings.py index e13fa5e..d6bc835 100644 --- a/Fools_Arena/settings.py +++ b/Fools_Arena/settings.py @@ -48,7 +48,7 @@ # WebSockets INSTALLED_APPS += ['channels'] -ASGI_APPLICATION = 'myproject.asgi.application' +ASGI_APPLICATION = 'Fools_Arena.asgi.application' CHANNEL_LAYERS = { "default": { diff --git a/Fools_Arena/urls.py b/Fools_Arena/urls.py index 53c8d1d..dd44f18 100644 --- a/Fools_Arena/urls.py +++ b/Fools_Arena/urls.py @@ -19,6 +19,8 @@ from django.urls import path from django.conf import settings from django.conf.urls.static import static +from django.http import HttpResponse +from django.shortcuts import redirect urlpatterns = [ path("admin/", admin.site.urls), diff --git a/accounts/admin.py b/accounts/admin.py index 8c38f3f..8a741af 100644 --- a/accounts/admin.py +++ b/accounts/admin.py @@ -1,3 +1,198 @@ +"""Admin configuration for the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the accounts app, providing a comprehensive management interface for +administrators. +""" + from django.contrib import admin +from django.contrib.auth.admin import UserAdmin as BaseUserAdmin +from django.utils.html import format_html +from .models import User + + +@admin.register(User) +class UserAdmin(BaseUserAdmin): + """Admin interface for the custom User model. + + Extends Django's built-in UserAdmin to handle the custom fields + and provide enhanced functionality for managing users in the + Durak card game application. + + Features: + - Custom list display with avatar preview + - Enhanced filtering and search capabilities + - Readonly fields for system-generated data + - Custom fieldsets for better organization + - Avatar preview in detail view + + Attributes: + list_display: Fields shown in the user list view + list_filter: Available filters in the sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + ordering: Default ordering for the user list + fieldsets: Organization of fields in the detail view + """ + + # List view configuration + list_display = ( + 'username', + 'email', + 'get_full_display_name', + 'avatar_preview', + 'is_active', + 'is_staff', + 'created_at', + 'last_login' + ) + + list_display_links = ('username', 'email') + + list_filter = ( + 'is_active', + 'is_staff', + 'is_superuser', + 'created_at', + 'last_login', + 'date_joined' + ) + + search_fields = ('username', 'email', 'first_name', 'last_name') + + readonly_fields = ('id', 'created_at', 'date_joined', 'last_login', 'avatar_display') + + ordering = ('-created_at',) + + # Detail view configuration + fieldsets = ( + ('Basic Information', { + 'fields': ('id', 'username', 'email', 'password'), + 'description': 'Core user identification and authentication fields.' + }), + ('Personal Information', { + 'fields': ('first_name', 'last_name'), + 'description': 'Optional personal details for display purposes.' + }), + ('Profile', { + 'fields': ('avatar_url', 'avatar_display'), + 'description': 'User profile customization options.' + }), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser', 'groups', 'user_permissions'), + 'description': 'User permissions and group memberships.', + 'classes': ('collapse',) + }), + ('Important Dates', { + 'fields': ('created_at', 'date_joined', 'last_login'), + 'description': 'System-generated timestamps for user activity.', + 'classes': ('collapse',) + }), + ) + + # Add user form configuration + add_fieldsets = ( + ('User Creation', { + 'fields': ('username', 'email', 'password1', 'password2'), + 'description': 'Create a new user account for the Durak game.' + }), + ('Optional Information', { + 'fields': ('first_name', 'last_name', 'avatar_url'), + 'classes': ('collapse',) + }), + ('Permissions', { + 'fields': ('is_active', 'is_staff', 'is_superuser'), + 'classes': ('collapse',) + }), + ) + + def avatar_preview(self, obj): + """Display a small preview of the user's avatar in the list view. + + Args: + obj (User): The user instance. + + Returns: + str: HTML string with avatar image or placeholder text. + """ + if obj.has_avatar(): + return format_html( + '', + obj.avatar_url + ) + return "No avatar" + + avatar_preview.short_description = "Avatar" + + def avatar_display(self, obj): + """Display a larger preview of the user's avatar in the detail view. + + Args: + obj (User): The user instance. + + Returns: + str: HTML string with avatar image or placeholder message. + """ + if obj.has_avatar(): + return format_html( + '' + '
View full size', + obj.avatar_url, + obj.avatar_url + ) + return "No avatar uploaded" + + avatar_display.short_description = "Avatar Preview" + + def get_queryset(self, request): + """Optimize queryset for the admin list view. + + Args: + request: The HTTP request object. + + Returns: + QuerySet: Optimized queryset with prefetched related objects. + """ + return super().get_queryset(request).select_related() + + def has_delete_permission(self, request, obj=None): + """Control delete permissions for user objects. + + Prevents deletion of superuser accounts by non-superusers + and adds additional safety checks. + + Args: + request: The HTTP request object. + obj (User, optional): The user object being considered for deletion. + + Returns: + bool: True if the user can delete the object, False otherwise. + """ + if obj and obj.is_superuser and not request.user.is_superuser: + return False + return super().has_delete_permission(request, obj) + + def save_model(self, request, obj, form, change): + """Custom save logic for user objects. + + Args: + request: The HTTP request object. + obj (User): The user object being saved. + form: The admin form instance. + change (bool): True if this is an update, False if creating new. + """ + # Log user creation/updates for audit purposes + if not change: + # This is a new user + obj.save() + else: + # This is an update to existing user + obj.save() + + super().save_model(request, obj, form, change) + -# Register your models here. +# Optional: Customize admin site headers +admin.site.site_header = "Durak Game Administration" +admin.site.site_title = "Durak Admin" +admin.site.index_title = "Welcome to Durak Game Administration" diff --git a/accounts/migrations/0002_alter_user_options.py b/accounts/migrations/0002_alter_user_options.py new file mode 100644 index 0000000..115275e --- /dev/null +++ b/accounts/migrations/0002_alter_user_options.py @@ -0,0 +1,17 @@ +# Generated by Django 5.2.6 on 2025-10-13 16:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('accounts', '0001_initial'), + ] + + operations = [ + migrations.AlterModelOptions( + name='user', + options={'ordering': ['username'], 'verbose_name': 'User', 'verbose_name_plural': 'Users'}, + ), + ] diff --git a/chat/admin.py b/chat/admin.py index 8c38f3f..18b3407 100644 --- a/chat/admin.py +++ b/chat/admin.py @@ -1,3 +1,410 @@ +"""Admin configuration for the chat system of the Durak card game application. + +This module defines the Django admin interface configuration for all models +in the chat app, providing comprehensive management tools for administrators +to monitor and manage chat functionality. +""" + from django.contrib import admin +from django.utils.html import format_html +from django.urls import reverse +from django.utils import timezone +from datetime import timedelta +from .models import Message + + +@admin.register(Message) +class MessageAdmin(admin.ModelAdmin): + """Admin interface for the Message model. + + Provides comprehensive management capabilities for chat messages including + both lobby-based group messages and private direct messages between users. + Features advanced filtering, search, and moderation tools for administrators. + + Features: + - Differentiated display for lobby vs private messages + - Content preview with truncation for long messages + - Advanced filtering by message type, date, and participants + - Bulk actions for message moderation + - Enhanced search across users and content + - Readonly fields for system-generated data + - Custom validation and safety checks + + Attributes: + list_display: Fields shown in the message list view + list_display_links: Clickable fields in the list view + list_filter: Available filters in the admin sidebar + search_fields: Fields that can be searched + readonly_fields: Fields that cannot be edited + date_hierarchy: Date-based navigation + ordering: Default ordering for the message list + fieldsets: Organization of fields in the detail view + actions: Custom bulk actions available + """ + + # List view configuration + list_display = ( + 'message_preview', + 'sender', + 'message_type_display', + 'chat_context_display', + 'sent_at_formatted', + 'character_count', + 'is_recent' + ) + + list_display_links = ('message_preview',) + + list_filter = ( + 'sent_at', + ('sender', admin.RelatedOnlyFieldListFilter), + ('receiver', admin.RelatedOnlyFieldListFilter), + ('lobby', admin.RelatedOnlyFieldListFilter), + ) + + search_fields = ( + 'content', + 'sender__username', + 'sender__email', + 'receiver__username', + 'lobby__name', + ) + + readonly_fields = ( + 'id', + 'sent_at', + 'message_type_display', + 'chat_context_display', + 'character_count', + 'word_count', + 'content_preview_formatted' + ) + + date_hierarchy = 'sent_at' + + ordering = ('-sent_at',) + + # Detail view configuration + fieldsets = ( + ('Message Information', { + 'fields': ('id', 'content', 'content_preview_formatted'), + 'description': 'Core message content and identification.' + }), + ('Participants', { + 'fields': ('sender', 'receiver', 'lobby'), + 'description': 'Users and contexts involved in this message.' + }), + ('Message Analysis', { + 'fields': ('message_type_display', 'chat_context_display', 'character_count', 'word_count'), + 'description': 'Automated analysis of message properties.', + 'classes': ('collapse',) + }), + ('Timestamps', { + 'fields': ('sent_at',), + 'description': 'System-generated timing information.', + 'classes': ('collapse',) + }), + ) + + # Custom actions + actions = ['mark_as_reviewed', 'export_conversation', 'delete_selected_messages'] + + def message_preview(self, obj): + """Display a truncated preview of the message content. + + Args: + obj (Message): The message instance. + + Returns: + str: Truncated message content with sender information. + """ + preview = obj.content[:60] + "..." if len(obj.content) > 60 else obj.content + return f"{obj.sender.username}: {preview}" + + message_preview.short_description = "Message Preview" + message_preview.admin_order_field = 'content' + + def message_type_display(self, obj): + """Display the type of message (Private or Lobby) with visual indicator. + + Args: + obj (Message): The message instance. + + Returns: + str: HTML formatted message type with color coding. + """ + if obj.is_private(): + return format_html( + '🔒 Private' + ) + elif obj.is_lobby_message(): + return format_html( + '💬 Lobby' + ) + return format_html( + '❓ Unknown' + ) + + message_type_display.short_description = "Message Type" + + def chat_context_display(self, obj): + """Display the chat context with appropriate formatting and links. + + Args: + obj (Message): The message instance. + + Returns: + str: HTML formatted context information with admin links. + """ + context = obj.get_chat_context() + + if context['type'] == 'lobby' and obj.lobby: + lobby_url = reverse('admin:game_lobby_change', args=[obj.lobby.pk]) + return format_html( + '📋 {}', + lobby_url, + obj.lobby.name + ) + elif context['type'] == 'private' and obj.receiver: + receiver_url = reverse('admin:accounts_user_change', args=[obj.receiver.pk]) + return format_html( + '👤 Private with {}', + receiver_url, + obj.receiver.username + ) + + return format_html('❓ Unknown Context') + + chat_context_display.short_description = "Chat Context" + + def sent_at_formatted(self, obj): + """Display formatted timestamp with relative time information. + + Args: + obj (Message): The message instance. + + Returns: + str: Formatted datetime with relative time indicator. + """ + now = timezone.now() + time_diff = now - obj.sent_at + + if time_diff < timedelta(minutes=1): + relative = "just now" + elif time_diff < timedelta(hours=1): + minutes = int(time_diff.total_seconds() / 60) + relative = f"{minutes}m ago" + elif time_diff < timedelta(days=1): + hours = int(time_diff.total_seconds() / 3600) + relative = f"{hours}h ago" + else: + days = time_diff.days + relative = f"{days}d ago" + + return format_html( + '{}
({})', + obj.sent_at.strftime('%Y-%m-%d %H:%M'), + relative + ) + + sent_at_formatted.short_description = "Sent At" + sent_at_formatted.admin_order_field = 'sent_at' + + def is_recent(self, obj): + """Display whether the message was sent recently. + + Args: + obj (Message): The message instance. + + Returns: + str: Visual indicator for recent messages. + """ + now = timezone.now() + time_diff = now - obj.sent_at + + if time_diff < timedelta(minutes=5): + return format_html('🟢 Very Recent') + elif time_diff < timedelta(hours=1): + return format_html('🟡 Recent') + else: + return format_html('⚪ Old') + + is_recent.short_description = "Recency" + is_recent.admin_order_field = 'sent_at' + + def character_count(self, obj): + """Display the character count of the message content. + + Args: + obj (Message): The message instance. + + Returns: + int: Number of characters in the message content. + """ + return len(obj.content) + + character_count.short_description = "Characters" + character_count.admin_order_field = 'content' + + def word_count(self, obj): + """Display the word count of the message content. + + Args: + obj (Message): The message instance. + + Returns: + int: Number of words in the message content. + """ + return len(obj.content.split()) + + word_count.short_description = "Words" + + def content_preview_formatted(self, obj): + """Display formatted content preview for the detail view. + + Args: + obj (Message): The message instance. + + Returns: + str: HTML formatted content preview. + """ + content = obj.content.replace('\n', '
') + return format_html( + '