From c6ea8fb27cdd4b272581a3a9c13c5b02c0e65c44 Mon Sep 17 00:00:00 2001
From: Sander Roosendaal
QBLy|pY znX&IPWDI8co$lxP-rwi`-1qN!?%((M{_(pw&N1iAbzSf6^g&15mdBwHx=nAA0-W_x3rjD0>M| zyJ~1c_j@d`{q0)xZ _g7 z;4J8A{?`8XHSiD330gXO21cfn%q-vob*F$6G_ Z zXk>ZQ%G$=(&ffX%Jr~#eZtngM0|JABLqemTL_dv*eHNFR_98ta^X03og2J~U<}b z-&fbv*3~zB{M6Xq(b?7A^QE_Mcw}^JeB#IC6b8GnxU{^oy0(tr-TSpqI3OM#{if?T zo&VAP2W5Yuivy(V1RWhM9pi7hXifzECY*zg{(?LMr;a(}ZC|d73Xhq%ucqWzwVf1J zw7~J)@f%`3Euw@G#s4PlZ 2< zj9uEW4OhW+7owUUoVN444RejnJKVUu^%O?riaVkLhEyQ(E@IR^@x#aFhVM!}!rCU| zC5<}!yBE`8_yza_(x2c1&q(KqqV9;Zl)+q~>WMrfh8dTJuG}bh%gCybE6UvvOTkg` zlnbbr(r6D9Q6i>OS7Yb#FM)*;^%)_8S@c)J+dvj+{8z__vlCD7lNI-mjeHW-eyxO3 z0l095c}Lf7{URxu7QeE|acoXx7&f`O(hi>vOgZ6niy0&LgJ$%QrG7s7txGHHW5dVV zj~T<;lIL@IC8aG&4TS^d%b>u`r1^x24gF~J{9AM_Z^t=585Tk$?&{1RW;?2O0|oB4 z!>UCJd(#B7PYLZXAMJHg0X8adZ-*!Wdt8hUit;L`#%ndwQAQ#~W2`@93rX7$4h0{& z)?;;mLko5j7WFQSeMq$O7s_Y=9JUrp+dfiC;zhY2HEL}N!flAoSnnw0sk*$Ut#Y>L zIwiXdbwP$X)~YY@A{N~Sj1N+(KhT{}j8tF?nTgO8=kGF=UCUup+62|_{V8)@Zs7mr zprH=`r*1$S>xsAU8oRU5CKw|zGFV(URr1d8cCI={w^jO+KNLQw)1Bq8l `zas`9zoQAr2%V}OfO;3ML6shhnni26WV69P9x+3+C zUnGada;uaIC>;0n%J#77a$7%%4(*ERfyYn5J3Nw~oC%6UZIOihzTF*OxW|#6($IXH za9GAwC0fYN@l2gjT-!|dkG1wdOPl(}gEMIkiu+*%OB{ML1s5~lg5J7-kFolI7kVa= zakp;hY>&Tk!jM7l*-Jl8f6bKTHeCLqa`D6Yck~HPmq2Ahg`0gEoeZbJut5xlQ>vKV zT|D|TbVhvR41}*IV{ZJQ;8BqXfW$YB&8h;HjD6_S83p?CR)9B{gjb-5B ze$GFsA4*AT6+(2QYl1pj*k#Uid_J-+yV%Vcw&gOVrMHb~s%c(tGo-B3oBv=Y{+zXC zl?l?kxBkt(Xc)SybzP&(;&jP&5iyUMq)KklW=9NJ_v1 FjR|vyBwc zET7(z_tRFk`_4Vmx}`DE2IspiR1M!^d#8V_9=vW>fvHcjm6KjcT6$w#o>BGjPeFlX zIe^p`EM>rc67@ZA+J vZU8qx59zocWIe~yD(>Xa734Zk1x}gKtyb$w z+us`Lxv17x{UP?LHRJiHNGWgP%{d #GTfR91= zM2RLUuy7OEUQDc@0#|KfsQ}JAhzfMS*Q$dp!$T0?ZY9GC8k7fFgEKogyS%ZU7pZ_j zMt~eAe5&7lSSshVxm4+&5V|BNdxg^2kc@**L2*OF$EJ^Q!^7gaAv4Z#5-*y*Z2P=_ zPb1x#127m?ZcupSy1b7#oAp7>j7L+HjjV))o<+uo2@kHKu26w!6TAcE35tUX%oprX zfkbmEz?_L~AfI#+rUGrcb5!6=DPfcfyoy*@%yb@HEwiatZM?TQg(&itDH>~fxg7cM ze$lgu_l=iW%P&XL@L1Pge_jwLK}#L^59{wn|MbpBZ8XdWky79>RDb}zk3Q06LtZpH z5Lop%+9IB#0wOjTDnO2#lB1+tgY$Hw+ltnyfbeL)wxnR8lO=vGIa26U709sqIx6ti zZ=%fR1u2*cq+Y %91qxNzNz`uE%lW+iQ?Kt}ZdmoE2}Ah)~su3S!oe;FUHKQ_DZk`x_r3tvNc zLtHqvUboH?t1Nb4a^bWi2I(}U&+j!d-nA5|`9gD^S47tI`zPf+*@n07jSTr=`5PH& zw(csNS}kK0?R?9TH!&&Scgss6A?GJbIzHX}pp@f|M%dT==-|I)mFhbwG=9(qIlMRv zP=PAA9u*i?^B9HpQy8g0?`a$(Iqpc6lG5Rn56{5D;^nNvJ`l|lu`tN}S()$3Laf10 z$K)OkSWh`zZVF+ruywIWDp`j(6~03)!3YUN(Q?Y*-06Vin9#geGH>Mw6=GXGq=>Z< z&f95PEC91%QYtjC3u&i}Lv9@$t0CL*#Oss`xFu9JmI^S6G)x8Se$5R0I51FBD*Ikg zo(gDj(;Os*{Z!5(TXJkJlY8%z^7JXLvZ|1+3jvL=Yz?vusQK1upwHwgKPFo2Q30bZ z=$;YU7&)Ilx9h9XLypVLJF;?vBXf~oT2c9nw_%A~M-VW`!gs6=wxftp8`2BpZ^fl+ z1sU-K)$4bKUkkclNMRnM0;0mdHTSU``d5(@j3_?032NiXilxjR@3=s99QkdeH)JjI z nM3do#Fjy4lB!UO-p*+C&j@KjbJIGTqP&5b)nKdDzGBgC@p@2Awb9PP z7y%EtWFa1D`N|nRQ5@lwPtF?7K&>eqV*HGovSlWJVQ|hnZNEAWx>w#IWW#SaUPc92 zn*_Vgn3o9JMkDS}eo}$@UqUBkWLvqyC7TSJJp%*>uHA@@F*z;CcqN1Z|F{oDs%G@} zuRZ5I9Z#67B|=MnhAR-={Lc+0%jI`<^ l( z^V74dt81pSA;_ qP%d1T>?7W?Lsg&tB0Bo @+_CIUnW9gGhCUour)h;APmc#(Br=xH_kae#`WL zrfm=MD<6U=PnQ7#)U5^7F<<^~^C{bpLkkbCNGvOd+^U18YR5CB6$-r65RVR*$GrTi zEif#t^lp$|+OweElBo9{dT31sW0}Q;RtYNbW26kZdVtyxM5iTB ;tZ%;x(Z-&|${?bg1w19J 8#EgSd=%1O`%2ek>+#l(l7 z!-iScjMaKW)>UkttKy8=mI`tN*F#HJ`*hBpIsPf@v0t8BA&ExFH@sh^0{S4+cU`Yd zVx2{6U}38TaYU|h?X%=J=(|Mp{p4hACcIpHTrzzy+$d-6GwN9Y%PU!)g*w2m>QqsY zR};c&sT%D$IXUG vuQMjji&%-rBs2`sKU)8Pd7gFMJ|foX)jl z@O)jY*Y=E?w#Z($6H3)An<0aV`sSm`iB~sl#wzc%w6?t3mi2P=7pXA;Ephv2jl8 4fN~2HXA$$QxN?`(TkbE8 {)f <(}UA1Iv|5)x5ZSL@}<1ojcKPY_& 9 zb|>8 k(fZ;GD !W@VPDAh59u<*X5;dJFWXu0nG)G+caMjduaV3YM?`npo~r>_sB8AiQ+v6 zRKNq@5W`;*_DrQb>uKKQ)HKf<46C=AO8|f|0~tF}c(&{mE?L0a7q)f;zb51jaogM} zJrZ}a!Ixp=o>J7pmB(8Ji23rK@2`euKgxew+h~@>61zt-S7RjSr)zRKmFoJ8luG$q zM%Cz5h?Ng6OkjB*kqmL@?y12?nzy_>>pVHvw!C;>qmnHC5HI)3XOC(8P^p3r1Ebm^ zSdBhO907qRIi0GVLlN!gTu|J`d$Vp*#{8GmU28=eN@LLnH*Myl5gjjF%)2q^R;>ep zuHWw5*RWh#?0L;S2sunc#Nq4`w0036 pPV3u`7uGu%d$+p|WAh&2W}~@mLDXbMVK=JpA+A v#hw{_Wf!{ z_<&EYRmUaY>Z>*)>=1K!ecj;_2vnL>Ang`7@&`&fn8UP$@|$`jlqMFP97{8)EbrmC znpGlJP_J3`Z`yLD7nsnbB*Lu@FXE7Gu{OKD$ChAB*-ZlTj;azcOB&jNk82^th3a6W z9Pcin<1+!?WWoI{ADiPYGqcg xk*~TlIFDw z1U=0 i&eT_4gG4StZ3XOt6BFsEkRq^@nZH8=Ig7e=CkN5Bbm+2D!b z>5e%L(z!z7MtkPP4a4y*;pV#M0h8yKYy%%YC_`EOsj~)G?L4#@s}h{bIuIvnm+kAj zFraH=pfl(0aht<01qnsNan`6qNg)v3Hcx_N6iLE!K$L8S2FK6mbyjd;ZJyd@SBUux zw>7!nvPbu8fw)y8eKd;L;*9nGd9_4%ur!L{fo2jd#eBMx>Xh357Ghxpt;pHYTuv zJgxXNt7&}#KaL9YIGrQDny(P(Y32IJt6|;Xc5bEy%)8mO@A^(I{AoT>bK 0q3{C65! krt>>N?Rnd9(n9w4) z|J0;7|CF}nN@Y)P(iM$DL1JZ2jB>EFksndC5Hy|?zDB|E(+-7)*Fdelh9SGQhY|`g z$sAK!Nu_Up_Pd67p6}?=l(rfrGtK`JPH8I-C3}LI_+Ev>m4Z0Q$JB}6D6yXxlShk? z3b(3i9Mfcc(_d;g*>^q*slq4Bna=is|CZ_{i<2xlClp6*Ha~R-W!_V>NgR+rRcL6Z zKKkM|PXy1BSO!YBvgGUcjxiL>RZrGpGktD81j}Sjcl6h1;Pphv9-iR|p{~VxUxj zg_z;-noIw~NO7u2t$cGv#u}G`*C$3dDT6PJ=dPdzP()|clqw}!j0$|-fKjycN%9k0 zpxs 196zxsmxa z@0VH!5@RAqxxRXo>rDl&?1Ezm{kM%F^bbR5mw 2&n>ByR4KrZ~)PLqLBB zAtjG_6T=wW U=vX8z-d@2J324RRrJ)xw*^4g$i$*ia@h9y7~q`cN@Pd5-*t1@btA zr~2#o``QoxQ=LlS?8y0I^a)L2+(A2qd$iV@+c2nLbqev?2RG%Q XUwRN#$JJPgM^0`g*G*ZBX;i?YaYH%$54#l0LA^gb8pHd9q>rDvcK zPH-X}`pYqz@CJmx$D}jF5u74eRc9s16a_z>zB%q(=MduUS+USyNk6KnLNI>DchC9! zZ8J4gkNuJNzcK9C=iqD%g+06Inns6R(?)C&=;K1p?pNmwmo@(f3lwa?oMqGO*n16K z(;8Jq MW=)`Yj81$scK{~=K1)`c$_U8W2I{0#syBXGH z@zBFn%{mX{J?P$^6{Rh|SQ ekhmUrtv8w3gbF+!N0LI5{{mW0 z+q_*)G#$f EaE! zVo(u_&bE}Xm<#ET&J!-8{Yms-Ok+ue^F}EJBQCCH9S3JX4Hib{OzRs`haFxB*c-(E z)N8xjy;Y<)9JrT|_f_aG^Y|z5tbEYXnFo g$j0A&(d{DKv7smtoVF z7lhg%$=XU)#AIBP>9|sbJ65R1BV}FXdk5S1Cp2{HLuQhDR>;6ZK;g-Xls`T(LiOJe z^3LsM>v<{ebUs&G4;j?obop$6!h>&qe!;&1MUIsGN&;m^jKQg5OWMl>ZdvwZu{L_X zUMyyE-MgKuC}A;u^4XjR58b05pgUp NM4jT9RSLtHb6r)-OUHgX!jR+n?E4lQ z%aT$?kJ#DS>4RH%j@d(=-+T3q)0TA(!aRB_`w6z>^hjf1s{7hz__z>{0|e%A)l^~o z^{agpN#$URg9_+m3z1GwgU(6`+2M^C68gmtRxR2*!Kjc6E^4z8v{`}^o~Hsy+Kj8M zqImCUX=M|OW$dA7V~*jnMwid(qb(n8D}Wt(IMMaBq T&wGuWRT{ZV> zE|e7C@hh{3R1m%&m8Z7)znTaRRVx)s-+$3mG)x{$L1P-g^28DA50IDjb5!72Aw?jJ z0M-lM3?x%nJVCFSwF(C9F;0v`?*%y9+ARH0#V-$!0WrIkxaq)25w7Z|mtTFFl85uy z4B>cB5Z$_RV9{7<_G>ZGQfs}EH;TSih@X3qHS_Sn($_udIr?Y0r;Ei*dTL+SfBN^B zU=D}>UR>$eIpVyH#OcB)e}E-VTYs}7raTHA75I8I@lO+m{gozE>bpY9`Mj99Um~ET z&c`81vB+}u5;C0`6*z%hj_?4>vn|JeAiulxfc6QNQ%EL4xaqlCeKNxXB+da05;`Y( zZ-UP5ALaoYKRZH+1kLO*Y)>0p8hAQOE`Y5nI)k7-jiiVr6FqmPgeg%fFrrKY6~KBd zzL%f^vErBm^2tkR;zcm@xhS;34#J(zZ)D6n%(Dk0n7=52{$KS(`6s0V&Du*?@cPHIqL8Tcss4k=1TwUq+@O65ET{U;quAE9>2X`R@%|xYhIG3Q z-ZLV182&8rR~2~bfy;4m#)t`YN)Y|E&%VVg_&QSBV`>gc!>mwUxHa6)Qt7XUYb*Z! zg7>S;(r&32MVxDaadrL^d;eKw4yEGA)BnivXM1`7--+F|;zo9I;f^U!UcyNnptMpT z`!OH{y?F+T(Tp@$f&8ne|K(g@*poysH@SLUj>1#|LUqhhM;7w?AK>zrIK_1w{TERW z{j)ZNr|Syg=}2G#moLT(7Wynfw|0$udjPh25lIB4r?>(Ci?r|mlRmT>RiIfNbV9J6 zg5TEYJ~+Coq!=X*4I8G|nN`unb#ToO#?-nQ)mC3uF36_PYsXQ6J~h|Fw{Huoeyhi; zUlV^-g?|AV?`&YzbDM;CdH9L7Ki`G>%JDG@?GB@9MQQG(=mVE&e$h=}TUi%0Me(9{ zX3aZZlt#eQvZrpmL2apTc{zE0I=ACFmi%V0@b!{o?lkwDGT}v|LrYVh?Z2a#yH-5M z$s`>Z1ifk)4T2aq7};^Hp#K*m(JI6SZPDW`v6O7LLgI>5(t3BFvp@GK2VJkHo<-Lh zGTu}%8}O^~D`S6|!+ld0`CUw&B+*KAv?uZe<} #p6$U|0jDs;xk%bMW1+j?eg6(=C1gQY30t~^7-l7)FN>oYNBp5jw z6yr!hatoN!=z*8CBy4yTX#Zf)n;MOgJ3avh14T0M+`1#4fB9*{P$tFXDX5fAOH`ml zEchoF?+S-f%64_&ZivCWs#!GN|9VWfwscF{72N2GN~`1T9q$)sd1GPyXYJ+ s<=pX&IQ#NYShF z37%Gw-#Gi?7_abLg6_eK;E9uij5pdAH5CqhUzW9oiDBqQZKQ%6N7CF>@}%O2x0Kg? zydmFvjDmBVKE#;rM1*JVuk2<~+U9`q!Obo9+F@s*`x=iqT_;Y! B~6ld|^4W2fPIL!!2 zb^TC!4Wd8UcUie`e*|wv5jgz*{h^FQ*O{^8n1?K`Eu#UF!&Bo-hi; EO1Wl2|77l7O?2{2Yvy}t=Ttf05bUu?m$~Rha7&B_ zTFA{;j}84KzbQf)qHRBC$QLiTMi)(UfO-ek9pTT84%$&qcr|*Wi&Yxyi^3RQdHMk| z-YbkDB94pRC;6GpuKG{4mh=dn`B xrr2 FweUACN+$5-lF(aqvKvo)u2Tji)zLm5JK-(cj!Omu5$l1ww(!& zSlbKbpTo5wX}a?WdS~XQR2H_7-sEN93D?XegaCAJgpfea4j6oN^tx`;#-uFR+cGfe z8>zTgI9+dnZe814Y}P8KZ~iQxLpzSXE0jEUiV9#n*JhdUrcDtQYudsJ)0?B>VHuH2 z&Thh~s?Td=jXdu$(y919X9Jzr@y%ebK`=`Mnw>o#KF;m#To2p~eLH?#lrEGrKXbLa zwT7h!Ash_HnzF>G)hGo2l$9K5O7nB96I Pj+rj062Z;3XnzC z!6k3f;0mu-Cj_I-$CNj)j4!|UNmm>MF~y#_9#;EgJa1EAzMqX{euP-%3~wN2P=YcX z!{=OUoaxM0zAH32kCk~;iy~vQ|k1|X7DNH`G zaqKkwGT(fla|?M~YCFl{Mkve<-2$TI8vAmIxtrM9nuV#Mx=6|PReAR#b*+fm2ThXw z)j!{>=iWBUwW!Oi?3Jk>YuMe}(F_6|_A!n<|F$%meLtQoU~VNQf%@rWZ!nXdG?uPN zo_7$MQ9kruFk=W#!yG(AH49^>RjNjR9ZX8!U9uIl8j67kBw5AK*{s7i74c _!DB&{p5x`Fa0$FwVlccj29tai+giuJ#*DI)R0}iEc6m|6j|wQg z)oo%od3ojaT_UTC6}ckwT1v&lw?UIMCx^m?!AGzfcs$~=(?EcS!kXZ@s^|Ja?~R^b z?|pRrhJMPj0S( CXHvNJ0w@$Kr= z6M}s}Wu6b=3+LEZgmdhdJwZK-&Uzmv+Q)F2sD(L><~vwY;H`G`%+X6~9v?r>%2ayU zs;eFErW>o #fDW9XvhPRamqmI%C zHpkcRdJ36K4EL4wp5A+R1wM2R;gwwj&z 3 z#+Z-#IX4-DMU>%P=P8+3&xj{kb ytwcoZ)Ws`U#Gf4q9JHY76dh^ m*JdsBq~?Hw1xpI+-5g!PV7JAdJ`I`-F9yg?Mf;9Iv2T;#%7{8oCrOhS zfk}R<%~Oj*KG}+HNw2q?`Ec*!tHY2YZ*0n-<#$$^M}SZ_g`GG9?;u5~7G~%XzuHtK zsw)SNm|KM`?=)v>hVWDb1qBG&MU01Z;J;QWiP{}MU-k@YDQ{64B?@50H@%Nx;j*}* zm4t9O7JX{kb}B(CrdH9>XwHw%=8M3h|Mx(CaWE-;X;a~Hkk~fpajG%IJo@LBJ$SEs(oSNH30uL953yqld}?psP6fT#H@m<3sfY*$9CtpX z%gbgkr#=xbacEeHXwIBl1Q!pF&G%ORm#v=vG6H-?8AR9fK5FF*hYph%D_5slmX;DG z{4~cSX<1mFW$AVwT76`t`LY4;&ofS99#kugTwR)EIAgUT{fF3s06g9DefC&owefO9 zGTky?xS)oWui{dCyiLS*az5JKdoryXelFZRe 6#W~MN$FRR}K%X6?K=N?g^V3q4@?7 ~@Q z!AxS*yF7pMRXi5KvG$yJxy#xN3t_urP|xv&lh9?@Yx}Cz?&9D@mF_vrpK7RPyiMD- z*1{}%pISDTS?1*qskkD!Kz4INVn?QvuEg6c8zUn#r@Lu;)!} vAi< zP0dy0VVwIa!8iqyWQ~yZrq{<9A8ymlUJt73>@j=zjcyK1pl=EbFRPz_+{TYZ`!%1$ zWFu*Uxvt_;UfatsUT>&P4ZM7VoO?;+dD@U6{ijvvG99^@a-uFTcd(-T xmv RcpDbsT(HMufC1RaOKyC MoK^_eIirQLbzBm?sLe&^~`t_OI`TzDkVbGntoJpDLAeIh6Qzc1zEuL8K5W z-e0smyn@M@8E>;LN-C1HNK5lGtgEaxodQLs7)xY4<_d0TgK<3E5g=U~eYjHpz4+Lw zs5nm~P3b@alJRZu>}?LnK$78kcKV2le+(@@&oYpU5i*qTXuj|Z7!!?l8G$QxW9LVe zp%ItAK5uDxwP*P0^{3r7^JtoD^1vdtb%9*CA9jmtUO&m(>*vMRMBfLmD3V@}o+=A2 z<=8CbO5gst+z_kIdxW>nsj3sz&q#;bzc}ZitL;$fW}Iba=jI(cZamw2FH>Kp6X{x9 zTImV5=|}a3C&!@YbI`l|_&IXO8u~~f^ax5AA{t`had38}Lz2+_qo<{qjpe4(zQ~H) z+52DpV%v_ty@k@RUr}2DC`d{lTBunL2j-uhK?t#toqhW8KWpPS{=6v4)@1M%{`KMf z4qvy&+Lk|snFq5p{>vkojTmH}W^DahIV@~YOg&fim;r zo*k>)DyUvXJVUg#DppM(z$|TC_ZayUwPJW@aDWijt=J`|#lq`&R1v(g)rJ%A5fsL! zCwHP4+*Iwy>IM?5RdJJgBW85 m6#eFagC@SD%ssFJZ2+t;e$FhOv1V;$|K9eUI8!XtE+21-O4;ME2f}X zePKzp{(JrhpSKHiuHeFZWNh%aJ{UXE6|=|*g*)DAMQPBcojuk4z0+YN> O=A zQH81-LZPbnzxn4dK%^+W{Uw7DbKiDuWg{Gfj~IzM3l5N2AvR5AELlT0pm}7T^O673 z&-McKLhq}oP-apLasOB)-28j1)P^@^j%{6IJgt14%V}-GKBDPozC(!u-y#Sh_!^$e zI3^$}$`SQhZpy3+tmt~X)YU! >mC=S{jHN()5trpLtPLB)vnInvyKesex`w)Y z-8Z)v!qkjpS_BTl#9!UBTV{AI9RPQ=5R%OZY|TK1lE^lM+r-=NNuuC#L9jFYMpj+t z$U5&3l;PBXpWelvYN8xB<-wp+0lL>j_<%2piHEtfiFKHyxWsg%xrJIb=0y24Xg>hA zP@XY~nK224cg;<2uG`=0j=@D;|MVdP!|GY?e&O`}^p9e*bh5NZm#d~nWj*%F^vATj zumWsezj|SlJM+yiS01K@_?0L5^<+E{G+fN}*do#4oZ}Wc`7>j~-Ha#iRB@E`8XNCf z1--~J*%fK|qT2vf9q@o2k__|y%srD~^C;+vebcUkoc7ix(s+WEqsafut*=OSk44mE z86hiXA#o>GNy};coSe-_rQmYkl!~ v=nK9)BZqM;lYp}a zKjLL&u$~~+Pyv&T-95}fascY-tlEfX0A^)6sQBk$)5-P$7EY09JW1h#6yno5Jwgs+ zHl(jMpcbalj+bSy4R@6hkItDQPSpC@8&=xcbEJ9vv0bO%l!JfGtd^sROXtoH`j+V_ zGg4e3RDx{a{hUlRs)Z08a`_u(%2s`zjNM5@c)ZrqFn&C?wKwR`51ZjR6o8|^l|%ZI zL~|uQb&?`V;Y*Q|ii5QU-|ZL62%WVri#AK> !P1LvpK z?XB7!r;Qk{JlyOkgtC^=jv4YCwaj}HB^WzAEzP1ek;Ut0PQT?onL8|3Z`b6U8f|L? zE;9$bSSU8LCq)&YMex_!1i2T879nKbEy?0S3(lo1GaG@)VpK)Z>9i*6CUip@o?TJK zdwqClJGboU7tEs)>6tv)p&x0SD7#DuxyygQ{8!1dMXe}a4%vu)o6mW*$St#SlCN#J z`rAqEcubh#6YP{e#5YJ`Q6#OJ^hV6|$HaNARNDn1F>^S${4s=lOw?CO3W$m}`Vkwh zP@$}?UxzK^>jpQ0Pp!EGE{DTkI=^e=a+eQ#R|dyF0N !lK c@fVi<@BW8S|GrE0|E`t^!*0m8H$EdT%j literal 0 HcmV?d00001 diff --git a/static/img/NK_white.png b/static/img/NK_white.png new file mode 100644 index 0000000000000000000000000000000000000000..0c35d7d9156b3336d93adf7ee1202dbef1bff3e3 GIT binary patch literal 15555 zcmeIZWmwxm(=S{o#ai5gwJC1Jr37~pD6YjVc(Gu`tyrMA7HA bpI8@|@(~8|_KP7XYe*zO9xXOz)jC$im5y)6CMz z+=|o7(HYhI$rDj85bC3&70e9i<>>Ii4df+8_n#gh)aQTATy((ybb;B6(doTY1IjqL zS^)((`8m1i#BqT@ps1@Q6r>?5|KH@OZ(?*dFqkuli_6o~lhc!r)5+DEi$_>kn2Vd2 zi d5Q?+-9DuQwmu=>Kbw|8E>wD>n;QTW6T9(+A+cam~z~++kvLbpIIr&) wm;1j;q|wtpDVR$WySal$ICTQP%T&_n9w$m9(09!3EeP zGF!an0P`)CJ!$DgFnmc~G5%wAw!LS7nIRor)Q_VIGkzpXDrL-2BR>EUFBL)8JI |9kykOJJNM z=v`^!eY5+4qBS3waZfwG5I)&xJM~WJ!9xCK0K`6-pUE1so6xg?F$=DEth|1`{8!*o zOz6ZPY;D}_v9GHUkv?TN^-Pm_v4BG;LHA(t`sdwn*_hQU6HO)&i&=PK#so`^&=kuN z6Vjlx5er X+(6?8<#RNi7M`DVZKj)BCRUucFGnZ-Xp$b$HK|WtwJTs3;0`x8B#no+8Qi)b zaa`Ij6mur4K5eSo5=bxr(+D!0l4 S=l9qLK-W*^9ZojK7&1X^gU><|*%e^pX+* zU261K@idGsfEdYL0$PX4t>HD1_iW3;Y=g8er{CI;?Z-(TwF%snJ$!DJ_yiWDn8InY zqVJ;~L~-T2Sg)nbzhAe1!Q+84QT11r&T=2UVCB{2)N@c6=GjK)Y2MrS93 1U63Rnbej{v_GF L?6i0U8nFDR)342h*M7Ni#S*WN_~5j zl-Vd)f0>XswLQogRy!1rlO~xS2vR(@=oOo*xmdJd@U7VQkhyX7o5#24{d#S9<|p{f zX#o(AMa{r6{c5mpheiATrEQ9(r_95lUswvUn#j7bebuefqP;fU8nUar*D&Y^F`<=b zcp`yeabbMshq<$D3jYy~M@$MOQG2rVViBU_0%m>s0ecROn@0JotmF}Th$ev6Q9^aG zzc8m~L<_W#NlzR|+ZQpv9nKK0$Sq%H2-jz6p?ZG&!aDk8o~&0wQefc_PI(9#RzQYi zx@6Us-qBX%!cD=&(=2H1xAUqMua}M1qxbbQYHj3N;?-ZIMtH)^d zGd4cbT&Nn2Bp{=6DZ=Pz1FhTTgS2$ZH$Hbj&XYct;})Z`qmTqoCUy(iDu=kQ+>4!l z^hp+;U=HUrM|@uak{?Nu@SkL>>iWJpmLid QO>(E00Xk{pjQ1M2^9yoH)mzC75v}8#W21SNF;D zgoo*V!<-tPT)3d3nhg`i-2;L_RaO7A4FTQn)^&^vyvW&J4~pL!F%b!06j=Pq>2N z2!cxi;WxUl(e{m@RYl9a0`^o^5b|8yY3$RJLv^ZdT-KrQ`TS@6x26>m@4jwgAg ~j5v9||19CvbYj8rWfj;`_SgM2m+pM93N`s Xy`q z0k-zR8XMonW3S{#D&iEns?OIh$XZZZriqc8r!^d{F_!B5JIiBjrXZMG3flsuFws1> zD%{z??|mv&Qg$yMFewU%HiT9cATXO-B%a$-*~dvT^a*vg_N>9)^AC?2!T2-m`xs`y zgnhgHAPS4#S3&g~*%QwCj&YwN`G054tDO;pk?i@(B8tVWo!`=%PqpnCfXlJgF{jw4 z%sxvJR?sc$nE#@;2BrGQn>eC&`w+F;Hj?iO*P44!Xdo WU5vF&={+3i!gwzyc{^ms1- k-FVwtbn>@4Ug%g#Jh9EC clFiAjzgoh9Cmg~b$SE!al)u`c4*;s(Uz9^K$=q9&--2dJxaKY|2A8rqpI-b; z &jq^YwvON?W$f(-7$(NIrHg< zKQkNdT;p$~Wh nHRp4$}Mn5rNBpb%19xmpf6ccDv;|zQoxxblyqvOxBQ?XjF~kJ(jg7 zoY={7+$sNzfk@r8pHR2+iK!<@<^F0O(D=1(j~hLxn&)HH!b2vK^wYv&0Z(uw-XwO$ z#OdqnSeNA^w#aArlOva79{gV 021f4PNO>UReRxk~V5$0o&0@bF4ju%8`m z4YQo;sRzrhxC}?=t)0g4OXGI9t_XV`-%3BxrG4)7M>}XtIjq0}Z~5qXxb4tD!m-U_ ze>AI89q_auT7^0 G=2&1h?|uLqStEa?TQ&Ah&RXXct^H>k zWo<3EI^a~BSCTnC +YytQ;t?s>JiWACS+16K&T?1xt&dyN68@$EM~UbL8jKpR&L@ zdvo}h0*@JKm*i9X0L$Qwe?~Y=*cT!9yn=j6*VTU&PGnV&qVz%U3!m6IV(c$;*lf7M zEx_oRaKJ{45)1L72`!sIMEQdIyn^nek^92Pic}spW6?~w-wVlpr7EPtBCxLFJa;sk zE8j1T6d;ss hCl$G-v zZ*G2&+Y2H`3TP|rj}wESyrf#C6qU2hCNu6k>&hBSZ?-^+w+w`_C5%9NQm2y@8P*)G zNTCRB15ZHAKP;^?Dsp_3m$r$XiE3-L=7*19+NLS`w5R(0QBW*?739i(_wsvvA-+NU zR02 s4+Oq-?rg>2A5)+!h$s$#Q@RaLPqWJMlow |#L)PHES8uiprMyz63#f*fE6&O$qy{JLHR=JUUMTo< z@AVU0f|(>9mj1ccq|VD ~xM4x7i3G74 )+z+`goA3(k0T|^?HCkV|Q|FyM>6;K#l*%>W zxr7u-uW_-pcA9l|=P;sAKEFVCfh+2D?eCm%O8MqbDC>Z5!fwEWVgE6+#f24h9#PR7 zXY8n*@ptIM3A+x*T;5=|T#?0FJDAji^_lA!eQld4kCgQi7Xv^^c766fKNiKB zvpnq1E%NvMZ*+_@ujhe#ogYA7%r8o>x9wOw)kD^e6O24*i cJnZpm _a#(Di z#cXyejdjo!^`B`k7g^+Rr2M5TsJsiQcku=(D|TYqN;|&+y&-=~QzGAElSX)lxq3fO zjY871&I?+t-78cuDvPy9jju_{ug8-qs)xCoivn;P3;t&_uoAq Nc?&sngSM^N^|lHt*TZv4$7r@z&o@$-x860 z-k9SLn<||R4-^S`8azf &}L1J|PJ-&!6heR5D;@v`h z<_8?FyvIWoTP=+v@$%N@V;ccP%Y-oNA9k|^qYC T6cxSz3n>W zKhllUBzfh}bcw_Fe AdVcvm6MX6W8yRd=Qs@CL%%>t1^4p278wpGoik5{mBwui*h3T?E|ru zQW C+olUrnp;C>42fnd<@cZz#y0Ak9BpAkW|z zc`@qg8hDF>iUN|3QVA|6YXEn>meS-KF1Gh-wt3JhOZc^tPm1RW%X*f*i-mC}{&T`k z0P=-v*g{`14Rb-*fm@JUYYQE^pdwk2gFbInr -FIaE^R^4)-E#w0coltYcQqGES-Lob&PXcyC7}9WY1+IHvdCt zdB04zwbE72*!lpVYs{< qCG=AO5N@ab(R7*nmrfjSdz62MeY8DDi>XY$0C|3 zZLL0g&Ce}DC&YDM G+8fcEN{kMxF}zfx^=u`X6sh~@8^s6<;WmDIEp z@7^c(>Y&b@sh-cf>ApF@S5U6$MQk0<`_P|o|7xiWqTOMxkONLbQq+un1T?suf3!>j zxD6|soAb@#6P&~n_6*oc7>2O*l*h_?f^{!ydKUgy4sjV3A%?Dj2f>+*o~fjz@E>VZ zb~oZAEi@U7gj^R}6*UdXUznR3Leu|q-jzc?1bCSZOE9ey{PALGy~Z-~^R!6|#M9^@ zi<{UU`m}?zLtN!ou?4jAWUDi>vQI$wv{pHU)ua{dDN2DH#HOL*o1?9=-6o$mpRpKM zAx`iq39+|Ff$XEwm>AsRjeaNx6LnuSQQg_~5gjgy?k>CYQ5Wa0eXrm*(XW8RGi$;? z^ d)`xHz-0WZy0(b>5AdK*t#EyWj3{2KeJRhSZ@mc8v{?8itl*Ol8*_L>^vGl2W$o zkOB&srP3Ewn|-rl)IZGBic1J89_V}7i=jcmjc=WwpK;~k-w;*uyl+)O89qA*W G<@H<(R-Un@GA0GP=`u+5=#!q;*63%Vvkl2$_ymCi+_k*?(%WsPIy J#N^%<-J8&q2meZ @%0H=4mNoAeu)V @Z zd%j{h3+vvd6pqF!Ax)z8{alQ3&4p!gs<#hDpvk9&a`6Ix^YApp+yggESZ}-`?Mj%D zbY^gtDf;D~UF&hL(QfMjgy*xjMgq>LsWu j2BlG2WmQ$ <0&iZJY*(Xu0g!vhz}iSjK*N8=5)ceHqtB`M5H=mIbh2 zB<1$!dBQ7Qan75VP<`3MIjA-IcD@S<%AL4nFg8^Y{6`&XB@8_;yjyjOyhs|&|A<&h zcVZT2M@8V@j!g@1*0=B*6+~l3=7uR*QaEy{T3w=tV|1$v{@b;+rwsiF!vUsw_f$Rp zm-E0GgJ(+$bfJr}m=kqKJUO(>F6Bt)P^#;mBVx+VQ)?XzX@!Alm&vI+Cb3Hn) `fqFO2R8GdGQ)!n-xFdB`O4Dcf9uB9-6eXrPrJqdjI6UBmP zkvZ(I6EdghN<$wp`hw7NTZVCLh)!WN6vsgs)xSf-Tk3Yc1`#7X;` SDjs++|3rJ$uL(a!89Xq!Cc_i%dNE6X zoE`+d;$tv=6}pjkILCU6y@-3iGK0iR;)2SKO%(kmViU1?^Y%0^1jkYucyUoYIaOF7 z+NA1G>H4C>_}YNw=8aNnqvYZlG=!6y;eyS0u39H%`8f>)vNqy?opgC&xwjWfCHgO; zr!7c%q$KYUw_m)M_IkbCLST$Z!*%brz-0VazOU}?zFmvd9wr~1VPXKIH+X^4a0AfK zG|rE9OwHrfTw|#{2sawW=`EwDMtqFiEHF=zjqm}Vy!3su^xi0&(ad+lc)p7FM@#-h zp^@i*?FCe}mEpQ-QnMwEx`YZv)x~# 2)<^C4`@dFyw5Ah*@)F6*W zs@tbiqk#B6p6x~g<-zSskCzzZV9bcCWEu+ngi%gGuc#gxU0EO~Ix4}9R|Z4vmsfpI ztRQIPcb%Wb?e~KkhQVPDoK4>(%VK>E?@yMS7~J6~KSrIMXBF|N5u&hAU^HCwHK*Jx zL`&kZTAHFyA!>BvQ}k5+-T2<8*W!wJ53h@ts+_)2v3L?br`>#DQe#AeC1_VJU2Vs# z2`QC}TqSC27&E*HF~(4llNh07U~Pc;9^0O}Y24l$XOn5c=F1YK#!e?lI=HvtLT`ty zVa=m-sPuRN#;Farvv1>;B9583814PW7z7nnYDc+oR#l^qQ6etv8+SRUuTVJ?zunM~ zfgoPltDd)|hdmCRL_(<~R0>Son&`W>UT<_1bx)X#tWNoJ%18Mv{2J5nG#=_&gXol+ z^p~8UBO5VCRZ K@fKa4#NZ94I0A7C}q zKALgDz>*#5!Yqb%Lj2o(7 FBc(5u`79SRZyE^{Fvjw z{ZhvqipjzLP=(#rBwKuNmB8JabS9plkUB`4DVjs}?SLcqOYCn(!k4kurESNV)s%PV zBkoFW 5NF_Q#Cs^4(H9Vu1DHC8f72}dhJA7-EW6U z4^-hNPfO;RT)3XhqzW%lM63rr5M1R{o;HWm#zafV7ZDSCG9GI){8{_ys#r4npQ>B# z7dqilhq<+Bk}pV4zVUL{y9ig0HKVpqyOwm*St+Zy%#gDykjG-zHG=p2b2AnmSrVlN zTrp&1{Pm+9Iv- hE;C$)C8?*VujOq`T76d(^Oizey@zdQl_v?kf*EfPP^+gpn)S@W)%^A*+XQ zcAO*(mn8dmsssp>k?p5;uX?{Yr|66YyD`R%c{_Mz>8Q{NFiS#-O$dEE{o#yP)~D#m z{OiV}^@kfrBmZ0y2cy206f;r--1hX1lTrtJ&6^^WRud2A4fYoudMaA=y>XRUIJTSD zJfo?Lds4#ygBc7IZ<#pGR*<2~K11m&dV~PO8B_j^EzTuLO#~I^lve_rg9il2Q^;T9 z7P-}4@UTP|ChlbU)}T5rxi8EblFg{CriqH*B+J+$`IR=1rHyDQN_sy74`NS@UJNGy z9Etzz$XIbvGmOOrZp2ZeT?j60*-_>j0yz_;X8sTeS zxQobNbJ8T|N=ZtVST$5lsj=xK(8*iT2A?+2gr>3Lx8F=*oYQxT8N(-y7?}}=cz^RD z3r9Ars4!KD$Zpq@k}io#m)5K)w1L8bGcvPy;ZV8rl>4qFf9s5RWp>RJ-SIWy(-Qg_ z=#|c*Tx#A?ef|smttm{_XgfV_lE&lf=%}%ShyWJa8M!&5#-l!cJL(b`Kod%|bPd zge%W6bqb3|K712An8SUiX7u5dL^5-R)fPeK4y!J|GXis M+q zSbZpBoY;)XiF_@% }NP3cn*-POm2gfG&2 zQ-Qp&bR+y|Lhd5DwXO5Sr1wwDo1WnhC2_4lJNL6WIh~*Q!1?Drin1lW|J>Ly{#}CM zz%_0f_A8biNNrQOeQOsEpKH1-kHzYpU<%L`y}*xthHs*7Y@B>9)4z-y6_G!tG(9mS z-{qK~TzK@RI&4)w_izl*lHT11TB|kRN({;S<)fk$+mtPBPx{IRDJnD#{BUk14e{K( zKA_QSsezVX&6@Evc65O}(t3d3#(UG>@()@y7F(^_hWJi2T N^HLc}fK*%GwTR3|AEFBI8(hQf)7& zXgr~FZtdB^$aV6Ix+X76COxDje@!?}F5D|&CI2;zsQ9UO$9WjxA(Ybjbmty}n{yYN zcB7vGODVwXG^6XC_b0y${~m6h5Vn&!-Ebb9@rFM8u-T|fN92MNh6#Eu-Xw9L>zzL2 z&2qNETBYHQMZKkv(g(6+Rex@I00n%4F(E{wKZ1u1q>MzKTOvDVPlgyU##*sx3T{GQ z!%sN$<)qDrEoeNJ(JZn}L-+}jmOL5r`$o>+ZP#7TL69C*TgEOmV 3){sT9d&wqO4aHa zSg35GsM xRrunowdN&0 eSiU ~7BH(uGSX4K|9(qT%t6m6bi zbN@?Rwe>n-yfU{A;m1HAO`dP&;cq3f^%!UfFL~ECN-GS11mxxHdL2{x`MtN$YX0y| z0`gn==#B#CAq-2HQIh0?c~TndQAP7dD5Pho^I110tF6o;y*t(9QIpWyGHvfesI5;v zXVzbC`Ht~#cVMh`!*4uM)`{j5!@Ncz@om;CN^?UR3Tw^Jd)npQ>udH$E69jRsS){F zg>oHA#Z8nW=;5WwM WmAjR= zKL$DI<6AsO382=tP&9_8fh!$4KQM9L?do6)4N+^CDKWzP)O^E_2~A-!i{YMQXP~cR z(`N8rT}a>#1ZS>ZW#p+1+^AM5)VXp0Br;@V16gld6A+K5(Z6q)rFllaqs2k~cHDa% zdiH?8$loTq{IN@QHUGNgM3tL;*257~dX7aWnDv)%0~v9!^~zKHDPMW9gg(}i{v%}L zTm=~_b0?p5E=H06tE** (*`Ku)=0H(bvrA5uygSlUGhrEI(-KIR{N5G$=# zEF9!A5tEuj8vPI}TYy4q*bFe|B0*x1PrKhLb+Xeg|8V{Uy4McoPp?DGgilMfl#^Nk zdmgnv{n%%f4>Um9jftdx!zFX#O{dal#Y7H1wtYH?eN3Cft>P|D{kMkw`VtD-l%zXa zwqN~>u$SxS69pkS%F1i}S>9oxqthS(p1vj$i^^-@kudaw)0m(`9LR8;S9bduK66o# z5X5=8>FJ(x+nhfC9x0#G jgUFO4HCair|kTSEyOw*9*vyv1I z3e5oI-}2n^a*<#oj;HvKU*M81tBRmYsjSD9q);s3#43)Qu(Nfu9Y@AC-|umF8v3-& z9V3gKf0cqa?x3)%gO(KHK| +IkWg?9@xOFqI3V)2S4N*KMpfiRvYJJ+UUC-lX#QT8LE( x<4S6P?(&h8V0WJGw4@E;uGmW{5fz^69s7tfSnE` zc`)4p99CLrewO?4JTU+Lu@VBGVTq&lmT`##27Ed5M-GRLvk=Bc{?_n2Vh*t-_PhL( z+r@;!AfcGK%@ef|GF}KHZl-$5mt;!9h#f_}C1?NU>>T=Zv}=45x@ zVcRJ)X>_aDRvnumz!T*(<5`q5v@#OAw2W0xkzN8CeJi~dPWY&H#iYHbRb|5zvzG47 z*J(BZ#{x~+qdAhr`aoi{#G8CIE2Rb-KJ1TDmWz d(6eEhtMI(kt7f0m%Lyh;?FlR4b}S2I`c0F^foz2d=$xUL zGqLAy&!uoybq1wYoHsDDYr5 10e2YZEC>5X0HEMsc_E+GBrDH$*JEQmqx###$2bl=J^8nWiWD0uqZ!@upzR!Z&5 z3aA)kv+~pH%2$Ra6U*1LG@@L9Gax4rR&uWsX9ejB@ZlBj3mg6FSpCrj3w(wzb;gOA z{5)&C;6YH)vE+Nkc?N05Gko1W;_F8oc;6wERqfgW9lfe^eF!A9f*UNZU&s_F41>G? z5= fQ=2F_aq27jYe4q%$li~UCcMD+KkzKbA# z5m5m10Lu}>N|qPO8YeFXCn8A^<7YHDkAJs`L8K_{i(GIlAe<~Vx2@hdNDd)mr7-5# zeCN(>+RL)6UJYWzIRRiGjE 8(Adm3s)Unwx>#S7L8s_jE8;w27bNwKf% z{+HUYC!qX5e4;dOK7(o%DT`Y*kJ22v(ZvYQGvltk#1Bl*s1eXcYo8g!IBIWXtcdeu zB~?Y0*ibSuM4te&Jr<0LF=m~lUo`@EBwkXm)QtNm8K>jhIvUhTK?HVfo}we+inKor zYOfNBFBm@LesKQUk=B`pVVt+w!D5zTnMFmif*u}za10npc!}^BdN6&VeGrVNaZpnB z_<}hMUufv8nT{3<_rsjg!?lR_vpv!)W>gq%osg0*{l2iI_{e#=D^V!;x44wfa#e>m zDr(f85tth8Lk|y_()OOC?fQ^{y04`o4}`9~n%g^31nU(tSqS@*KUE};7S(dqy5)hY z?Abq}VC~U;S`lBMJ2x+ah}{402?Pb!2*G9-by$?X2HOY(Ij4AioZbLbrsNi{`skAW z_JnhV!x?);lSv|T$}tYw)0C_W#zi4?PRWx}&DYM)LpK_#4M$;a7`cgB_p5_QFe~$g z%=b2;Rhp5Qa`@Rzo23u hpn+XNaLH}fAYUnv3I5)_0L^t}~1?9)I^Dweh7+Lw*J-yU_Q5IKvRKc2O?YRs& zGMbci%?o5v7rOuH5hE&YrrhC7Akn0Ic2b+=Xc>~^IAfVwrB~&a;ra&;2Qp@VN~>(@ zfKIi<{WVgKZ%Jt*oA_Tj3FBKZq2HZZ2IEnVmvu+yYhEL%n=IK>9K~;&!o(dcHA7@# zJ=NesXT~aH^ZMG~5;UrTpHyAp&P>;Vy qK=;bvMwIDVqAsDn>t z<>INP8E>QDOFOpK6GvX*?0xdRD_DQe2dn2 @U-lujnHFCo~b7xvMfO|KENSxY6=$^!%UAztMy zmH9+DwN6F5C&ypn`11VyH`-p|=s8@}WHr0mROANX(Tp(Lx_WORRO g;EF&*@EIFq&gJ-F=Mz*iF&3DfQ* zzxO6Mim7Sdo6Rj6FIsI1-pj3Jt1k(C{PtD?^2N4veYEtC9en{bgrAP(l^}VL(BAG4 z!>(>4W*?*zD^rq=@F^Afh_6bp3aaw5rc7z(+jyn)R$G2A#g=e9O}$!*O6!vZI}m6U z`wXAMMvwE`X6MKTzAPb4A2T;Xv#PTz8rrAOG)}pW*_MOvg_On`vFpH_EK|JN0U@KQ zXW|VD=yKAdX!DjH)ttel42028@qF3UG~z$NLQwa5vriZleDzsM(tY4_b^_kyZkg{E zHU?rCkfU}gu<+O(O7z{xGkA?#JP4sVq3aTGZihpE=PVSHQ#8gxf<3pKvM|4EAC{Xi zl>WOOM_CRSQ`t!A7eo9$nOJ5{R5K&WAwZRLZ~C+BYuI3U%f1$RUTEn9Q3fMzdO}u{ zU)s&H(#@SDUS>5>Kxyoj!}jkS#@!5s+H~FbzviE3eat-ft_n?WH1eF$qB^QsE4{{` z%x@_)xcYlOrjV+W3?*F`wWSp&}@(G`3QCBOvvS zn%18#`0uL(S_bBLJ6;H14GCWhVVK)4Et<~qnd;Zp&Er>){Dy;ajGoLqk~q#Yd9-X# z_5;#e6kgG8nk$$mnR>^){re8&4D~RFQoM%;1{^S 2c+zWG{-Hx~bTLBWy23F}kIEFs1B zOFEU-RSY$#d~Ia6E3za08V~ @X5Or)e)SF-eX!oeq1O_V0?Jl&ez+DzP;+aG(AvC_TZwM3U!H zq4D@QKPgtJn5VI9#2po0yJrapsbd#SeNDucEBM_gO^ho6Ar|k$AgAwK&EorcmYa@b ziMgUa5?DB#;FS%?_kfp@_SIofBi>hnCT_45)6*GI2ei~%KRFQQoDs_8+TB{mP}FLZ ztV^9klT|NZWt!1J)DJ>%f^w0x?1Dc-@T7FxH}!cbvI< *fhy3)8-;};M#_nU%^~p$vk$vazVW{=Qb7T z_2nQA@EF#xf~jI*nzsqN*)JnN$<63ldlmLtee|8{(6s+aJQU1b&i}6tG!+>m4P@oh z{Oh!Lea);!DxTz@Wa$gX875-coA#`$@j8mxa8&?#mI*P@5tg?c!8|6Q$S>NS$qR81 zlFXts@!}JVYvyq-NE)NJ?4qY*QNev{!M+J2EO)lpE-e99l#*Xu{nM`gp<2=OC#{U1 zz<@{opnMz~7@6wgUmb; HlH$2ho)Z77JKWxYkclQgy|mx zpFBb~PyTa;@AUY@zZ0W}oyBVeC>OW!<=ZqEG1~ZKDpsK@gD!E1yrSJpT*W#&ElxdC z$`~lgz!}wlLv0f=jSrbX6KrDwr~JJF#Kano>N-nSvf}G}dZNeWb_$z#a8$k#Tii zsP0 r{1{=`yQE9EOI*d*$ru-evEjuJ{%XS# zxsE^kGHGjt^?~th=#)#MjJ0xPI36r`o@LQmP%5Ld+~E^&Qxm1%CkB{pJfF{ nfL0|5w#-LPS^EIl1?}wOrDtl)k@rxP!)v8}`FL7rqL= zD`-EMV%Yxd7IEfq$RsR&w*3&SiI-3SN{?n|dEdXg>7QT#`LU(7w|`%b0aehAm{1MY z*2UFv7w!$0IG6e6sk6|6x-(s-KpoP@vOa%Zw>4zmH)$Y;EKL^_Z|?UAj^4-$H6sAO z$na6lug;wk szGVUUz^HDcd|Y(sA!M;R4VrHnQE@{dU#(* zzLU6Ekqk(jW~od<^ka{jNj`ZQFdi2aAKHU*=}N c@df6>^Am%=NC5bgh-EC$P|$d*f+2LCT2y7?{u literal 0 HcmV?d00001 From 0cd8fabc13f5c30f176eb1fb05750e7c5eee7526 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 30 Mar 2021 09:05:26 +0200 Subject: [PATCH 03/18] passing tests with mock server for nk workouts list --- rowers/nkstuff.py | 107 ++++++++++++++++++++++++ rowers/stravastuff.py | 2 - rowers/templates/nk_list_import.html | 58 +++++++++++++ rowers/tests/mocks.py | 21 +++++ rowers/tests/test_imports.py | 64 ++++++++++++++ rowers/tests/testdata/nkworkoutlist.txt | 84 +++++++++++++++++++ rowers/tests/testdata/nkworkouts.txt | 84 +++++++++++++++++++ rowers/urls.py | 1 + rowers/views/importviews.py | 102 +++++++++++++++++++++- rowers/views/statements.py | 4 +- rowsandall_app/settings.py | 3 +- 11 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 rowers/nkstuff.py create mode 100644 rowers/templates/nk_list_import.html create mode 100644 rowers/tests/testdata/nkworkoutlist.txt create mode 100644 rowers/tests/testdata/nkworkouts.txt diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py new file mode 100644 index 00000000..f5dbfb61 --- /dev/null +++ b/rowers/nkstuff.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import + +import time +from time import strftime + +import requests + +#https://oauth-stage.nkrowlink.com/oauth/authorizegrant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=fc8fc3d8-ce0a-443e-838a-1c06fb5317c6&redirect_uri=https%3A%2F%2Fdunav.ngrok.io%2Fnk_callback%2F +#https://oauth-stage.nkrowlink.com/oauth/authorize?grant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=1234&redirect_uri=https%3A%2F%2Fdev.rowsandall.com%2Fnk_callback + +import django_rq +queue = django_rq.get_queue('default') +queuelow = django_rq.get_queue('low') +queuehigh = django_rq.get_queue('low') + +from rowers.rower_rules import is_workout_user, ispromember + +from iso8601 import ParseError +from rowers.utils import myqueue + +import rowers.mytypes as mytypes +import gzip + +from rowsandall_app.settings import ( + NK_CLIENT_ID, NK_REDIRECT_URI, NK_CLIENT_SECRET, + SITE_URL, NK_API_LOCATION + ) + +try: + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +from rowers.imports import * + +oauth_data = { + 'client_id': NK_CLIENT_ID, + 'client_secret': NK_CLIENT_SECRET, + 'redirect_uri': NK_REDIRECT_URI, + 'autorization_uri': "https://oauth-stage.nkrowlink.com/oauth/authorize", + 'content_type': 'application/json', + 'tokenname': 'nktoken', + 'refreshtokenname': 'nkrefreshtoken', + 'expirydatename': 'nktokenexpirydate', + 'bearer_auth': True, + 'base_url': "https://oauth-stage.nkrowlink.com/oauth/token", + 'grant_type': 'refresh_token', + 'scope':'read', + } + +def get_token(code): + return imports_get_token(code, oauth_data) + +def nk_open(user): + t = time.localtime() + timestamp = time.strftime('%b-%d-%Y_%H%M', t) + token = imports_open(user,oauth_data) + return token + +def do_refresh_token(refreshtoken): + return imports_do_refresh_token(refreshtoken, oauth_data) + +def rower_nk_token_refresh(user): + r = Rower.objects.get(user=user) + res = do_refresh_token(r.nkrefreshtoken) + access_token = res[0] + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+timedelta(seconds=expires_in) + + r.nktoken = access_token + r.nktokenexpirydate = expirydatetime + r.nkrefreshtoken = refresh_token + r.save() + + return r.nktoken + +def make_authorization_url(request): + return imports_make_authorization_url(oauth_data) + +def get_nk_workout_list(user,fake=False): + r = Rower.objects.get(user=user) + + if (r.nktoken == '') or (r.nktoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + elif (r.nktokenexpirydate is None or timezone.now()+timedelta(seconds=3599)>r.nktokenexpirydate): + s = "Token expired. Needs to refresh." + return custom_exception_handler(401,s) + else: + # ready to fetch. Hurray + authorizationstring = str('Bearer ' + r.nktoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + + url = NK_API_LOCATION+"api/v1/sessions" + + params = {} # start / end time + + s = requests.get(url,headers=headers,params=params) + + return s diff --git a/rowers/stravastuff.py b/rowers/stravastuff.py index 2cef3373..d0914b77 100644 --- a/rowers/stravastuff.py +++ b/rowers/stravastuff.py @@ -131,8 +131,6 @@ def strava_establish_push(): response = requests.post(url,data=post_data) - print(response.json()) - return response.status_code def strava_list_push(): diff --git a/rowers/templates/nk_list_import.html b/rowers/templates/nk_list_import.html new file mode 100644 index 00000000..44ae0554 --- /dev/null +++ b/rowers/templates/nk_list_import.html @@ -0,0 +1,58 @@ +{% extends "newbase.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Workouts{% endblock %} + +{% block main %} + Available on nk
+{% if workouts %} ++
+{% else %} +- + Import all NEW +
+- +
+ +This imports all workouts that have not been imported to rowsandall.com. + The action may take a longer time to process, so please be patient. Click on Import in the list below to import an individual workout. +
+- +
++ +
++ + + + {% for workout in workouts %} +Import +Name +Date +Duration +Distance +New ++ + + {% endfor %} + ++ Import +{{ workout|lookup:'name' }} +{{ workout|lookup:'starttime' }} +{{ workout|lookup:'duration' }} +{{ workout|lookup:'distance' }} m +{{ workout|lookup:'new' }} ++ No workouts found +
+ {% endif %} + {% endblock %} + +{% block sidebar %} +{% include 'menu_workouts.html' %} +{% endblock %} diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 35adee66..79fe3065 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -621,6 +621,8 @@ def mocked_requests(*args, **kwargs): stravaworkoutlist = json.load(open('rowers/tests/testdata/stravaworkoutlist.txt')) sporttracksworkoutlist = json.load(open('rowers/tests/testdata/sporttracksworkouts.txt')) + nkworkoutlist = json.load(open('rowers/tests/testdata/nkworkouts.txt')) + rkworkoutlistjson = json.load(open('rowers/tests/testdata/rkworkoutslist.txt','r')) uaworkoutlistjson = json.load(open('rowers/tests/testdata/uaworkoutlist.txt','r')) @@ -732,6 +734,7 @@ def mocked_requests(*args, **kwargs): rktester = re.compile('.*?runkeeper\.com') uatester = re.compile('.*?mapmyfitness\.com') tptester = re.compile('.*?trainingpeaks\.com') + nktester = re.compile('.*?nkrowlink\.com') garmintester = re.compile('.*?garmin\.com') c2importregex = '.*?concept2.com\/api\/users\/me\/results\/\d+' @@ -746,6 +749,9 @@ def mocked_requests(*args, **kwargs): c2workoutlistregex = '.*?concept2\.com\/api\/users\/me\/results\?page=\d' c2workoutlisttester = re.compile(c2workoutlistregex) + nkworkoutlistregex = '.*?nkrowlink\.com\/api\/v1\/sessions' + nkworkoutlisttester = re.compile(nkworkoutlistregex) + stravaathleteregex = '.*?strava\.com\/api\/v3\/athlete$' stravaathletetester = re.compile(stravaathleteregex) @@ -796,6 +802,7 @@ def mocked_requests(*args, **kwargs): garmindownloadregex = '.*?garmin\.com\/mockfile?id=1' garmindownloadtester = re.compile(garmindownloadregex) + if garmintester.match(args[0]): if garmindownloadtester.match(args[0]): return MockStreamResponse('rowers/tests/testdata/3x250m.fit',200) @@ -910,6 +917,20 @@ def mocked_requests(*args, **kwargs): return MockResponse(sporttracksworkoutlist,200) + if nktester.match(args[0]): + if nkworkoutlisttester.match(args[0]): + return MockResponse(nkworkoutlist,200) + elif 'token' in args[0]: + json_data = { + "token_type": "Bearer", + "access_token": "987654321234567898765432123456789", + "refresh_token": "1234567898765432112345678987654321", + "expires_at": arrow.now().timestamp()+7200 + } + + return MockResponse(json_data,200) + + if stravatester.match(args[0]): if stravaworkoutlisttester.match(args[0]): return MockResponse(stravaworkoutlist,200) diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index a83d534e..9b0fb73c 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -360,6 +360,70 @@ class C2ObjectsTokenExpired(DjangoTestCase): self.assertEqual(response.status_code, 200) +#@pytest.mark.django_db +@override_settings(TESTING=True) +class NKObjects(DjangoTestCase): + def setUp(self): + self.c = Client() + self.u = User.objects.create_user('john', + 'sander@ds.ds', + 'koeinsloot') + + self.u.first_name = 'John' + self.u.last_name = 'Sander' + self.u.save() + self.r = Rower.objects.create(user=self.u,gdproptin=True,surveydone=True, + gdproptindate=timezone.now() + ) + + self.r.nktoken = '12' + self.r.nkrefreshtoken = '123' + self.r.nktokenexpirydate = datetime.datetime.now()-datetime.timedelta(days=1) + + self.r.save() + self.c.login(username='john',password='koeinsloot') + self.nu = datetime.datetime.now() + + filename = 'rowers/tests/testdata/testdata.csv' + + rr = rrower(hrmax=self.r.max,hrut2=self.r.ut2, + hrut1=self.r.ut1,hrat=self.r.at, + hrtr=self.r.tr,hran=self.r.an,ftp=self.r.ftp) + row = rdata(filename,rower=rr) + totaldist = row.df['cum_dist'].max() + totaltime = row.df['TimeStamp (sec)'].max()-row.df['TimeStamp (sec)'].min() + totaltime = totaltime+row.df.loc[:,' ElapsedTime (sec)'].iloc[0] + + + hours = int(totaltime/3600.) + minutes = int((totaltime - 3600.*hours)/60.) + seconds = int(totaltime - 3600.*hours - 60.*minutes) + tenths = int(10*(totaltime - 3600.*hours - 60.*minutes - seconds)) + + duration = "%s:%s:%s.%s" % (hours,minutes,seconds,tenths) + + + workoutdate = row.rowdatetime.strftime('%Y-%m-%d') + workoutstarttime = row.rowdatetime.strftime('%H:%M:%S') + + self.w = Workout.objects.create( + name='testworkout',workouttype='water', + user=self.r,date=self.nu.strftime('%Y-%m-%d'), + starttime=workoutstarttime, + startdatetime=row.rowdatetime, + duration=duration,distance=totaldist, + csvfilename=filename + ) + + @patch('rowers.nkstuff.requests.get', side_effect=mocked_requests) + @patch('rowers.nkstuff.requests.post', side_effect=mocked_requests) + def test_nk_list(self, mock_get, mockpost): + result = rowers.nkstuff.rower_nk_token_refresh(self.u) + self.assertEqual(result,"987654321234567898765432123456789") + response = self.c.get('/rowers/workout/nkimport/') + + self.assertEqual(response.status_code,200) + #@pytest.mark.django_db diff --git a/rowers/tests/testdata/nkworkoutlist.txt b/rowers/tests/testdata/nkworkoutlist.txt new file mode 100644 index 00000000..320a47cc --- /dev/null +++ b/rowers/tests/testdata/nkworkoutlist.txt @@ -0,0 +1,84 @@ +[ + { + "id": 1, + "name": "JustGo-7189M", + "type": 0, + "speedInput": 0, + "startTime": 1614264826, + "endTime": 1614269826, + "location": "Some Location", + "deviceId": 11, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928, + "intervals": [ + { + "id": 1, + "sessionId": 1, + "startTime": 1614264826, + "intervalNumber": 1, + "sessionStrokeStartIndex": 473, + "sessionStrokeEndIndex": 674, + "sessionStrokeCount": 91, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928 + } + ], + "oarlockSessions": [ + { + "id": 1, + "sessionId": 1, + "boatName": "Fast Boat", + "seatNumber": 3, + "portStarboard": 0, + "oarLength": 284, + "oarInboardLength": 85 + } + ] + } +] diff --git a/rowers/tests/testdata/nkworkouts.txt b/rowers/tests/testdata/nkworkouts.txt new file mode 100644 index 00000000..320a47cc --- /dev/null +++ b/rowers/tests/testdata/nkworkouts.txt @@ -0,0 +1,84 @@ +[ + { + "id": 1, + "name": "JustGo-7189M", + "type": 0, + "speedInput": 0, + "startTime": 1614264826, + "endTime": 1614269826, + "location": "Some Location", + "deviceId": 11, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928, + "intervals": [ + { + "id": 1, + "sessionId": 1, + "startTime": 1614264826, + "intervalNumber": 1, + "sessionStrokeStartIndex": 473, + "sessionStrokeEndIndex": 674, + "sessionStrokeCount": 91, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928 + } + ], + "oarlockSessions": [ + { + "id": 1, + "sessionId": 1, + "boatName": "Fast Boat", + "seatNumber": 3, + "portStarboard": 0, + "oarLength": 284, + "oarInboardLength": 85 + } + ] + } +] diff --git a/rowers/urls.py b/rowers/urls.py index e6d1fc04..977c80c9 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -567,6 +567,7 @@ urlpatterns = [ re_path(r'^workout/stravaimport/user/(?P\d+)/$',views.workout_stravaimport_view,name='workout_stravaimport_view'), re_path(r'^workout/c2import/all/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), re_path(r'^workout/c2import/all/(?P \d+)/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), + re_path(r'^workout/nkimport/$',views.workout_nkimport_view,name='workout_nkimport_view'), re_path(r'^workout/rp3import/(?P \d+)/$',views.workout_getrp3importview, name='workout_getrp3importview'), re_path(r'^workout/rp3import/all/$',views.workout_getrp3workout_all,name='workout_getrp3workout_all'), diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index e5bdbaba..7e1a86d7 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -411,6 +411,8 @@ def rower_nk_authorize(request): return HttpResponseRedirect(url) + + # Concept2 authorization @login_required() def rower_c2_authorize(request): @@ -679,7 +681,7 @@ def rower_process_callback(request): message = "The resource owner or authorization server denied the request" messages.error(request,message) - url = reverse('workouts_view') + url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) @@ -689,7 +691,7 @@ def rower_process_callback(request): message += ' Contact info@rowsandall.com if this behavior persists.' messages.error(request,message) - url = reverse('workouts_view') + url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) @@ -775,11 +777,105 @@ def rower_process_garmincallback(request): @login_required() def rower_process_nkcallback(request): # do stuff + try: + code = request.get['code'] + res = nkstuff.get_token['code'] + except MultiValueDictKeyError: + message = "The resource owner or authorization server denied the request" + messages.error(request,message) + url = reverse('rower_exportsettings_view') + return HttpResponseRedirect(url) + + access_token = res[0] + if access_token == 0: + message = res[1] + message += ' Contact support@rowsandall.com if this behavior persists' + messages.error(request,message) + + url = reverse('rower_exportsettings_view') + + return HttpResponseRedirect(url) + + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) + + r = getrower(request.user) + r.nktoken = access_token + r.nktokenexpirydate = expirydatetime + r.nkrefreshtoken = refresh_token + + r.save() + + successmessage = "Tokens stored. Good to go. Please check your import/export settings" + messages.info(request,successmessage) url = reverse('rower_exportsettings_view') - return HttpResponseRedirect(url) +@login_required() +@permission_required('rower.is_coach',fn=get_user_by_userid, raise_exception=True) +def workout_nkimport_view(request,userid=0): + r = getrequestrower(request,userid=userid) + try: + thetoken = nk_open(request.user) + except NoTokenError: + return HttpResponseRedirect("/rowers/me/stravaauthorize/") + + res = nkstuff.get_nk_workout_list(request.user) + + if (res.status_code != 200): + if (res.status_code == 401): + r = getrower(request.user) + if (r.stravatoken == '') or (r.stravatoken is None): + s = "Token doesn't exist. Need to authorize" + return HttpResponseRedirect("/rowers/me/stravaauthorize/") + message = "Something went wrong in workout_stravaimport_view" + messages.error(request,message) + url = reverse('workouts_view') + return HttpResponseRedirect(url) + + # get NK IDs + + nkdata = [{ + 'id':int(item['id']), + 'elapsed_time':item['elapsedTime'], + 'start_date':item['startTime'], + } for item in res.json()] + + workouts = [] + + for item in res.json(): + d = int(float(item['totalDistanceGps'])) # could also be Impeller + i = item['id'] + n = item['name'] + nnn = '' + ttot = str(datetime.timedelta(seconds=int(float(item['elapsedTime'])/1000.))) + s = item['startTime'] + keys = ['id','distance','duration','starttime','name','new'] + values = [i,d,ttot,s,n,nnn] + res = dict(zip(keys, values)) + workouts.append(res) + + breadcrumbs = [ + { + 'url':'/rowers/list-workouts/', + 'name':'Workouts' + }, + { + 'url':reverse('workout_nkimport_view'), + 'name':'Strava' + }, + ] + + return render(request,'nk_list_import.html', + { + 'workouts':workouts, + 'rower':r, + 'active':'nav-workouts', + 'breadcrumbs':breadcrumbs, + 'teams':get_my_teams(request.user) + }) # Process Strava Callback @login_required() diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 88b7f36b..b323beb1 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -154,7 +154,9 @@ import os,sys import datetime import iso8601 import rowers.c2stuff as c2stuff +import rowers.nkstuff as nkstuff from rowers.c2stuff import c2_open +from rowers.nkstuff import nk_open from rowers.rp3stuff import rp3_open from rowers.runkeeperstuff import runkeeper_open from rowers.sporttracksstuff import sporttracks_open @@ -186,7 +188,7 @@ from rowsandall_app.settings import ( BRAINTREE_MERCHANT_ID,BRAINTREE_PUBLIC_KEY,BRAINTREE_PRIVATE_KEY, PAYMENT_PROCESSING_ON, RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_SECRET, - NK_REDIRECT_URI, NK_ClIENT_ID, NK_CLIENT_SECRET + NK_REDIRECT_URI, NK_CLIENT_ID, NK_CLIENT_SECRET ) #from rowers.tasks_standalone import addcomment2 diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 7fd1c457..f6c45d9d 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -329,9 +329,10 @@ RP3_REDIRECT_URI = CFG["rp3_redict_uri"] RP3_CLIENT_KEY = RP3_CLIENT_ID # Nielsen-Kellerman -NK_ClIENT_ID = CFG["nk_client_id"] +NK_CLIENT_ID = CFG["nk_client_id"] NK_CLIENT_SECRET = CFG["nk_client_secret"] NK_REDIRECT_URI = CFG["nk_redirect_uri"] +NK_API_LOCATION = CFG["nk_api_location"] # Full Site URL SITE_URL = CFG['site_url'] From e9b39d83506f1eda49c6d5979ce1476ee4a2631b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 31 Mar 2021 07:41:00 +0200 Subject: [PATCH 04/18] small progress on nk link --- rowers/imports.py | 2 +- rowers/models.py | 1 + rowers/nkstuff.py | 37 ++++++++++++++++++++++++++++++++++++- rowers/views/importviews.py | 10 +++++++--- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/rowers/imports.py b/rowers/imports.py index 821300fc..3aedd9db 100644 --- a/rowers/imports.py +++ b/rowers/imports.py @@ -266,7 +266,7 @@ def imports_get_token( except (ValueError,TypeError): expires_in = 0 else: - return [0,0,0] + return [0,response.text,0] return [thetoken,expires_in,refresh_token] diff --git a/rowers/models.py b/rowers/models.py index c93db7fb..05200a7e 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -3187,6 +3187,7 @@ class Workout(models.Model): uploadedtorunkeeper = models.BigIntegerField(default=0) uploadedtogarmin = models.BigIntegerField(default=0) uploadedtorp3 = models.BigIntegerField(default=0) + uploadedtonk = models.BigIntegerField(default=0) forceunit = models.CharField(default='lbs', choices = ( ('lbs','lbs'), diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index f5dbfb61..37d91aba 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -12,6 +12,8 @@ import requests #https://oauth-stage.nkrowlink.com/oauth/authorizegrant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=fc8fc3d8-ce0a-443e-838a-1c06fb5317c6&redirect_uri=https%3A%2F%2Fdunav.ngrok.io%2Fnk_callback%2F #https://oauth-stage.nkrowlink.com/oauth/authorize?grant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=1234&redirect_uri=https%3A%2F%2Fdev.rowsandall.com%2Fnk_callback +from requests_oauthlib import OAuth2Session + import django_rq queue = django_rq.get_queue('default') queuelow = django_rq.get_queue('low') @@ -48,11 +50,22 @@ oauth_data = { 'expirydatename': 'nktokenexpirydate', 'bearer_auth': True, 'base_url': "https://oauth-stage.nkrowlink.com/oauth/token", - 'grant_type': 'refresh_token', 'scope':'read', } def get_token(code): + #client_id = oauth_data['client_id'] + #client_secret = oauth_data['client_secret'] + #base_uri = oauth_data['base_url'] + + #callbackuri = 'https://'+callbackuri[9:] + #print(callbackuri) + + #nk = OAuth2Session(client_id) + #token = nk.fetch_token(base_uri,client_secret=client_secret,authorization_response=callbackuri) + + #print(token) + #return [0,0,0] return imports_get_token(code, oauth_data) def nk_open(user): @@ -105,3 +118,25 @@ def get_nk_workout_list(user,fake=False): s = requests.get(url,headers=headers,params=params) return s + +def get_nk_workout_strokes(user,nkid): + r = Rower.objects.get(user=user) + + if (r.nktoken == '') or (r.nktoken is None): + return custom_exception_handler(401,s) + s = "Token doesn't exist. Need to authorize" + elif (timezone.now()>r.tokenexpirydate): + s = "Token expired. Needs to refresh." + return custom_exception_handler(401,s) + else: + # ready to fetch. Hurray + authorizationstring = str('Bearer ' + r.nktoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + url = "https://log.concept2.com/api/users/me/results/"+str(nkid)+"/strokes" + s = requests.get(url,headers=headers) + + return s +# +#def get_workout(user,nkid): diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 7e1a86d7..8be07e00 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -401,7 +401,7 @@ def rower_nk_authorize(request): params = { "grant_type": "authorization_code", "response_type": "code", - "client_id": NK_ClIENT_ID, + "client_id": NK_CLIENT_ID, "scope": scope, "state": state, "redirect_uri": NK_REDIRECT_URI, @@ -776,10 +776,11 @@ def rower_process_garmincallback(request): # Process NK Callback @login_required() def rower_process_nkcallback(request): + uri = request.build_absolute_uri() # do stuff try: - code = request.get['code'] - res = nkstuff.get_token['code'] + code = request.GET.get('code',None) + res = nkstuff.get_token(code) except MultiValueDictKeyError: message = "The resource owner or authorization server denied the request" messages.error(request,message) @@ -787,6 +788,9 @@ def rower_process_nkcallback(request): url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) + uri = request.build_absolute_uri() + + print(res) access_token = res[0] if access_token == 0: message = res[1] From af4dc4bc29434cd09d0f9d66cc1901def61f3e12 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 31 Mar 2021 13:59:30 +0200 Subject: [PATCH 05/18] still not working token exchagen --- rowers/imports.py | 1 + rowers/nkstuff.py | 20 +++++++++++++++----- rowers/views/importviews.py | 7 ++++++- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/rowers/imports.py b/rowers/imports.py index 3aedd9db..cb1cb83f 100644 --- a/rowers/imports.py +++ b/rowers/imports.py @@ -201,6 +201,7 @@ def imports_get_token( code,oauth_data ): + redirect_uri = oauth_data['redirect_uri'] client_secret = oauth_data['client_secret'] client_id = oauth_data['client_id'] diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index 37d91aba..f79ec9e0 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -53,20 +53,30 @@ oauth_data = { 'scope':'read', } -def get_token(code): +def get_token(code,callbackuri): #client_id = oauth_data['client_id'] #client_secret = oauth_data['client_secret'] #base_uri = oauth_data['base_url'] - #callbackuri = 'https://'+callbackuri[9:] - #print(callbackuri) - #nk = OAuth2Session(client_id) #token = nk.fetch_token(base_uri,client_secret=client_secret,authorization_response=callbackuri) #print(token) #return [0,0,0] - return imports_get_token(code, oauth_data) + url = oauth_data['base_url'] + data = {"client_id": oauth_data['client_id'], + "grant_type": "authorization_code", + "redirect_uri": oauth_data['redirect_uri'], + "client_secret": oauth_data['client_secret'], + "code": code, +# "scope":oauth_data['scope'], + } + #url = url+'?'+urllib.parse.urlencode(params) + print(url) + response = requests.post(url,data=data) + print(response.reason) + return [0,response.text,0] + #return imports_get_token(code, oauth_data) def nk_open(user): t = time.localtime() diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 8be07e00..33f52fe3 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -777,10 +777,15 @@ def rower_process_garmincallback(request): @login_required() def rower_process_nkcallback(request): uri = request.build_absolute_uri() + uri = 'https://dunav.ngrok.io/'+uri[22:] + print(uri) + print(request.GET.get('code')) + raise ValueError + # do stuff try: code = request.GET.get('code',None) - res = nkstuff.get_token(code) + res = nkstuff.get_token(code,uri) except MultiValueDictKeyError: message = "The resource owner or authorization server denied the request" messages.error(request,message) From bffefdb12646a13f6396d94096cb7c4160bf6aad Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 31 Mar 2021 16:41:52 +0200 Subject: [PATCH 06/18] fix --- rowers/nkstuff.py | 22 +++++++++++++++++----- rowers/views/importviews.py | 3 --- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index f79ec9e0..6c680a6e 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -53,6 +53,9 @@ oauth_data = { 'scope':'read', } +import http.client as http_client +http_client.HTTPConnection.debuglevel = 1 + def get_token(code,callbackuri): #client_id = oauth_data['client_id'] #client_secret = oauth_data['client_secret'] @@ -64,17 +67,26 @@ def get_token(code,callbackuri): #print(token) #return [0,0,0] url = oauth_data['base_url'] - data = {"client_id": oauth_data['client_id'], + + headers = {'Accept': 'application/json', + #'Api-Key': oauth_data['client_id'], + #'Content-Type': 'application/x-www-form-urlencoded', + #'user-agent': 'sanderroosendaal' + } + + post_data = {"client_id": oauth_data['client_id'], "grant_type": "authorization_code", "redirect_uri": oauth_data['redirect_uri'], "client_secret": oauth_data['client_secret'], "code": code, + #"response_type": "code", # "scope":oauth_data['scope'], } - #url = url+'?'+urllib.parse.urlencode(params) - print(url) - response = requests.post(url,data=data) - print(response.reason) + + + + response = requests.post(url,data=json.dumps(post_data),headers=headers) + print('Status Code',response.status_code) return [0,response.text,0] #return imports_get_token(code, oauth_data) diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 33f52fe3..9a689123 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -779,8 +779,6 @@ def rower_process_nkcallback(request): uri = request.build_absolute_uri() uri = 'https://dunav.ngrok.io/'+uri[22:] print(uri) - print(request.GET.get('code')) - raise ValueError # do stuff try: @@ -795,7 +793,6 @@ def rower_process_nkcallback(request): uri = request.build_absolute_uri() - print(res) access_token = res[0] if access_token == 0: message = res[1] From 5c6ed8ef163c75dd5047df85bc4a52b5d655c11d Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Thu, 1 Apr 2021 09:40:01 +0200 Subject: [PATCH 07/18] workout list nk import working --- rowers/models.py | 1 + rowers/nkstuff.py | 98 +++++++++++++++------- rowers/templates/rower_exportsettings.html | 3 + rowers/views/importviews.py | 21 ++--- 4 files changed, 85 insertions(+), 38 deletions(-) diff --git a/rowers/models.py b/rowers/models.py index 05200a7e..616a6216 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1001,6 +1001,7 @@ class Rower(models.Model): nktokenexpirydate = models.DateTimeField(blank=True,null=True) nkrefreshtoken = models.TextField(default='',max_length=1000, blank=True,null=True) + nk_owner_id = models.BigIntegerField(default=0) trainingpeaks_auto_export = models.BooleanField(default=False) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index 6c680a6e..b8173755 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -53,51 +53,84 @@ oauth_data = { 'scope':'read', } -import http.client as http_client -http_client.HTTPConnection.debuglevel = 1 +from requests.auth import HTTPBasicAuth -def get_token(code,callbackuri): - #client_id = oauth_data['client_id'] - #client_secret = oauth_data['client_secret'] - #base_uri = oauth_data['base_url'] - - #nk = OAuth2Session(client_id) - #token = nk.fetch_token(base_uri,client_secret=client_secret,authorization_response=callbackuri) - - #print(token) - #return [0,0,0] +def get_token(code): url = oauth_data['base_url'] + headers = {'Accept': 'application/json', - #'Api-Key': oauth_data['client_id'], - #'Content-Type': 'application/x-www-form-urlencoded', + #'Authorization': auth_header, + 'Content-Type': 'application/x-www-form-urlencoded', #'user-agent': 'sanderroosendaal' } + + post_data = {"client_id": oauth_data['client_id'], "grant_type": "authorization_code", "redirect_uri": oauth_data['redirect_uri'], - "client_secret": oauth_data['client_secret'], "code": code, - #"response_type": "code", -# "scope":oauth_data['scope'], } + response = requests.post(url,auth=HTTPBasicAuth(oauth_data['client_id'],oauth_data['client_secret']), + data=post_data) + if response.status_code != 200: + return [0,response.text,0,0] + + token_json = response.json() + + access_token = token_json['access_token'] + refresh_token = token_json['refresh_token'] + expires_in = token_json['expires_in'] + nk_owner_id = token_json['user_id'] + + return [access_token, expires_in, refresh_token,nk_owner_id] - response = requests.post(url,data=json.dumps(post_data),headers=headers) - print('Status Code',response.status_code) - return [0,response.text,0] - #return imports_get_token(code, oauth_data) def nk_open(user): - t = time.localtime() - timestamp = time.strftime('%b-%d-%Y_%H%M', t) - token = imports_open(user,oauth_data) - return token + r = Rower.objects.get(user=user) + print(r.nktokenexpirydate) + if (r.nktoken == '') or (r.nktoken is None): + s = "Token doesn't exist. Need to authorize" + raise NoTokenError("User has no token") + else: + if (timezone.now()>r.nktokenexpirydate): + thetoken = rower_nk_token_refresh(user) + if thetoken == None: + raise NoTokenError("User has no token") + return thetoken + else: + thetoken = r.nktoken + + return thetoken def do_refresh_token(refreshtoken): - return imports_do_refresh_token(refreshtoken, oauth_data) + post_data = {"grant_type": "refresh_token", + #"client_id":NK_CLIENT_ID, + "refresh_token": refreshtoken, + } + + url = oauth_data['base_url'] + + response = requests.post(url,data=post_data,auth=HTTPBasicAuth(oauth_data['client_id'],oauth_data['client_secret'])) + + print(response.request.headers) + print(post_data) + print(response.status_code,response.text) + + if response.status_code != 200: + return [0,0,0] + + token_json = response.json() + + access_token = token_json['access_token'] + refresh_token = token_json['refresh_token'] + expires_in = token_json['expires_in'] + + return access_token, expires_in, refresh_token + def rower_nk_token_refresh(user): r = Rower.objects.get(user=user) @@ -128,16 +161,25 @@ def get_nk_workout_list(user,fake=False): return custom_exception_handler(401,s) else: # ready to fetch. Hurray + endTime = int(arrow.now().timestamp())*1000 + endTime = str(endTime) + startTime = arrow.now()-timedelta(days=90)*1000 + startTime = str(int(startTime.timestamp())) authorizationstring = str('Bearer ' + r.nktoken) headers = {'Authorization': authorizationstring, 'user-agent': 'sanderroosendaal', - 'Content-Type': 'application/json'} + 'Content-Type': 'application/json', + } url = NK_API_LOCATION+"api/v1/sessions" - params = {} # start / end time + params = { + 'startTime':startTime, + 'endTime':endTime, + } # start / end time s = requests.get(url,headers=headers,params=params) + #print(s.status_code,s.json()) return s diff --git a/rowers/templates/rower_exportsettings.html b/rowers/templates/rower_exportsettings.html index 06b3a874..6cabf41f 100644 --- a/rowers/templates/rower_exportsettings.html +++ b/rowers/templates/rower_exportsettings.html @@ -11,6 +11,9 @@ {% if rower.c2token is not None and rower.c2token != '' %} Concept2 Logbook, {% endif %} + {% if rower.nktoken is not None and rower.nktoken != '' %} + NK LiNK, + {% endif %} {% if rower.sporttrackstoken is not None and rower.sporttrackstoken != '' %} SportTracks, {% endif %} diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 9a689123..d5fb53d1 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -776,14 +776,10 @@ def rower_process_garmincallback(request): # Process NK Callback @login_required() def rower_process_nkcallback(request): - uri = request.build_absolute_uri() - uri = 'https://dunav.ngrok.io/'+uri[22:] - print(uri) - # do stuff try: code = request.GET.get('code',None) - res = nkstuff.get_token(code,uri) + res = nkstuff.get_token(code) except MultiValueDictKeyError: message = "The resource owner or authorization server denied the request" messages.error(request,message) @@ -791,8 +787,6 @@ def rower_process_nkcallback(request): url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) - uri = request.build_absolute_uri() - access_token = res[0] if access_token == 0: message = res[1] @@ -805,12 +799,14 @@ def rower_process_nkcallback(request): expires_in = res[1] refresh_token = res[2] + nk_owner_id = res[3] expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) r = getrower(request.user) r.nktoken = access_token r.nktokenexpirydate = expirydatetime r.nkrefreshtoken = refresh_token + r.nk_owner_id = nk_owner_id r.save() @@ -836,7 +832,7 @@ def workout_nkimport_view(request,userid=0): if (r.stravatoken == '') or (r.stravatoken is None): s = "Token doesn't exist. Need to authorize" return HttpResponseRedirect("/rowers/me/stravaauthorize/") - message = "Something went wrong in workout_stravaimport_view" + message = "Something went wrong in workout_nkimport_view" messages.error(request,message) url = reverse('workouts_view') return HttpResponseRedirect(url) @@ -846,7 +842,7 @@ def workout_nkimport_view(request,userid=0): nkdata = [{ 'id':int(item['id']), 'elapsed_time':item['elapsedTime'], - 'start_date':item['startTime'], + 'start_date':arrow.get(item['startTime']), } for item in res.json()] workouts = [] @@ -857,12 +853,17 @@ def workout_nkimport_view(request,userid=0): n = item['name'] nnn = '' ttot = str(datetime.timedelta(seconds=int(float(item['elapsedTime'])/1000.))) - s = item['startTime'] + s = arrow.get(item['startTime'],tzinfo=r.defaulttimezone).format(arrow.FORMAT_W3C) + #s = arrow.get(item['startTime']).to(r.defaulttimezone).isoformat() keys = ['id','distance','duration','starttime','name','new'] values = [i,d,ttot,s,n,nnn] res = dict(zip(keys, values)) workouts.append(res) + workouts = workouts[::-1] + + print(workouts) + breadcrumbs = [ { 'url':'/rowers/list-workouts/', From 047035f62e800371d295a97dc0564862b677d89c Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Thu, 1 Apr 2021 11:10:51 +0200 Subject: [PATCH 08/18] passing tests for nk_list --- rowers/nkstuff.py | 12 +++++------- rowers/templates/menu_workouts.html | 1 + rowers/templates/nk_list_import.html | 4 ++-- rowers/tests/mocks.py | 7 +++++++ rowers/tests/test_imports.py | 2 +- rowers/views/importviews.py | 8 +++----- 6 files changed, 19 insertions(+), 15 deletions(-) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index b8173755..0c661a55 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -97,6 +97,7 @@ def nk_open(user): raise NoTokenError("User has no token") else: if (timezone.now()>r.nktokenexpirydate): + print('refreshing token') thetoken = rower_nk_token_refresh(user) if thetoken == None: raise NoTokenError("User has no token") @@ -115,10 +116,7 @@ def do_refresh_token(refreshtoken): url = oauth_data['base_url'] response = requests.post(url,data=post_data,auth=HTTPBasicAuth(oauth_data['client_id'],oauth_data['client_secret'])) - - print(response.request.headers) - print(post_data) - print(response.status_code,response.text) + print(response.status_code) if response.status_code != 200: return [0,0,0] @@ -152,11 +150,11 @@ def make_authorization_url(request): def get_nk_workout_list(user,fake=False): r = Rower.objects.get(user=user) - + print(r.nktoken,r.nktokenexpirydate) if (r.nktoken == '') or (r.nktoken is None): s = "Token doesn't exist. Need to authorize" return custom_exception_handler(401,s) - elif (r.nktokenexpirydate is None or timezone.now()+timedelta(seconds=3599)>r.nktokenexpirydate): + elif (r.nktokenexpirydate is None or timezone.now()+timedelta(seconds=10)>r.nktokenexpirydate): s = "Token expired. Needs to refresh." return custom_exception_handler(401,s) else: @@ -179,7 +177,7 @@ def get_nk_workout_list(user,fake=False): } # start / end time s = requests.get(url,headers=headers,params=params) - #print(s.status_code,s.json()) + return s diff --git a/rowers/templates/menu_workouts.html b/rowers/templates/menu_workouts.html index aea1c84f..73c22bdf 100644 --- a/rowers/templates/menu_workouts.html +++ b/rowers/templates/menu_workouts.html @@ -38,6 +38,7 @@
- Concept2
+- NK LiNK
- Strava
- RunKeeper
- SportTracks
diff --git a/rowers/templates/nk_list_import.html b/rowers/templates/nk_list_import.html index 44ae0554..b3632e6d 100644 --- a/rowers/templates/nk_list_import.html +++ b/rowers/templates/nk_list_import.html @@ -34,8 +34,8 @@Import -{{ workout|lookup:'name' }} -{{ workout|lookup:'starttime' }} +{{ workout|lookuplong:'name' }} +{{ workout|lookuplong:'starttime' }} {{ workout|lookup:'duration' }} {{ workout|lookup:'distance' }} m {{ workout|lookup:'new' }} diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 79fe3065..05c347ec 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -918,6 +918,13 @@ def mocked_requests(*args, **kwargs): if nktester.match(args[0]): + if 'token' in args[0]: + json_data = { + 'access_token': 'TA3n1vrNjuQJWw0TdCDHnjSmrjIPULhTlejMIWqq', + 'expires_in': 604800, + 'refresh_token': 'jHJhFzCfOOKB8oyiayubhLAlxaMkG3ruC1E8YxaR' + } + return MockResponse(json_data,200) if nkworkoutlisttester.match(args[0]): return MockResponse(nkworkoutlist,200) elif 'token' in args[0]: diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index 9b0fb73c..e27fe2ce 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -419,7 +419,7 @@ class NKObjects(DjangoTestCase): @patch('rowers.nkstuff.requests.post', side_effect=mocked_requests) def test_nk_list(self, mock_get, mockpost): result = rowers.nkstuff.rower_nk_token_refresh(self.u) - self.assertEqual(result,"987654321234567898765432123456789") + self.assertEqual(result,"TA3n1vrNjuQJWw0TdCDHnjSmrjIPULhTlejMIWqq") response = self.c.get('/rowers/workout/nkimport/') self.assertEqual(response.status_code,200) diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index d5fb53d1..556353ee 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -822,7 +822,7 @@ def workout_nkimport_view(request,userid=0): try: thetoken = nk_open(request.user) except NoTokenError: - return HttpResponseRedirect("/rowers/me/stravaauthorize/") + return HttpResponseRedirect("/rowers/me/nkauthorize/") res = nkstuff.get_nk_workout_list(request.user) @@ -831,7 +831,7 @@ def workout_nkimport_view(request,userid=0): r = getrower(request.user) if (r.stravatoken == '') or (r.stravatoken is None): s = "Token doesn't exist. Need to authorize" - return HttpResponseRedirect("/rowers/me/stravaauthorize/") + return HttpResponseRedirect("/rowers/me/nkauthorize/") message = "Something went wrong in workout_nkimport_view" messages.error(request,message) url = reverse('workouts_view') @@ -853,7 +853,7 @@ def workout_nkimport_view(request,userid=0): n = item['name'] nnn = '' ttot = str(datetime.timedelta(seconds=int(float(item['elapsedTime'])/1000.))) - s = arrow.get(item['startTime'],tzinfo=r.defaulttimezone).format(arrow.FORMAT_W3C) + s = arrow.get(item['startTime'],tzinfo=r.defaulttimezone).format(arrow.FORMAT_RFC850) #s = arrow.get(item['startTime']).to(r.defaulttimezone).isoformat() keys = ['id','distance','duration','starttime','name','new'] values = [i,d,ttot,s,n,nnn] @@ -862,8 +862,6 @@ def workout_nkimport_view(request,userid=0): workouts = workouts[::-1] - print(workouts) - breadcrumbs = [ { 'url':'/rowers/list-workouts/', From c4b20fb9e054197d77de59c4d8886f37b307e8dc Mon Sep 17 00:00:00 2001 From: Sander RoosendaalDate: Thu, 1 Apr 2021 21:01:55 +0200 Subject: [PATCH 09/18] starting to work on nkstuff.get_workout --- rowers/nkstuff.py | 71 +++++++++++++++++++++++++----------- rowers/tests/mocks.py | 21 ++++++----- rowers/tests/test_imports.py | 15 ++++++++ rowers/views/importviews.py | 5 ++- 4 files changed, 80 insertions(+), 32 deletions(-) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index 0c661a55..bdbbb84d 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -116,7 +116,6 @@ def do_refresh_token(refreshtoken): url = oauth_data['base_url'] response = requests.post(url,data=post_data,auth=HTTPBasicAuth(oauth_data['client_id'],oauth_data['client_secret'])) - print(response.status_code) if response.status_code != 200: return [0,0,0] @@ -150,7 +149,7 @@ def make_authorization_url(request): def get_nk_workout_list(user,fake=False): r = Rower.objects.get(user=user) - print(r.nktoken,r.nktokenexpirydate) + if (r.nktoken == '') or (r.nktoken is None): s = "Token doesn't exist. Need to authorize" return custom_exception_handler(401,s) @@ -181,24 +180,54 @@ def get_nk_workout_list(user,fake=False): return s -def get_nk_workout_strokes(user,nkid): - r = Rower.objects.get(user=user) - - if (r.nktoken == '') or (r.nktoken is None): - return custom_exception_handler(401,s) - s = "Token doesn't exist. Need to authorize" - elif (timezone.now()>r.tokenexpirydate): - s = "Token expired. Needs to refresh." - return custom_exception_handler(401,s) - else: - # ready to fetch. Hurray - authorizationstring = str('Bearer ' + r.nktoken) - headers = {'Authorization': authorizationstring, - 'user-agent': 'sanderroosendaal', - 'Content-Type': 'application/json'} - url = "https://log.concept2.com/api/users/me/results/"+str(nkid)+"/strokes" - s = requests.get(url,headers=headers) - - return s # #def get_workout(user,nkid): +def getdict(x): + try: + return x[0] + except KeyError: + pass + + return {} + +def get_workout(user,nkid): + print('aap') + r = Rower.objects.get(user=user) + if (r.nktoken == '') or (r.nktoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) ,0 + elif (timezone.now()>r.nktokenexpirydate): + s = "Token expired. Needs to refresh." + return custom_exception_handler(401,s),0 + + params = { + 'sessionIds': nkid, + } + + authorizationstring = str('Bearer ' + r.nktoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json', + } + + + + # get strokes + url = NK_API_LOCATION+"api/v1/sessions/strokes" + response = requests.get(url,headers=headers,params=params) + if response != 200: + # error handling + pass + + data = response.json()[str(nkid)] + + df = pd.DataFrame.from_dict(data) + ol = df['oarlockStrokes'] + ol = ol.apply(lambda x:getdict(x)) + df2 = pd.DataFrame.from_records(ol.values) + df2 = df2.rename(colunms={"timestamp":"ts"}) + df = pd.concat([df,df2],axis=1) + + # get workout data + + return 0,0 diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 05c347ec..102f19f3 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -593,6 +593,12 @@ def mocked_requests(*args, **kwargs): with open('rowers/tests/testdata/c2jsonstrokedata.txt','r') as infile: c2strokedata = json.load(infile) + with open('rowers/tests/testdata/nk_strokes.json','r') as infile: + nkstrokedata = json.load(infile) + + with open('rowers/tests/testdata/nk_list.json','r') as infile: + nkworkoutlist = json.load(infile) + polar_json = { 'available-user-data': [] } @@ -621,7 +627,7 @@ def mocked_requests(*args, **kwargs): stravaworkoutlist = json.load(open('rowers/tests/testdata/stravaworkoutlist.txt')) sporttracksworkoutlist = json.load(open('rowers/tests/testdata/sporttracksworkouts.txt')) - nkworkoutlist = json.load(open('rowers/tests/testdata/nkworkouts.txt')) + #nkworkoutlist = json.load(open('rowers/tests/testdata/nkworkouts.txt')) rkworkoutlistjson = json.load(open('rowers/tests/testdata/rkworkoutslist.txt','r')) uaworkoutlistjson = json.load(open('rowers/tests/testdata/uaworkoutlist.txt','r')) @@ -752,6 +758,9 @@ def mocked_requests(*args, **kwargs): nkworkoutlistregex = '.*?nkrowlink\.com\/api\/v1\/sessions' nkworkoutlisttester = re.compile(nkworkoutlistregex) + nkstrokesregex = '.*?nkrowlink\.com\/api\/v1\/strokes\?sessionIds=\d' + nkstrokestester = re.compile(nkstrokesregex) + stravaathleteregex = '.*?strava\.com\/api\/v3\/athlete$' stravaathletetester = re.compile(stravaathleteregex) @@ -925,17 +934,11 @@ def mocked_requests(*args, **kwargs): 'refresh_token': 'jHJhFzCfOOKB8oyiayubhLAlxaMkG3ruC1E8YxaR' } return MockResponse(json_data,200) + if nkstrokestester.match(args[0]): + return MockResponse(nkstrokedata,200) if nkworkoutlisttester.match(args[0]): return MockResponse(nkworkoutlist,200) - elif 'token' in args[0]: - json_data = { - "token_type": "Bearer", - "access_token": "987654321234567898765432123456789", - "refresh_token": "1234567898765432112345678987654321", - "expires_at": arrow.now().timestamp()+7200 - } - return MockResponse(json_data,200) if stravatester.match(args[0]): diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index e27fe2ce..9da148b9 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -424,6 +424,21 @@ class NKObjects(DjangoTestCase): self.assertEqual(response.status_code,200) + @patch('rowers.nkstuff.requests.get', side_effect=mocked_requests) + @patch('rowers.nkstuff.requests.post', side_effect=mocked_requests) + @patch('rowers.dataprep.getsmallrowdata_db') + def test_nk_import(self, mock_get, mock_post, + mocked_getsmallrowdata_db): + + result = rowers.nkstuff.rower_nk_token_refresh(self.u) + response = self.c.get('/rowers/workout/nkimport/12',follow=True) + + self.assertRedirects(response, + expected_url='/rowers/workout/'+encoded2+'/edit/', + status_code=301,target_status_code=200) + + self.assertEqual(response.status_code, 200) + #@pytest.mark.django_db diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 556353ee..358f0391 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -1931,7 +1931,8 @@ importsources = { 'runkeeper':runkeeperstuff, 'sporttracks':sporttracksstuff, 'trainingpeaks':tpstuff, - 'underarmour':underarmourstuff + 'underarmour':underarmourstuff, + 'nk':nkstuff, } @login_required() @@ -1960,7 +1961,7 @@ def workout_getrp3importview(request,externalid): def workout_getimportview(request,externalid,source = 'c2'): data,strokedata = importsources[source].get_workout(request.user,externalid) if not data: - messages.error(request,res[1]) + messages.error(request,"No strokedata received") url = reverse('workouts_view') return HttpResponseRedirect(url) From 0fc6ecb70c3d2f2a2545fa3b91ae7eba2c50813a Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Fri, 2 Apr 2021 10:52:30 +0200 Subject: [PATCH 10/18] gets a valid DF out of NK --- rowers/nkstuff.py | 24 +++++++++++++++--------- rowers/tests/mocks.py | 5 ++++- rowers/tests/test_imports.py | 2 +- 3 files changed, 20 insertions(+), 11 deletions(-) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index bdbbb84d..e2c5ad0f 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -182,16 +182,17 @@ def get_nk_workout_list(user,fake=False): # #def get_workout(user,nkid): -def getdict(x): +def getdict(x, seatIndex=1): + seatStrokes = pd.DataFrame(x) try: - return x[0] + seatStrokes = seatStrokes.set_index('seatIndex') + return dict(seatStrokes.loc[seatIndex]) except KeyError: pass return {} def get_workout(user,nkid): - print('aap') r = Rower.objects.get(user=user) if (r.nktoken == '') or (r.nktoken is None): s = "Token doesn't exist. Need to authorize" @@ -219,15 +220,20 @@ def get_workout(user,nkid): # error handling pass - data = response.json()[str(nkid)] + jsonData = response.json() + #print(jsonData) + strokeData = jsonData[str(nkid)] + + df = pd.DataFrame.from_dict(strokeData) + oarlockData = df['oarlockStrokes'] + oarlockData = oarlockData.apply(lambda x:getdict(x, seatIndex=1)) + df2 = pd.DataFrame.from_records(oarlockData.values) + df2 = df2.rename(columns={"timestamp":"ts"}) - df = pd.DataFrame.from_dict(data) - ol = df['oarlockStrokes'] - ol = ol.apply(lambda x:getdict(x)) - df2 = pd.DataFrame.from_records(ol.values) - df2 = df2.rename(colunms={"timestamp":"ts"}) df = pd.concat([df,df2],axis=1) + print(df.info()) + # get workout data return 0,0 diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 102f19f3..37dba495 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -758,7 +758,7 @@ def mocked_requests(*args, **kwargs): nkworkoutlistregex = '.*?nkrowlink\.com\/api\/v1\/sessions' nkworkoutlisttester = re.compile(nkworkoutlistregex) - nkstrokesregex = '.*?nkrowlink\.com\/api\/v1\/strokes\?sessionIds=\d' + nkstrokesregex = '.*?nkrowlink\.com\/api\/v1\/sessions\/strokes' nkstrokestester = re.compile(nkstrokesregex) stravaathleteregex = '.*?strava\.com\/api\/v3\/athlete$' @@ -927,6 +927,7 @@ def mocked_requests(*args, **kwargs): if nktester.match(args[0]): + if 'token' in args[0]: json_data = { 'access_token': 'TA3n1vrNjuQJWw0TdCDHnjSmrjIPULhTlejMIWqq', @@ -935,8 +936,10 @@ def mocked_requests(*args, **kwargs): } return MockResponse(json_data,200) if nkstrokestester.match(args[0]): + return MockResponse(nkstrokedata,200) if nkworkoutlisttester.match(args[0]): + return MockResponse(nkworkoutlist,200) diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index 9da148b9..afe715e8 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -431,7 +431,7 @@ class NKObjects(DjangoTestCase): mocked_getsmallrowdata_db): result = rowers.nkstuff.rower_nk_token_refresh(self.u) - response = self.c.get('/rowers/workout/nkimport/12',follow=True) + response = self.c.get('/rowers/workout/nkimport/469',follow=True) self.assertRedirects(response, expected_url='/rowers/workout/'+encoded2+'/edit/', From ec6de056303b8a70951af1f2b874614bc91e3131 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sat, 3 Apr 2021 21:38:57 +0200 Subject: [PATCH 11/18] NK doiesnt upload well --- rowers/dataprep.py | 5 ++++- rowers/nkstuff.py | 10 +++++----- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 07778df4..9d4d7d86 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -57,7 +57,7 @@ from rowingdata import ( MysteryParser, BoatCoachOTWParser,QuiskeParser, painsledDesktopParser, speedcoachParser, ErgStickParser, SpeedCoach2Parser, FITParser, fitsummarydata, - RitmoTimeParser,KinoMapParser, + RitmoTimeParser,KinoMapParser,NKLiNKLogbookParser, make_cumvalues,cumcpdata,ExcelTemplate, summarydata, get_file_type, ) @@ -1850,10 +1850,12 @@ parsers = { 'ergdata': ErgDataParser, 'humon': HumonParser, 'eth': ETHParser, + 'nklinklogbook': NKLiNKLogbookParser, } def parsenonpainsled(fileformat,f2,summary,startdatetime=''): try: + print(fileformat) row = parsers[fileformat](f2) if startdatetime != '': row.rowdatetime = arrow.get(startdatetime).datetime @@ -2872,6 +2874,7 @@ def dataprep(rowdatadf, id=0, bands=True, barchart=True, otwpower=True, rowdatadf[' ElapsedTime (sec)'] = rowdatadf['TimeStamp (sec)'] if empower: + print('aap') try: wash = rowdatadf.loc[:, 'wash'] except KeyError: diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index e2c5ad0f..23159cc2 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -9,8 +9,8 @@ from time import strftime import requests -#https://oauth-stage.nkrowlink.com/oauth/authorizegrant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=fc8fc3d8-ce0a-443e-838a-1c06fb5317c6&redirect_uri=https%3A%2F%2Fdunav.ngrok.io%2Fnk_callback%2F -#https://oauth-stage.nkrowlink.com/oauth/authorize?grant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=1234&redirect_uri=https%3A%2F%2Fdev.rowsandall.com%2Fnk_callback +#https:#oauth-stage.nkrowlink.com/oauth/authorizegrant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=fc8fc3d8-ce0a-443e-838a-1c06fb5317c6&redirect_uri=https%3A%2F%2Fdunav.ngrok.io%2Fnk_callback%2F +#https:#oauth-stage.nkrowlink.com/oauth/authorize?grant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=1234&redirect_uri=https%3A%2F%2Fdev.rowsandall.com%2Fnk_callback from requests_oauthlib import OAuth2Session @@ -43,13 +43,13 @@ oauth_data = { 'client_id': NK_CLIENT_ID, 'client_secret': NK_CLIENT_SECRET, 'redirect_uri': NK_REDIRECT_URI, - 'autorization_uri': "https://oauth-stage.nkrowlink.com/oauth/authorize", + 'autorization_uri': "https:#oauth-stage.nkrowlink.com/oauth/authorize", 'content_type': 'application/json', 'tokenname': 'nktoken', 'refreshtokenname': 'nkrefreshtoken', 'expirydatename': 'nktokenexpirydate', 'bearer_auth': True, - 'base_url': "https://oauth-stage.nkrowlink.com/oauth/token", + 'base_url': "https:#oauth-stage.nkrowlink.com/oauth/token", 'scope':'read', } @@ -232,7 +232,7 @@ def get_workout(user,nkid): df = pd.concat([df,df2],axis=1) - print(df.info()) + # not to_csv and run upload API! # get workout data From f82c451e586c585cc1a06018256b8fd8ce10e8d2 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 4 Apr 2021 13:34:56 +0200 Subject: [PATCH 12/18] exports better csv (NKLinkLogbook) --- rowers/dataprep.py | 1 - rowers/nkstuff.py | 12 ++++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 9d4d7d86..76477ae6 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -2874,7 +2874,6 @@ def dataprep(rowdatadf, id=0, bands=True, barchart=True, otwpower=True, rowdatadf[' ElapsedTime (sec)'] = rowdatadf['TimeStamp (sec)'] if empower: - print('aap') try: wash = rowdatadf.loc[:, 'wash'] except KeyError: diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index 23159cc2..b1f8fea8 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -228,11 +228,19 @@ def get_workout(user,nkid): oarlockData = df['oarlockStrokes'] oarlockData = oarlockData.apply(lambda x:getdict(x, seatIndex=1)) df2 = pd.DataFrame.from_records(oarlockData.values) - df2 = df2.rename(columns={"timestamp":"ts"}) - df = pd.concat([df,df2],axis=1) + df.set_index('timestamp') + df2.set_index('timestamp') + df = df.merge(df2,left_index=True,right_index=True) + df = df.rename(columns={"timestamp_x":"timestamp"}) + df = df.drop('oarlockStrokes',axis=1) + df.sort_values(by='timestamp',ascending=True,inplace=True) + df.fillna(inplace=True,method='ffill') + + #df = pd.concat([df,df2],axis=1) # not to_csv and run upload API! + df.to_csv('~/Downloads/nk_logbook.csv') # get workout data From 13391c0e406a961bf362194472398d209e417611 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 4 Apr 2021 17:22:01 +0200 Subject: [PATCH 13/18] get_workout passing tests using mock upload api, need to add processing of nkid, oarlock params, etc --- rowers/dataprep.py | 1 - rowers/nkstuff.py | 96 ++++++++++++++++++++++++++++++++---- rowers/tests/mocks.py | 26 +++++++++- rowers/tests/test_imports.py | 6 ++- rowers/views/importviews.py | 1 + 5 files changed, 117 insertions(+), 13 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 76477ae6..55da70ec 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -1855,7 +1855,6 @@ parsers = { def parsenonpainsled(fileformat,f2,summary,startdatetime=''): try: - print(fileformat) row = parsers[fileformat](f2) if startdatetime != '': row.rowdatetime = arrow.get(startdatetime).datetime diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index b1f8fea8..eb42f748 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -29,7 +29,8 @@ import gzip from rowsandall_app.settings import ( NK_CLIENT_ID, NK_REDIRECT_URI, NK_CLIENT_SECRET, - SITE_URL, NK_API_LOCATION + SITE_URL, NK_API_LOCATION, + UPLOAD_SERVICE_URL, UPLOAD_SERVICE_SECRET, ) try: @@ -91,13 +92,13 @@ def get_token(code): def nk_open(user): r = Rower.objects.get(user=user) - print(r.nktokenexpirydate) + if (r.nktoken == '') or (r.nktoken is None): s = "Token doesn't exist. Need to authorize" raise NoTokenError("User has no token") else: if (timezone.now()>r.nktokenexpirydate): - print('refreshing token') + thetoken = rower_nk_token_refresh(user) if thetoken == None: raise NoTokenError("User has no token") @@ -192,6 +193,66 @@ def getdict(x, seatIndex=1): return {} +def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, + workoutsource='nklinklogbook'): + + csvfilename = 'media/{code}_{nkid}.csv.gz'.format( + nkid=nkid, + code = uuid4().hex[:16] + ) + + strokedata.to_csv(csvfilename, index_label='index', compression='gzip') + + userid = user.id + + title = data["name"] + speedInput = data["speedInput"] + elapsedTime = data["elapsedTime"] + totalDistanceGps = data["totalDistanceGps"] + totalDistanceImp = data["totalDistanceImp"] + intervals = data["intervals"] + oarlockSessions = data["oarlockSessions"] + + # oarlock inboard, length, boat name + if oarlockSessions: + oarlocksession = oarlockSessions[0] # should take seatIndex + boatName = oarlocksession["boatName"] + oarLength = oarlocksession["oarLength"] # cm + oarInboardLength = oarlocksession["oarInboardLength"] # cm + seatNumber = oarlocksession["seatNumber"] + + workouttype = "water" + boattype = "1x" + + uploadoptions = { + 'secret': UPLOAD_SERVICE_SECRET, + 'user':userid, + 'file': csvfilename, + 'title': title, + 'workouttype': workouttype, + 'boattype': boattype, + 'nkid':nkid, + } + + session = requests.session() + newHeaders = {'Content-type': 'application/json', 'Accept': 'text/plain'} + session.headers.update(newHeaders) + + response = session.post(UPLOAD_SERVICE_URL,json=uploadoptions) + + if response.status_code != 200: + return 0,response.text + + try: + workoutid = response.json()['id'] + except KeyError: + workoutid = 1 + + # evt update workout summary + + # return + return workoutid,"" + def get_workout(user,nkid): r = Rower.objects.get(user=user) if (r.nktoken == '') or (r.nktoken is None): @@ -221,7 +282,7 @@ def get_workout(user,nkid): pass jsonData = response.json() - #print(jsonData) + strokeData = jsonData[str(nkid)] df = pd.DataFrame.from_dict(strokeData) @@ -237,11 +298,28 @@ def get_workout(user,nkid): df.sort_values(by='timestamp',ascending=True,inplace=True) df.fillna(inplace=True,method='ffill') - #df = pd.concat([df,df2],axis=1) + # get workout data + timestampbegin = df['timestamp'].min() + timestampend = df['timestamp'].max() + + url = NK_API_LOCATION+"api/v1/sessions/" + params = { + 'startTime':timestampbegin-1, + 'endTime': timestampend+1, + } + response = requests.get(url, headers=headers,params=params) + + if response.status_code != 200: + # error handling + pass + + jsondata = response.json() + workoutdata = {} + for w in jsondata: + if str(w['id']) == str(nkid): + workoutdata = w # not to_csv and run upload API! - df.to_csv('~/Downloads/nk_logbook.csv') + #strokedata = df.to_json(orient='records') - # get workout data - - return 0,0 + return workoutdata, df diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 37dba495..e3f34f70 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -101,6 +101,30 @@ def mock_c2open(*args, **kwargs): print('mock') return('aap') +def mocked_session(*args, **kwargs): + + class MockEngine: + def __init__(self, *args, **kwargs): + self.headers = MockHeaders() + + def post(self, *args, **kwargs): + return MockResponse({},200) + + class MockHeaders: + def update(*args, **kwargs): + return None + + class MockResponse: + def __init__(self, json_data, status_code): + self.json_data = json_data + self.status_code = status_code + self.ok = True + + def json(self): + return self.json_data + + return MockEngine() + def mocked_sqlalchemy(*args, **kwargs): # return object with method @@ -939,7 +963,7 @@ def mocked_requests(*args, **kwargs): return MockResponse(nkstrokedata,200) if nkworkoutlisttester.match(args[0]): - + return MockResponse(nkworkoutlist,200) diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index afe715e8..c61ffa8d 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -426,15 +426,17 @@ class NKObjects(DjangoTestCase): @patch('rowers.nkstuff.requests.get', side_effect=mocked_requests) @patch('rowers.nkstuff.requests.post', side_effect=mocked_requests) - @patch('rowers.dataprep.getsmallrowdata_db') + @patch('rowers.nkstuff.requests.session', side_effect=mocked_session) + @patch('rowers.dataprep.getsmallrowdata_db', side_effect=mocked_getsmallrowdata_db) def test_nk_import(self, mock_get, mock_post, + mocked_session, mocked_getsmallrowdata_db): result = rowers.nkstuff.rower_nk_token_refresh(self.u) response = self.c.get('/rowers/workout/nkimport/469',follow=True) self.assertRedirects(response, - expected_url='/rowers/workout/'+encoded2+'/edit/', + expected_url='/rowers/workout/'+encoded1+'/edit/', status_code=301,target_status_code=200) self.assertEqual(response.status_code, 200) diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 358f0391..e1516914 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -2045,6 +2045,7 @@ def workout_getimportview(request,externalid,source = 'c2'): return HttpResponseRedirect(url) + # strokedata not empty - continue id,message = importsources[source].add_workout_from_data( request.user, From 6be21a0a8abee61a30e58a3f3bb70c89b217207b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 4 Apr 2021 19:58:04 +0200 Subject: [PATCH 14/18] also working for non oarlock workouts now - still naive implementation --- rowers/dataprep.py | 1 + rowers/nkstuff.py | 15 ++++++++++----- rowers/views/workoutviews.py | 7 +++++++ 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 55da70ec..5eca407c 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -1856,6 +1856,7 @@ parsers = { def parsenonpainsled(fileformat,f2,summary,startdatetime=''): try: row = parsers[fileformat](f2) + print(row,'jet') if startdatetime != '': row.rowdatetime = arrow.get(startdatetime).datetime hasrecognized = True diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index eb42f748..930b898b 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -44,13 +44,13 @@ oauth_data = { 'client_id': NK_CLIENT_ID, 'client_secret': NK_CLIENT_SECRET, 'redirect_uri': NK_REDIRECT_URI, - 'autorization_uri': "https:#oauth-stage.nkrowlink.com/oauth/authorize", + 'autorization_uri': "https://oauth-stage.nkrowlink.com/oauth/authorize", 'content_type': 'application/json', 'tokenname': 'nktoken', 'refreshtokenname': 'nkrefreshtoken', 'expirydatename': 'nktokenexpirydate', 'bearer_auth': True, - 'base_url': "https:#oauth-stage.nkrowlink.com/oauth/token", + 'base_url': "https://oauth-stage.nkrowlink.com/oauth/token", 'scope':'read', } @@ -287,13 +287,17 @@ def get_workout(user,nkid): df = pd.DataFrame.from_dict(strokeData) oarlockData = df['oarlockStrokes'] + oarlockData = oarlockData.apply(lambda x:getdict(x, seatIndex=1)) df2 = pd.DataFrame.from_records(oarlockData.values) df.set_index('timestamp') - df2.set_index('timestamp') - df = df.merge(df2,left_index=True,right_index=True) - df = df.rename(columns={"timestamp_x":"timestamp"}) + + if not df2.empty: + df2.set_index('timestamp') + df = df.merge(df2,left_index=True,right_index=True) + df = df.rename(columns={"timestamp_x":"timestamp"}) + df = df.drop('oarlockStrokes',axis=1) df.sort_values(by='timestamp',ascending=True,inplace=True) df.fillna(inplace=True,method='ffill') @@ -321,5 +325,6 @@ def get_workout(user,nkid): # not to_csv and run upload API! #strokedata = df.to_json(orient='records') + #df.to_csv('~/Downloads/nk_logbook.csv') return workoutdata, df diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py index 6553b0fa..ca46185e 100644 --- a/rowers/views/workoutviews.py +++ b/rowers/views/workoutviews.py @@ -4870,6 +4870,7 @@ def workout_upload_api(request): q = request.POST post_data = {k: q.getlist(k) if len(q.getlist(k))>1 else v for k, v in q.items()} + # only allow local host hostt = request.get_host().split(':') if hostt[0] not in ['localhost','127.0.0.1','dev.rowsandall.com','rowsandall.com']: @@ -4916,6 +4917,12 @@ def workout_upload_api(request): except KeyError: c2id = '' + try: + nkid = post_data['nkid'] + except KeyError: + nkid = '' + + try: rp3id = post_data['rp3id'] except KeyError: From 18c59d0fe47fde2e41ba72ad4c432e38a72cc9e2 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 5 Apr 2021 06:37:28 +0200 Subject: [PATCH 15/18] imports nk incl summary stats --- rowers/dataprep.py | 1 - rowers/models.py | 3 + rowers/nkstuff.py | 143 ++++++++++++++++++++++++++-- rowers/templates/menu_workout.html | 7 ++ rowers/templatetags/rowerfilters.py | 7 ++ rowers/uploads.py | 7 ++ rowers/urls.py | 3 +- rowers/views/importviews.py | 6 ++ rowers/views/workoutviews.py | 15 +++ 9 files changed, 180 insertions(+), 12 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 5eca407c..55da70ec 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -1856,7 +1856,6 @@ parsers = { def parsenonpainsled(fileformat,f2,summary,startdatetime=''): try: row = parsers[fileformat](f2) - print(row,'jet') if startdatetime != '': row.rowdatetime = arrow.get(startdatetime).datetime hasrecognized = True diff --git a/rowers/models.py b/rowers/models.py index 616a6216..9256b262 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1002,6 +1002,7 @@ class Rower(models.Model): nkrefreshtoken = models.TextField(default='',max_length=1000, blank=True,null=True) nk_owner_id = models.BigIntegerField(default=0) + nk_auto_import = models.BooleanField(default=False) trainingpeaks_auto_export = models.BooleanField(default=False) @@ -3268,6 +3269,7 @@ class TombStone(models.Model): uploadedtounderarmour = models.BigIntegerField(default=0) uploadedtotp = models.BigIntegerField(default=0) uploadedtorunkeeper = models.BigIntegerField(default=0) + uploadedtonk = models.BigIntegerField(default=0) @receiver(models.signals.pre_delete,sender=Workout) def create_tombstone_on_delete(sender, instance, **kwargs): @@ -3278,6 +3280,7 @@ def create_tombstone_on_delete(sender, instance, **kwargs): uploadedtounderarmour = instance.uploadedtounderarmour, uploadedtotp = instance.uploadedtotp, uploadedtorunkeeper = instance.uploadedtorunkeeper, + uploadedtonk = instance.uploadedtonk ) t.save() diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index 930b898b..f55aeef5 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -210,8 +210,11 @@ def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, elapsedTime = data["elapsedTime"] totalDistanceGps = data["totalDistanceGps"] totalDistanceImp = data["totalDistanceImp"] - intervals = data["intervals"] + intervals = data["intervals"] # add intervals oarlockSessions = data["oarlockSessions"] + deviceId = data["deviceId"] # you could get the firmware version + + summary = get_nk_allstats(data,strokedata) # oarlock inboard, length, boat name if oarlockSessions: @@ -220,6 +223,11 @@ def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, oarLength = oarlocksession["oarLength"] # cm oarInboardLength = oarlocksession["oarInboardLength"] # cm seatNumber = oarlocksession["seatNumber"] + else: + boatName = '' + oarLength = 289 + oarInboardLength = 88 + seatNumber = 1 workouttype = "water" boattype = "1x" @@ -232,6 +240,9 @@ def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, 'workouttype': workouttype, 'boattype': boattype, 'nkid':nkid, + 'inboard': oarInboardLength/100., + 'oarlength': oarLength/100., + 'summary':summary, } session = requests.session() @@ -248,11 +259,125 @@ def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, except KeyError: workoutid = 1 + # evt update workout summary # return return workoutid,"" + +def get_nk_intervalstats(workoutdata,strokedata): + intervals = workoutdata['intervals'] + separator = "|" + stri = "Workout Details\n" + stri += "#-{sep}SDist{sep}-Split-{sep}-SPace-{sep}-Pwr-{sep}-SPM--{sep}-AvgHR-{sep}DPS-\n".format( + sep=separator) + + i = 0 + + for interval in intervals: + id = interval['id'] + sdist = interval['totalDistanceGps'] + avgpace = interval['avgPaceGps']/1000. + avgpacetd = timedelta(seconds=avgpace) + newpacestring = dataprep.strfdelta(avgpacetd) + + elapsedSeconds = interval['elapsedTime']/1000. # secs + elapsedTime = datetime.utcfromtimestamp(elapsedSeconds) + + newsplitstring = "%s.%i" % (elapsedTime.strftime("%M:%S"), elapsedTime.microsecond/100000) + + pwr = interval['avgPower'] + avghr = interval['avgHeartRate'] + spm = interval['avgStrokeRate'] + dps = interval['distStrokeGps'] + + stri += "{i:0>2}{sep}{sdist:0>5}{sep}{split}{sep}{space}{sep} {pwr:0>3} {sep}".format( + i=i + 1, + sdist=int(float(sdist)), + split=newsplitstring, + space=newpacestring, + pwr=int(pwr), + sep=separator, + ) + stri += " {spm} {sep} {avghr:0>3} {sep}{dps:0>4.1f}\n".format( + sep=separator, + avghr=avghr, + spm=spm, + dps=dps, + ) + + i += 1 + + return stri + +def get_nk_summary(workoutdata,strokedata): + + name = workoutdata['name'] + + avgpace = workoutdata['avgPaceGps']/1000. # secs + avgpacetd = timedelta(seconds=avgpace) + avgpacestring = dataprep.strfdelta(avgpacetd) + + elapsedSeconds = workoutdata['elapsedTime']/1000. # secs + elapsedTime = datetime.utcfromtimestamp(elapsedSeconds) + + timestring = "%s.%i" % (elapsedTime.strftime("%H:%M:%S"), elapsedTime.microsecond/100000) + + dist = workoutdata['totalDistanceGps'] + spm = workoutdata['avgStrokeRate'] + avghr = workoutdata['avgHeartRate'] + avgdps = workoutdata['distStrokeGps'] + maxhr = strokedata['heartRate'].max() + pwr = workoutdata['avgPower'] + + sep = "|" + + + stri1 = "Workout Summary - " + name + "\n" + stri1 += "--{sep}Total{sep}--Total---{sep}--Avg--{sep}-Avg-{sep}-Avg--{sep}-Avg-{sep}-Max-{sep}-Avg\n".format( + sep=sep) + stri1 += "--{sep}Dist-{sep}--Time----{sep}-Pace--{sep}-Pwr-{sep}-SPM--{sep}-HR--{sep}-HR--{sep}-DPS\n".format( + sep=sep) + + + + stri1 += "--{sep}{dist:0>5.0f}{sep}".format( + sep=sep, + dist=dist, + ) + + stri1 += timestring + sep + avgpacestring + + stri1 += "{sep}{avgpower:0>5.1f}".format( + sep=sep, + avgpower=pwr, + ) + + stri1 += "{sep} {avgsr:2.1f} {sep}{avghr:0>5.1f}{sep}".format( + avgsr=spm, + sep=sep, + avghr=avghr + ) + + stri1 += "{maxhr:0>5.1f}{sep}{avgdps:0>4.1f}\n".format( + sep=sep, + maxhr=maxhr, + avgdps=avgdps + ) + + return stri1 + + + + return stri1 + +def get_nk_allstats(data,workoutdata): + stri = get_nk_summary(data, workoutdata) + \ + get_nk_intervalstats(data, workoutdata) + return stri + + def get_workout(user,nkid): r = Rower.objects.get(user=user) if (r.nktoken == '') or (r.nktoken is None): @@ -277,9 +402,11 @@ def get_workout(user,nkid): # get strokes url = NK_API_LOCATION+"api/v1/sessions/strokes" response = requests.get(url,headers=headers,params=params) - if response != 200: - # error handling - pass + + + if response.status_code != 200: + # error handling and logging + return {},pd.DataFrame() jsonData = response.json() @@ -314,8 +441,8 @@ def get_workout(user,nkid): response = requests.get(url, headers=headers,params=params) if response.status_code != 200: - # error handling - pass + # error handling and logging + return {},df jsondata = response.json() workoutdata = {} @@ -323,8 +450,4 @@ def get_workout(user,nkid): if str(w['id']) == str(nkid): workoutdata = w - # not to_csv and run upload API! - #strokedata = df.to_json(orient='records') - #df.to_csv('~/Downloads/nk_logbook.csv') - return workoutdata, df diff --git a/rowers/templates/menu_workout.html b/rowers/templates/menu_workout.html index 8c0d8385..51aefb8b 100644 --- a/rowers/templates/menu_workout.html +++ b/rowers/templates/menu_workout.html @@ -155,6 +155,13 @@ {% endif %} + - + {% if workout.uploadedtonk %} + + NK LiNK + + {% endif %} +
- {% if workout.uploadedtostrava %} diff --git a/rowers/templatetags/rowerfilters.py b/rowers/templatetags/rowerfilters.py index 89f5c1c6..af9fc57e 100644 --- a/rowers/templatetags/rowerfilters.py +++ b/rowers/templatetags/rowerfilters.py @@ -106,6 +106,13 @@ def adaptive(s): return u +@register.filter +def nkviewerlink(workout): + url = "https://viewer-stage.nkrowlink.com/workout-analysis/{nkid}".format(nkid=workout.uploadedtonk) + + return url + + @register.filter def boatclass(s): u = s diff --git a/rowers/uploads.py b/rowers/uploads.py index adf2688f..23546796 100644 --- a/rowers/uploads.py +++ b/rowers/uploads.py @@ -541,6 +541,13 @@ def do_sync(w,options, quick=False): except KeyError: pass + try: + if options['nkid'] != 0 and options['nkid'] != '': + w.uploadedtonk = options['nkid'] + w.save() + except KeyError: + pass + try: if options['garminid'] != 0 and options['garminid'] != '': w.uploadedtogarmin = options['garminid'] diff --git a/rowers/urls.py b/rowers/urls.py index d92e3167..ad8dfe03 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -568,6 +568,7 @@ urlpatterns = [ re_path(r'^workout/c2import/all/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), re_path(r'^workout/c2import/all/(?P
\d+)/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), re_path(r'^workout/nkimport/$',views.workout_nkimport_view,name='workout_nkimport_view'), + re_path(r'^workout/nkimport/all/$',views.workout_getnkworkout_all,name='workout_getnkworkout_all'), re_path(r'^workout/rp3import/(?P \d+)/$',views.workout_getrp3importview, name='workout_getrp3importview'), re_path(r'^workout/rp3import/all/$',views.workout_getrp3workout_all,name='workout_getrp3workout_all'), @@ -731,7 +732,7 @@ urlpatterns = [ re_path(r'^legal', TemplateView.as_view(template_name='legal.html'),name='legal'), re_path(r'^register/$',views.rower_register_view,name='rower_register_view'), re_path(r'^coachregister/$',views.freecoach_register_view,name='freecoach_register_view'), - path('activate/ / /',views.useractivate, name='useractivate'), + path('activate/ / /',views.useractivate, name='useractivate'), re_path(r'^register/thankyou/$', TemplateView.as_view(template_name='registerthankyou.html'), name='registerthankyou'), re_path(r'^workout/(?P \b[0-9A-Fa-f]+\b)/workflow/$',views.workout_workflow_view, name='workout_workflow_view'), diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index e1516914..42f78a20 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -815,6 +815,12 @@ def rower_process_nkcallback(request): url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) +@login_required() +def workout_getnkworkout_all(request): + messages.error(request,"Future Functionality, coming soon!") + url = reverse('workout_nkimport_view') + return HttpResponseRedirect(url) + @login_required() @permission_required('rower.is_coach',fn=get_user_by_userid, raise_exception=True) def workout_nkimport_view(request,userid=0): diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py index ca46185e..b1f1ef56 100644 --- a/rowers/views/workoutviews.py +++ b/rowers/views/workoutviews.py @@ -5033,6 +5033,21 @@ def workout_upload_api(request): plottype = r.staticchartonupload res, jobid = uploads.make_plot(r,w,f1,f2,plottype,t) + try: + oarlength = post_data['oarlength'] + inboard = post_data['inboard'] + w.inboard = inboard + w.oarlength = oarlength + w.save() + except KeyError: + pass + + try: + summary = post_data['summary'] + w.summary = summary + w.save() + except KeyError: + pass uploads.do_sync(w,post_data,quick=True) From f082d5f2b19b7efda02e01774a9f919460ab3c25 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 5 Apr 2021 06:50:39 +0200 Subject: [PATCH 16/18] parametrizing viewer location --- rowers/templatetags/rowerfilters.py | 6 +++++- rowers/views/importviews.py | 23 ++++++++++++++++++++++- rowsandall_app/settings.py | 1 + 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/rowers/templatetags/rowerfilters.py b/rowers/templatetags/rowerfilters.py index af9fc57e..baa1646b 100644 --- a/rowers/templatetags/rowerfilters.py +++ b/rowers/templatetags/rowerfilters.py @@ -30,6 +30,8 @@ from rowers.utils import NoTokenError, step_to_string import rowers.payments as payments +from rowsandall_app.settings import NK_VIEWER_LOCATION + from rowers.opaque import encoder from rowers.plannedsessions import ps_dict_get_description_html @@ -108,7 +110,9 @@ def adaptive(s): @register.filter def nkviewerlink(workout): - url = "https://viewer-stage.nkrowlink.com/workout-analysis/{nkid}".format(nkid=workout.uploadedtonk) + url = "{nkviewer}{nkid}".format( + nkid=workout.uploadedtonk, + nkviewer=NK_VIEWER_LOCATION) return url diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 42f78a20..f0dcc5b4 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -844,6 +844,24 @@ def workout_nkimport_view(request,userid=0): return HttpResponseRedirect(url) # get NK IDs + nkids = [item['id'] for item in res.json()] + knownnkids = uniqify([ + w.uploadedtonk for w in Workout.objects.filter(user=r) + ]) + tombstones = [ + t.uploadedtonk for t in TombStone.objects.filter(user=r) + ] + parkedids = [] + try: + with open('nkblocked.json','r') as nkblocked: + jsondata = json.load(nkplocked) + parkedids = jsondata['ids'] + except FileNotFoundError: + pass + + knownnkids = uniqify(knownnkids+tombstones+parkedids) + newids = [nkid for nkid in nkids if not nkid in knownnkids] + nkdata = [{ 'id':int(item['id']), @@ -857,7 +875,10 @@ def workout_nkimport_view(request,userid=0): d = int(float(item['totalDistanceGps'])) # could also be Impeller i = item['id'] n = item['name'] - nnn = '' + if i in knownnkids: + nnn = '' + else: + nnn = 'NEW' ttot = str(datetime.timedelta(seconds=int(float(item['elapsedTime'])/1000.))) s = arrow.get(item['startTime'],tzinfo=r.defaulttimezone).format(arrow.FORMAT_RFC850) #s = arrow.get(item['startTime']).to(r.defaulttimezone).isoformat() diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index f6c45d9d..80e47a5f 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -333,6 +333,7 @@ NK_CLIENT_ID = CFG["nk_client_id"] NK_CLIENT_SECRET = CFG["nk_client_secret"] NK_REDIRECT_URI = CFG["nk_redirect_uri"] NK_API_LOCATION = CFG["nk_api_location"] +NK_VIEWER_LOCATION = CFG["nk_viewer_location"] # Full Site URL SITE_URL = CFG['site_url'] From 84cefb31ab23e9802ee2333aa567ad223e0d3465 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 5 Apr 2021 14:54:18 +0200 Subject: [PATCH 17/18] adding all new NK workouts --- rowers/nkstuff.py | 185 ++++++++++++------------------------ rowers/tasks.py | 146 ++++++++++++++++++++++++++++ rowers/utils.py | 139 +++++++++++++++++++++++++++ rowers/views/importviews.py | 19 +++- 4 files changed, 364 insertions(+), 125 deletions(-) diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py index f55aeef5..709e9db7 100644 --- a/rowers/nkstuff.py +++ b/rowers/nkstuff.py @@ -22,7 +22,7 @@ queuehigh = django_rq.get_queue('low') from rowers.rower_rules import is_workout_user, ispromember from iso8601 import ParseError -from rowers.utils import myqueue +from rowers.utils import myqueue,get_nk_summary, get_nk_allstats, get_nk_intervalstats,getdict import rowers.mytypes as mytypes import gzip @@ -33,6 +33,9 @@ from rowsandall_app.settings import ( UPLOAD_SERVICE_URL, UPLOAD_SERVICE_SECRET, ) +from rowers.tasks import handle_nk_async_workout + + try: from json.decoder import JSONDecodeError except ImportError: @@ -108,6 +111,64 @@ def nk_open(user): return thetoken +def get_nk_workouts(rower, do_async=True): + try: + thetoken = nk_open(rower.user) + except NoTokenError: + return 0 + + res = get_nk_workout_list(rower.user) + + if res.status_code != 200: + return 0 + + nkids = [item['id'] for item in res.json()] + alldata = {} + for item in res.json(): + alldata[item['id']] = item + + knownnkids = [ + w.uploadedtonk for w in Workout.objects.filter(user=rower) + ] + + tombstones = [ + t.uploadedtonk for t in TombStone.objects.filter(user=rower) + ] + + parkedids = [] + try: + with open('nkblocked.json','r') as nkblocked: + jsondata = json.load(nkblocked) + parkedids = jsondata['ids'] + except FileNotFoundError: + pass + + knownnkids = uniqify(knownnkids+tombstones+parkedids) + newids = [nkid for nkid in nkids if not nkid in knownnkids] + + newparkedids = uniqify(newids+parkedids) + + with open('nkblocked.json','wt') as nkblocked: + data = {'ids':newparkedids} + json.dump(data,nkblocked) + + counter = 0 + for nkid in newids: + res = myqueue(queuehigh, + handle_nk_async_workout, + alldata, + rower.user.id, + rower.nktoken, + nkid, + counter, + rower.defaulttimezone + ) + counter += 1 + + return 1 + + + def do_refresh_token(refreshtoken): post_data = {"grant_type": "refresh_token", #"client_id":NK_CLIENT_ID, @@ -161,7 +222,7 @@ def get_nk_workout_list(user,fake=False): # ready to fetch. Hurray endTime = int(arrow.now().timestamp())*1000 endTime = str(endTime) - startTime = arrow.now()-timedelta(days=90)*1000 + startTime = arrow.now()-timedelta(days=30)*1000 startTime = str(int(startTime.timestamp())) authorizationstring = str('Bearer ' + r.nktoken) headers = {'Authorization': authorizationstring, @@ -182,16 +243,6 @@ def get_nk_workout_list(user,fake=False): return s # -#def get_workout(user,nkid): -def getdict(x, seatIndex=1): - seatStrokes = pd.DataFrame(x) - try: - seatStrokes = seatStrokes.set_index('seatIndex') - return dict(seatStrokes.loc[seatIndex]) - except KeyError: - pass - - return {} def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, workoutsource='nklinklogbook'): @@ -266,116 +317,6 @@ def add_workout_from_data(user,nkid,data,strokedata,source='nk',splitdata=None, return workoutid,"" -def get_nk_intervalstats(workoutdata,strokedata): - intervals = workoutdata['intervals'] - separator = "|" - stri = "Workout Details\n" - stri += "#-{sep}SDist{sep}-Split-{sep}-SPace-{sep}-Pwr-{sep}-SPM--{sep}-AvgHR-{sep}DPS-\n".format( - sep=separator) - - i = 0 - - for interval in intervals: - id = interval['id'] - sdist = interval['totalDistanceGps'] - avgpace = interval['avgPaceGps']/1000. - avgpacetd = timedelta(seconds=avgpace) - newpacestring = dataprep.strfdelta(avgpacetd) - - elapsedSeconds = interval['elapsedTime']/1000. # secs - elapsedTime = datetime.utcfromtimestamp(elapsedSeconds) - - newsplitstring = "%s.%i" % (elapsedTime.strftime("%M:%S"), elapsedTime.microsecond/100000) - - pwr = interval['avgPower'] - avghr = interval['avgHeartRate'] - spm = interval['avgStrokeRate'] - dps = interval['distStrokeGps'] - - stri += "{i:0>2}{sep}{sdist:0>5}{sep}{split}{sep}{space}{sep} {pwr:0>3} {sep}".format( - i=i + 1, - sdist=int(float(sdist)), - split=newsplitstring, - space=newpacestring, - pwr=int(pwr), - sep=separator, - ) - stri += " {spm} {sep} {avghr:0>3} {sep}{dps:0>4.1f}\n".format( - sep=separator, - avghr=avghr, - spm=spm, - dps=dps, - ) - - i += 1 - - return stri - -def get_nk_summary(workoutdata,strokedata): - - name = workoutdata['name'] - - avgpace = workoutdata['avgPaceGps']/1000. # secs - avgpacetd = timedelta(seconds=avgpace) - avgpacestring = dataprep.strfdelta(avgpacetd) - - elapsedSeconds = workoutdata['elapsedTime']/1000. # secs - elapsedTime = datetime.utcfromtimestamp(elapsedSeconds) - - timestring = "%s.%i" % (elapsedTime.strftime("%H:%M:%S"), elapsedTime.microsecond/100000) - - dist = workoutdata['totalDistanceGps'] - spm = workoutdata['avgStrokeRate'] - avghr = workoutdata['avgHeartRate'] - avgdps = workoutdata['distStrokeGps'] - maxhr = strokedata['heartRate'].max() - pwr = workoutdata['avgPower'] - - sep = "|" - - - stri1 = "Workout Summary - " + name + "\n" - stri1 += "--{sep}Total{sep}--Total---{sep}--Avg--{sep}-Avg-{sep}-Avg--{sep}-Avg-{sep}-Max-{sep}-Avg\n".format( - sep=sep) - stri1 += "--{sep}Dist-{sep}--Time----{sep}-Pace--{sep}-Pwr-{sep}-SPM--{sep}-HR--{sep}-HR--{sep}-DPS\n".format( - sep=sep) - - - - stri1 += "--{sep}{dist:0>5.0f}{sep}".format( - sep=sep, - dist=dist, - ) - - stri1 += timestring + sep + avgpacestring - - stri1 += "{sep}{avgpower:0>5.1f}".format( - sep=sep, - avgpower=pwr, - ) - - stri1 += "{sep} {avgsr:2.1f} {sep}{avghr:0>5.1f}{sep}".format( - avgsr=spm, - sep=sep, - avghr=avghr - ) - - stri1 += "{maxhr:0>5.1f}{sep}{avgdps:0>4.1f}\n".format( - sep=sep, - maxhr=maxhr, - avgdps=avgdps - ) - - return stri1 - - - - return stri1 - -def get_nk_allstats(data,workoutdata): - stri = get_nk_summary(data, workoutdata) + \ - get_nk_intervalstats(data, workoutdata) - return stri def get_workout(user,nkid): diff --git a/rowers/tasks.py b/rowers/tasks.py index c841185f..84957b55 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -53,6 +53,7 @@ from rowsandall_app.settings import PROGRESS_CACHE_SECRET from rowsandall_app.settings import SETTINGS_NAME from rowsandall_app.settings import workoutemailbox from rowsandall_app.settings import UPLOAD_SERVICE_SECRET, UPLOAD_SERVICE_URL +from rowsandall_app.settings import NK_API_LOCATION from requests_oauthlib import OAuth1, OAuth1Session @@ -110,6 +111,8 @@ def safetimedelta(x): siteurl = SITE_URL +from rowers.utils import get_nk_summary, get_nk_allstats, get_nk_intervalstats,getdict + # testing task from rowers.emails import send_template_email @@ -2952,6 +2955,149 @@ def handle_rp3_async_workout(userid,rp3token,rp3id,startdatetime,max_attempts,de return workoutid +@app.task +def handle_nk_async_workout(alldata,userid,nktoken,nkid,delaysec,defaulttimezone,debug=False,**kwargs): + time.sleep(delaysec) + data = alldata[nkid] + + params = { + 'sessionIds': nkid, + } + + authorizationstring = str('Bearer ' + nktoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json', + } + + # get strokes + url = NK_API_LOCATION+"api/v1/sessions/strokes" + response = requests.get(url,headers=headers,params=params) + + + if response.status_code != 200: + # error handling and logging + return 0 + + jsonData = response.json() + strokeData = jsonData[str(nkid)] + df = pd.DataFrame.from_dict(strokeData) + oarlockData = df['oarlockStrokes'] + + oarlockData = oarlockData.apply(lambda x:getdict(x, seatIndex=1)) + df2 = pd.DataFrame.from_records(oarlockData.values) + + df.set_index('timestamp') + + if not df2.empty: + df2.set_index('timestamp') + df = df.merge(df2,left_index=True,right_index=True) + df = df.rename(columns={"timestamp_x":"timestamp"}) + + df = df.drop('oarlockStrokes',axis=1) + df.sort_values(by='timestamp',ascending=True,inplace=True) + df.fillna(inplace=True,method='ffill') + + # get workout data + timestampbegin = df['timestamp'].min() + timestampend = df['timestamp'].max() + + csvfilename = 'media/{code}_{nkid}.csv.gz'.format( + nkid=nkid, + code = uuid4().hex[:16] + ) + + df.to_csv(csvfilename, index_label='index', compression='gzip') + + + title = data["name"] + speedInput = data["speedInput"] + elapsedTime = data["elapsedTime"] + totalDistanceGps = data["totalDistanceGps"] + totalDistanceImp = data["totalDistanceImp"] + intervals = data["intervals"] # add intervals + oarlockSessions = data["oarlockSessions"] + deviceId = data["deviceId"] # you could get the firmware version + + summary = get_nk_allstats(data,df) + + # oarlock inboard, length, boat name + if oarlockSessions: + oarlocksession = oarlockSessions[0] # should take seatIndex + boatName = oarlocksession["boatName"] + oarLength = oarlocksession["oarLength"] # cm + oarInboardLength = oarlocksession["oarInboardLength"] # cm + seatNumber = oarlocksession["seatNumber"] + else: + boatName = '' + oarLength = 289 + oarInboardLength = 88 + seatNumber = 1 + + workouttype = "water" + boattype = "1x" + + uploadoptions = { + 'secret': UPLOAD_SERVICE_SECRET, + 'user':userid, + 'file': csvfilename, + 'title': title, + 'workouttype': workouttype, + 'boattype': boattype, + 'nkid':nkid, + 'inboard': oarInboardLength/100., + 'oarlength': oarLength/100., + 'summary':summary, + } + + session = requests.session() + newHeaders = {'Content-type': 'application/json', 'Accept': 'text/plain'} + session.headers.update(newHeaders) + + response = session.post(UPLOAD_SERVICE_URL,json=uploadoptions) + + if response.status_code != 200: + return 0,response.text + + try: + workoutid = response.json()['id'] + except KeyError: + workoutid = 1 + + if debug: + engine = create_engine(database_url_debug, echo=False) + else: + engine = create_engine(database_url, echo=False) + + query = 'SELECT uploadedtonk from rowers_workout WHERE id ={workoutid}'.format(workoutid=workoutid) + + newnkid = 0 + with engine.connect() as conn, conn.begin(): + result = conn.execute(query) + tdata = result.fetchall() + if tdata: + newnkid = tdata[0][0] + + conn.close() + + parkedids = [] + with open('nkblocked.json','r') as nkblocked: + jsondata = json.load(nkblocked) + parkedids = jsondata['ids'] + + newparkedids = [id for id in parkedids if id != newnkid] + with open('nkblocked.json','wt') as nkblocked: + tdata = {'ids':newparkedids} + nkblocked.seek(0) + json.dump(tdata,nkblocked) + + # evt update workout summary + + # return + return workoutid + + + @app.task def handle_c2_async_workout(alldata,userid,c2token,c2id,delaysec,defaulttimezone,debug=False,**kwargs): time.sleep(delaysec) diff --git a/rowers/utils.py b/rowers/utils.py index c1e69aab..062f10c2 100644 --- a/rowers/utils.py +++ b/rowers/utils.py @@ -1092,3 +1092,142 @@ def step_to_string(step,short=False): s = 'Repeat {duration}'.format(duration=duration) return s,type, nr, repeatID, repeatValue + +def strfdelta(tdelta): + try: + minutes, seconds = divmod(tdelta.seconds, 60) + tenths = int(tdelta.microseconds / 1e5) + except AttributeError: + minutes, seconds = divmod(tdelta.view(np.int64), 60e9) + seconds, rest = divmod(seconds, 1e9) + tenths = int(rest / 1e8) + res = "{minutes:0>2}:{seconds:0>2}.{tenths:0>1}".format( + minutes=minutes, + seconds=seconds, + tenths=tenths, + ) + + return res + + +def get_nk_intervalstats(workoutdata,strokedata): + intervals = workoutdata['intervals'] + separator = "|" + stri = "Workout Details\n" + stri += "#-{sep}SDist{sep}-Split-{sep}-SPace-{sep}-Pwr-{sep}-SPM--{sep}-AvgHR-{sep}DPS-\n".format( + sep=separator) + + i = 0 + + for interval in intervals: + id = interval['id'] + sdist = interval['totalDistanceGps'] + avgpace = interval['avgPaceGps']/1000. + avgpacetd = timedelta(seconds=avgpace) + newpacestring = strfdelta(avgpacetd) + + elapsedSeconds = interval['elapsedTime']/1000. # secs + elapsedTime = datetime.datetime.utcfromtimestamp(elapsedSeconds) + + newsplitstring = "%s.%i" % (elapsedTime.strftime("%M:%S"), elapsedTime.microsecond/100000) + + pwr = interval['avgPower'] + avghr = interval['avgHeartRate'] + spm = interval['avgStrokeRate'] + dps = interval['distStrokeGps'] + + stri += "{i:0>2}{sep}{sdist:0>5}{sep}{split}{sep}{space}{sep} {pwr:0>3} {sep}".format( + i=i + 1, + sdist=int(float(sdist)), + split=newsplitstring, + space=newpacestring, + pwr=int(pwr), + sep=separator, + ) + stri += " {spm} {sep} {avghr:0>3} {sep}{dps:0>4.1f}\n".format( + sep=separator, + avghr=avghr, + spm=spm, + dps=dps, + ) + + i += 1 + + return stri + +def get_nk_summary(workoutdata,strokedata): + + name = workoutdata['name'] + + avgpace = workoutdata['avgPaceGps']/1000. # secs + avgpacetd = timedelta(seconds=avgpace) + avgpacestring = strfdelta(avgpacetd) + + elapsedSeconds = workoutdata['elapsedTime']/1000. # secs + elapsedTime = datetime.datetime.utcfromtimestamp(elapsedSeconds) + + timestring = "%s.%i" % (elapsedTime.strftime("%H:%M:%S"), elapsedTime.microsecond/100000) + + dist = workoutdata['totalDistanceGps'] + spm = workoutdata['avgStrokeRate'] + avghr = workoutdata['avgHeartRate'] + avgdps = workoutdata['distStrokeGps'] + maxhr = strokedata['heartRate'].max() + pwr = workoutdata['avgPower'] + + sep = "|" + + + stri1 = "Workout Summary - " + name + "\n" + stri1 += "--{sep}Total{sep}--Total---{sep}--Avg--{sep}-Avg-{sep}-Avg--{sep}-Avg-{sep}-Max-{sep}-Avg\n".format( + sep=sep) + stri1 += "--{sep}Dist-{sep}--Time----{sep}-Pace--{sep}-Pwr-{sep}-SPM--{sep}-HR--{sep}-HR--{sep}-DPS\n".format( + sep=sep) + + + + stri1 += "--{sep}{dist:0>5.0f}{sep}".format( + sep=sep, + dist=dist, + ) + + stri1 += timestring + sep + avgpacestring + + stri1 += "{sep}{avgpower:0>5.1f}".format( + sep=sep, + avgpower=pwr, + ) + + stri1 += "{sep} {avgsr:2.1f} {sep}{avghr:0>5.1f}{sep}".format( + avgsr=spm, + sep=sep, + avghr=avghr + ) + + stri1 += "{maxhr:0>5.1f}{sep}{avgdps:0>4.1f}\n".format( + sep=sep, + maxhr=maxhr, + avgdps=avgdps + ) + + return stri1 + + + + return stri1 + +def get_nk_allstats(data,workoutdata): + stri = get_nk_summary(data, workoutdata) + \ + get_nk_intervalstats(data, workoutdata) + return stri + +#def get_workout(user,nkid): +def getdict(x, seatIndex=1): + seatStrokes = pd.DataFrame(x) + try: + seatStrokes = seatStrokes.set_index('seatIndex') + return dict(seatStrokes.loc[seatIndex]) + except KeyError: + pass + + return {} diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index f0dcc5b4..e3120a29 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -817,8 +817,21 @@ def rower_process_nkcallback(request): @login_required() def workout_getnkworkout_all(request): - messages.error(request,"Future Functionality, coming soon!") - url = reverse('workout_nkimport_view') + try: + thetoken = nk_open(request.user) + except NoTokenError: + return HttpResponseRedirect("rower_nk_authorize") + + r = getrequestrower(request) + + result = nkstuff.get_nk_workouts(r,do_async=True) + + if result: + messages.info(request,"Your NK workouts will be imported in the coming few minutes") + else: + messages.error(request,"Your NK workouts import failed") + + url = reverse('workouts_view') return HttpResponseRedirect(url) @login_required() @@ -854,7 +867,7 @@ def workout_nkimport_view(request,userid=0): parkedids = [] try: with open('nkblocked.json','r') as nkblocked: - jsondata = json.load(nkplocked) + jsondata = json.load(nkblocked) parkedids = jsondata['ids'] except FileNotFoundError: pass From f0b974208e2b199b444bcab52d615c8da84b641b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 5 Apr 2021 14:59:00 +0200 Subject: [PATCH 18/18] fix --- rowers/templates/nk_list_import.html | 2 +- static/img/myimage.png | Bin 0 -> 140952 bytes 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 static/img/myimage.png diff --git a/rowers/templates/nk_list_import.html b/rowers/templates/nk_list_import.html index b3632e6d..f8689b06 100644 --- a/rowers/templates/nk_list_import.html +++ b/rowers/templates/nk_list_import.html @@ -5,7 +5,7 @@ {% block title %}Workouts{% endblock %} {% block main %} - Available on nk
+Available on NK LiNK Logbook
{% if workouts %}
- diff --git a/static/img/myimage.png b/static/img/myimage.png new file mode 100644 index 0000000000000000000000000000000000000000..5c0211ff290554e7f43751ad8fb3c37759f995ce GIT binary patch literal 140952 zcmeFZbx@V-`!~7(F;GFbA`N0u(j{#mDInb;oeQKJR4hbFI+gD3HW85S25FG)ey`j8 z{k`vdX3l$N&R=Kd%#WG9H(S?w*7MwPU7xzw+sCrv=LyIOFc{2vNeNK}4CX{027@
Gd2?Hb{GuN6Z8*Gnnfyt0(q0me%%`X8Knhjco1Atg%-)S=d-OZ(lXFx3}SEW&K|-V6nC}VU0bTnTWw$ z#Yl?YQ*w^^Gwc%ce)ITvbtcjy{)wLvsn6~H>x_OzKm3?Qi_|EBl_aDLrG-V`-FGvL zyiwru$AW=i{GG)8eviw>=YFuSA03W6 #4A44p@M&I6Cj1q9d`mYaK@|5Pf@2;u4oFt^Ar=g)q-8Apb$==!7p-@@!3KnwP z`e|Iqz{l4wW#{CV)9rP mCzKg{Q@YW(aBi`5v?gbfhTmR-wf3N3 zsd`&yswrG Aad?$rJ6mz2xy=tJ0#EWpl1m+-cOK^xCy+m&wS|2Z}8$w*K_ @f zYTDeH2*`XC$w5Fspb&KJVON2XwADyeztz!u3SLGQ7Fk+a+Qtub0UD*)?8E(SU8nM+ zoe84NrD08ng&t*R=dz|vm*iyW@c9w9Wo13Rj|mA0At512m*`{-HS52>Bhg&^ny#3t z6}H@8D9)@|q6F`_|KLGrVBp7W&C-fVCWWLogFG{lCUFgp$Amvf&r{xcEECP6Y;Ru# zPf+2wIM1^zPUDeug;^s_EnlDD+_|n;r;+s3cux)S0P1E(8gYM0`8Yurn3kcE)My?n zg?Cq&mC)FnI+dwaZc8rgUe*#TFcV_^D}p^ky)Xryn9j?~i V zZ}M-=+YNN_Fi&PpVa(WYUh^)AWT{YFsme81B0@ryV)O2eqy1&w^&_{fKj|>&M&(0K zt!=B|2aRZoO!WL~8qc!n!(sF0XkOKYp6o)c8uwk>)IAqBHzr0#DQ1nLHaU?)`{8}< zO2_P1gj5+YOba7Fx|K4NBwIy}-NTxwdc!8BCMFWEJ@ieJ4rk$a-&v7iBJm!ifJQ;R ztbwN%ywUc6$ZWX6K{ofMbeLLGrPFfGa?Spd7|CXd?REe5p>jLo%a@a0ou{a{O~Iz; z-4ZWyTv}55 }K; db5rEg%cxsY31F&Xgw zy@Q$E-qvEYRXG(02gk $Hy<%F0mTq>CRA=K1E2`)|YR)&e#wPg?fpxi z__fFI*%h2$xw8S=3YJbM@2Gp?#p`0Tu*?;zT{kzpj<>zMQptIh&CN5yShS-O_ui9p zOQBb0C}(c0HL)&4Ti3{2T4rr64ZCUY>%a=rahVE@zEZch*{ch4Swh)u)fmD6n>aR% zSu=%2y8_L3ettg7=ckyT7x6IRyBnQy?{5^sB7xF&^Uj?<`}r=0`rVt6a1VP?+lrYg z%$G<=oSGMg2|W)tRj)TZisj#Yh2^EC{Vcfl^E%7isK<8f>iRk-r|~bgVAyO1ITrg~ zhnwN$HsjBw_ddu8o56 S t MrI_Ip-J)TYAXn)hms~eVE&r zu Jb*!WeS;yNUf!a2&{6u{m2-$C(nZnp)fdV?^E;HCIwA)c@9fl7xQC#NkDA7Pv-P< zeqesIwl>HxMK0b`vC;8*tdJW|={w9A!A2dfjdHAjlZu4I8)zDdw`8K|Z`=@NXJ>bX z6(1TGhn*U%^{R!*6$_xwK0cTcVbmx}!^Oo_s&;kAR?Gil6b)^_slT`F(*vWz{;X}D zXc;pzvnWBAg6{LxI@PXTm9l(P)ZuY)Ik5QBp%blbZ>vIY?1oL0NhnjH;{VAQl?$j& z#FUg-v$LiPy?>zW$wEz5u(8R5bv@xo-#z~I_2#!ra%nI%YwPQZ4Z%0L-FIxlKiR(~ zr0RG;B$U+F_V}Tw=xlyHb?53ty>`jW5-D^Vne!ApN}uk(yi7`(W;@krR_Bew@3xiI zv_~fw*9E&v%kz_roSbSni*{G0DyuRqpvBcYj(;NJT;~}=nN(4GWPJS5`}gnPTCP4* z4Ijj7YHC;(deh_|{2-+gbZ(Q1<}~sD^5qL$iz&4V# lnv@d;p)aGtVg*=9O0_ENySzEfis>*hYcxrpp zt9QCNy8F|EQ_$5k=b(Ntsuv`69#O%@N`8BZZhj&_#8G3$)^7Tjyt{jq?_~!4WHjcm zH@m7_) {&ah zfH!Xf!^6W54h{;K6_u1iZtxY8K%=f2aEJ>EdQ#l_?AbHlH*X$8m*(<3a86xxUvK6$ zZ~OQRh7)x`jbd}N_JoJ3!?ibJMZ9!SneHzznp$4Y{rOXZ+qCWOLT_&A8nif)4Ee-Y zU%q}tiwl~}{9C%Xz#CLTu1YYIu2Z3^S|!yU`;PsVJk{GHZhVaI=-$DnrYj`d|LM!8 z%;mJ`xgE-?t7Fs@Rt??XZMiy_#p8(TBoTEMIk!2z^+**wj3O*mF0+msWCmJ2t&;mS zh7JF8=j!T0-$AGgH80oAd5a8PSmN5ks(kXWb0lr~;-tfF%dby2D@@v^f1VfLbWV zwSo`;65z1#8{fZuQyY!#$ C-3kuC$=R@%H}iY~@T9tjL7w z#L^J702u^KL`8kLtGwpB(tFmRq#}F?{iUFTMW-q)FOSW|#f7iiYh$*3ZF3X#n$Rtn zP(V$_2m!*Dw%;ChvgpZDmz}ab=c1Lb|B~NnDGOF4^vAj1-{19WiDP()0Il%pmkxGU zx!8UG{(UYpw?;uhAsDKCqI{xQ`6QKyXB9#drK6sj4Gj%zYikM b}x6P%~wR{{j7kSZIyF`t>6;kLb`Qd4NsnsM*NNey^lvG?T3tTj9LgMxxwH`>MR zlzyPz{`EB=s3fRmW&=fL=KTf9(jzYG(+&0Y;sBx@rD!+|-~4f`92A+QJUUpHgT1p5 zs;bkD9^lNSTWRnFzVF|6!+^MT+vaGMD|mW(R;;%OWEU+CmSzI4V8MAO70RgIM0N5U z=|@5;e)?Ot#1vB=wg2faM0+unFIM A3SfIZ+ zbLPw*JWm&Znd;-CeQdxkym=D}klD`4I1q>w0uq)iK(Kubr`PLgywYGjM)6w50c^^I zK8L6Y)c>E#FK{zevK5};;sfY)={A^cfv!|+)N}=Qz=+gfp-DhTXJ__=b2;3u+4j^JuM{;Z3B{{)QH6HsJP;IfLF4Mor7DsDEn)vy`bg@&7p# $N~fnTS~Q|IYO6Qv4!vvAtt@P(oBLfEw3)oxBON^$@N z(ETEo1m#f?2M32`!O+l gw*Ian$wPH7YU68(frn^r-92#p_v) zyPa|(-B21VJX&fGw={+JW)m5;D~h0x=HQ>bVmem$tcP0{KARgm)Ms^d3SJ#rTkGXN zI2z4x{rcCbI2Yz^nabgu7pI6a07USO%)rFhah+(Us<%-VUs0;Ke8FWtJ(bJg$GPSx zZfT%UU4Yt?gRYD7XGee7^5{sCG#@I 7tFIWB8L)#Y)R-IE6+{9x0(=9Ns2u}Y z$xuoUMz9^GdZ8;_F+;nu#1pnsX4oR?2QW6+zVdy*z7EHS+ZKRG$YFyUH%BFRBui%+ z)cd >d@lC6rOS|>LeW%<2 zk)yuGbpX*6*4Eakn>m#C?>NjWU2kyT>{gZC?a9?$=HN2>;&bN5;NYP9R=)`!%ghuo z=$Qh o+lY%(%7Ca)aEs${7>xe-Yv;CP4G8n}eZ%I8zD9X!|} zQ-o%LyS`^vCn75E07UhE7|RQ&78Yeu1J5$l5-WgUF5@prVmg+~CI~x=%oM{9ybe}h z2N?n|vkGl!YGN9+SlXEDyv4=Ug}}@p?PdS NZix%(QV(BqI!ybhm3q$i>j8rL?j9bouYB&jns1Kb>%4iK z_vEYJjZ}VZx$**v`*&Q{CU3tMqPuxB^5e&=sYOs9BL$YLI?WSxU>Akc`qUmDRRZ?) zI&=uPl8xl}F2Nykw38Ux1ABUXE;SyCeNP0egD6-JT@oCComvWH`x~&Q$3tPE7)gb* zSoxB$VmmrIV#1nZ1$uXOcO&Dyj`~SIY}!D<=+8HJ&7hd#rx@LzDi?oydtq_WYKlp- zB&)yJqF1@emAO|>HfJ3QdgOd}rb%Ti{5$$N{#kyiXlOZ6P@T%FPibR+e|rnF1>~sk z*ZP1(E}0}iB2M+`NFle9AVZZLEwkkQMMuStcX8HNz7m;ezKU*U|HV`pUP `msJFuYx$anf9W$+s^}&Up-fsIv{yz(My7%6 z*h$GrO0w^+&p44$FSbxvS3Q9RH^XH(NL62440mQwIw&J0 ba;Dryc fY@QqsFbD`#bu@+=luJOfgk%! zYdkODH{+GD&pnRNER2@QXQ;#1c+9(gUS`qit9UKY7#$K~-)(@oy4{9%_O3r{E1{J- zJXy0)MrGT1+@&@Ir*;24c<^Ae4v%OS= pv^vm0Bxtp>gwuPSEk*)y$r*KU`EiR6y*W|(*QPVo$F(T zOFVu0lu<43qoO(mFZQ;9ANSlA@Pf-1FOG9CILbCvLrwi#^nnFbRaZ|fEM%6I-I447 z)hXK@$f!n%Wn?F7kz*z5F6N^()y1-oBOcq@KqZu6;MP`GgP`95sG2B>XR_+cyB8q5 zmxkm+AT*V?Td`#?4EmvzhDJsr)r-HW%Az+1VKdi_*`P$)%c|76_4|PH_>y@o``XgQ zpOu!D4wP7BLg7}Ii_|PK4T8Fto1Xd=mUdU6iJTCS^&Zys;QcsX^*LaCs88h^)MvmX zur%4(TH0ykECh86v&iX!R`4T$CKqn!r&Vr340gTI2=xsF`TF|QicQ<^cc#cFLYL =oG$Me+O&QgT9@Aqc&|wlb=Wt#;d a8e5eT9IDV|#bUU(;*=$F%^Y3YwqN;oc@}+ML_lPqSzE>3UrdS~Kgs zg)ojrg?)OHPd5Mnz+NBV(HHD3VII0`JlqaJv6H>jTFLal#>S>Zs0n~E?Oex;2gbi5 z#<%9op`RjNA$+)@$YeGC KRX{`YhYOfGN&` z+GZE+HB#jQ2pQoNC_p(N>nQ>Hjsv_Ol6Z)^G*S)+o7!WMx& *-?3Bc%? zT47relaQpYtk^6Zaas1U0VEzG*AMI5I)v&BPy9jJx(Mph98whL@gz_)1w1xYrfLR~ z4_aVjV|qx!L;W52qV_8qY5~VbNDu=7H)~%XknK%|LAS)OfXPFHf CqNdTU_ljI_H#&vr)b`R2D>Rb2aC-NG-(8-nhtX$KiuNCpAD3zu&EYm!VHmv zr!^_@_;FwpjXU&wZ0$GV+iGoA124~#*uw&eR{7EN@Zm#rN3uvn*Y4zj`%Mu!KIq&F z_S{_na>(mWodsnjd^ZLvXb7*hPIwixBH8+*oz;mbpx4rTR1Z#48Xc~;iWCZps6zQk z;CGle1zJJQuI~d|D-otn&5lc3IJFb%sF9z1p>cCo5folUB<6j*_X3GsK*JiN5`Tk; z3U3bC6z`jDPu%>)**XiWs5eW!u*mb!741*B)-T(oG$`SWw6ynNz&fG%E^=7Q85$;a z3X%~b(d&kKeLs7xtjaq66j#85yLY`od5`I!tlw{3GjzgIy2!=c2}(TVzcZbRz+1|r z$lpbl-+hT$dVfu=zv4Sb&Lx5N1FV55t{ck1o2N8X>U#zUH9;EdRm!n=T6`$blF2TH z3d&$fHUKnz?HB$o2eR?PR>gAu4z{+{hTm5=gzL2=L9Kqu*Aw0 aY0c?GhA^>#z-Cs4k)6tDTf7+0z zxF!PwV^+%>i<;m5L1AqQ6kh{`$Gz?4+^?^RGC?+wm6LD&`SURh4`?f?;X1C8pnkUj zDm5*>&gYU5pNNI>?vOqROE71zdKzRjjatt`?(XJ`l*3gnh5nR$31Fr0 y| zSRj0+Ih ;1$ zsu7HQJ2)pnMzQzr-yah8`E5IW+)6bW5hHZ#){iMQ-PXzOj%14rkPi01q(BBkDN#vZ zjR)|zMc(jrhC|k}u&AgED1{a}g-vb@lHs>rJ6SHQwH|~56;@7pM7B6mJ!sUw8MhFl zH3R!)ey}tbQTPvZa^^7eNa6kG(j|wnI-IHIXjKqm!99Db-rKIoSV_YJ&K)?LVq^vd z=k gVV6OyyQaM?QcyqS>0oZnpTh2(L z0!OPR<*LM^M}E?QTefHb4-OBD^*-aK(y->l%-p71tel8)hGv_!9 Vjg15<@s&9%82eT%srVB{+~rSmI*KajFN*=B!-`Y>DIIKqNqg@C~T5ks#Ps zCh6{yt^>S`h5{I&m?~>j8DGnJD$Ia7oN0y}sZX61P{wc3)Bk)F!S _nb vy)g#Z zk5F4pDwm -t2`BJ_%Nj!KvYf;#D0v9iVW_)LQN^W zqGt66g;=l)aQ 8l*yJY+J?OW_jUB$uC#8`yfQsXX1E|=lY3yMXi?UbRcsp~hued+Xh z{h9(~f@JuK1k@(bj33&$C3O4tFTwpkg?x+?p@3Zs5rj;vQg4<9Qw1v(LjMG|c2?^Y z%=M&W&1bYL<1Jp7j<_~S*zO*H+*I%E<`xZTXx%l#c)lmw0-?9WD%HvoN`TY`pu5D> z5Ru!>4OF@l0{@BRv5E>Z{O6y49)Wb6wSma_x9{J{Ks;IR(X>{d1Cf8)Uto!iNN}a@ zJQJ(;&_klz7~UA*C8(4Od+gD3aw;Z@y;lG(skr mzCf~%e<;}8YTjNEj`KYimN)-drEU~_5|nU06( z Q(FLgV8Ctr4KYBS1sI#VZl!57%Ku!sPw?&tdyo`7j>ErK8c$dkkKXXK&GZ7%f6p?ch3XwqF`rTOyZOEa**5oUdL zjw}IG{OY5 2S=BVY?D0cij6o+4G(>##eW p&afdp4>r@^jjX>?+q;`pH!z;_H@_D$`miQ8AN-UXo>nrdkA2%wD6h=??_ ziV(;2rxwb9Yor6uobSs|R5o@J3Z-06zmsGA8d|t~yvJ5#6pvNLrw6Ya!65?!v<-v+ zZrEl_qks}n%LO(o3X*p>)R2wuB)X>1=;jBCGY9GLDhgdU%` Jgki-3CQ40WC%-C|$r&vaEEP${l+>rziQX;gx{; z6W}u#AX@@}s OY%WPlR@sHZgn>XiR|C$e16hq;%KiGL{HC&;L{8-?7!(m^s zr4~@!Wv#w#uB@+j!Zt-hjv2_Ru5-!ZN;U2-#H>16=<|`Q_2$iKXwR*ShlA~$o5;q1 z!9o-oL`|gkkb^aYmMXwtaj3sAfo*Uvn@|TB1qHRBE;(wZK>lC>uyZ%8%VU+{oJnWk zSFW2}rfpZj+cbq@t5IP1cEo*MCe=+DPy@)Y2$vuyT*Va-FL%<$+LLksTQnXULFn0G zOCe$0*WbSf8tx{{kOknBJy4CYrXH9f!*Z&7cw_2VQKBq>){&|@F5fRRD11ZeCUmAK z*nwqPwKn7T6#+e6A|*9N1s;T& pBep1ro(}q zF@UlUCr+F|8l7T^rFtg_t*N@LRJB7!-47 b)z#I#L`7vq?YZ-0ef0RKmvp^0?t*%C4eWr-xM Wh5Z3NYJ_Xr^>@@`TRlbt%Z0I^u6K*xC;e;tG<4Ht+ST2$E2={JTkTL1w;j|@wm9lX@MID6-* zr^pw}I0S!P*mcA7FHQkD24W;4072x}AzKjv^88mb0Z8TWKojCcW6j3aHUxCd7|MdI z3#C dq&9quW)i(xJ zO(5Lh0Cbc{nAYn?$A(`u=ft+^IDSMx0j!s3TzgvuUBB@uh2f(XH$5`DbQY+%JZNEv zzWv7-Sot)VO7{C)H8DlYfF?OWoZ$iYfgNO`6p5f~DbP}lK?hO~djhLkEVzj~BJ_{& z2S6$*Fn_l=I6CHE7D0QwL_=c@@qxcVAGn|;l= z~1FwbNFYZa@UpMn-jkxFNzGlx`f2!+U2lNH)A3q>Jg2t8tGDvXCQObB)zJf3!m z0^t`w4wf78!$qF1*+%nP^B~~z*4LK >nqa1`+!0)XGn6tbw27P*JYNYTy)J@n@6rh2N9Narym)Nd@vqQHMQsQf7iPb%K z?%xOc|3$idNC6NG%1arQ4rUjDaSc)8bnDR?l>z9X5V$IGT+~S21d5#jjZJfz*Loxi zh{FQ3Qr_AXGy|Bcsu$|MFss5QN1f7D F-WFh?*7!ML6QjA^bq(! zf~|t0Vzv>)lOX?C1gd))s8g8=$x_hqH<@$s{$8xi;k4d(*gMEY0AFzcaPI+WGZbe6 zZ9jYJ(yd32K~hKgP!yzr%%_t^Es#l+QvtHi?Rl`HsqOyvLN@O%*4@vUZTs}%mRwvG zxMPeA3=LAHgNq2hhxUN77yiWO6cKe7sLqp%izwtg+Nl5cCcL%AJ`Cd5)gn*B0irLN zV9@^!W^4dgm<7BDA%GAF_1N#MJh2+B(I#e83bbcP>n^p{C1%!80tK}PdOe_xXr|4- z_k!W25y}f>N=QuP21WoTMk9j 1Zj5ke3`w-!86kiw0cBR8SQ(=#*6v liUdvn4hg$6?H_~D{_iK;#q?uH6xRFRl@5{J{jgA~zgHUKKapqCnUtK&C)2_rDd6}gFHC#sC5cYp4Je~6 zMlK*IW|ikav~;0Jkd5szQ1f?#R=9T^A<}|0<)R|4SG0I5rbVilqcgUZ*}s=R57OXd z4gN$aRw(J(u=YA2ufS!}LJ7V>hlb~$K6XT6AmfZs_GJo+Oi0x8HQz&O6C^|C0TK0r zMspcF5o&4}C9}Pp<)77!7p&Tb{hVc&F `slvHKV zK)=`53p_*_o@_|IneJ`OA+7_KMj{A(kiM8uQTewJ)ai5nfP!=aD;V88-GyfXrWPYO z$0gRIIpEbu2SaQ)M8wMzg>J#>=!TU|OiY{%_dX9kRoD$w_#h970(6^(b(tW`%R|f^ z)w#U9ylBOHn4M@CE2JiY{qt%P^S{PQ3SJ611rY9=M?aM^yt;5Cos^m?9Sm8kEG}Tw zySuw)ASEK04C(6|kQ0I8%_IkJLF@~ZIpKPo+phr?K-_&af&f!@-nev-_`-#c@JB~i zPmHyj=3)*mO*(3RPy2)>#p^A?y%3~FQXqoo@IJ_AjC4S4aG3mgQ4;+!H8Yb6!o{6z zO@I*)gyU+mN=;4G#Nn7znAEK3^6p>UihfLi+1aODytH^3BPaf!YRowB^Y3Sz0mOt{ zFyI&R0Mw^oQ^9hC{L4 6|Gwoyi2%#bDMDou;mFn9gi zP`Un@T`~`tySzXbt ~4!c+38FK>rKQ`<+5a9> z^TvbyD*+_O(67PP1#Y<;B5; | z0hED4xNn>8^S@n^8!qYar%xUf9P@#qbf{~YP^mSV)~A|MQG^c?Tjgp}f8Q`BiJDM8 zq`lzGLkNSS5&%6AX{3 3~kbtf>wy z0dNUnNCrsDOPwVk=m0v;<-9UBM1Sk=dsf&spq`-stLxaaQ 4=Rg0om*GX&UE!w|7wh&& zka&mOwWOpQ07fm~LkFteN=IB~s3bce!Dd)G7(1N^gj@xDNt8PPSC4!B765T?Cd zDgfOc65X1)fP14gi2j}P<@-ecUd$Z!EebFC5;LV(*X)W3xo!Xfc$r1}Vu|$Maye4# z