From 4b584874cb9e1985ee2ce28ba289f661b0662fe5 Mon Sep 17 00:00:00 2001 From: Mahziar Date: Wed, 6 Nov 2024 23:50:31 +0330 Subject: [PATCH 01/60] feat: add user registration and email verification --- FD/__pycache__/settings.cpython-312.pyc | Bin 3555 -> 3555 bytes FD/__pycache__/urls.cpython-312.pyc | Bin 791 -> 791 bytes FD/settings.py | 26 ++++++++++++ FD/urls.py | 2 + authentication/__init__.py | 0 .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 162 bytes .../__pycache__/admin.cpython-312.pyc | Bin 0 -> 363 bytes .../__pycache__/apps.cpython-312.pyc | Bin 0 -> 484 bytes .../__pycache__/managers.cpython-312.pyc | Bin 0 -> 2344 bytes .../__pycache__/models.cpython-312.pyc | Bin 0 -> 2509 bytes .../__pycache__/serializers.cpython-312.pyc | Bin 0 -> 7947 bytes .../__pycache__/urls.cpython-312.pyc | Bin 0 -> 820 bytes .../__pycache__/utils.cpython-312.pyc | Bin 0 -> 1251 bytes .../__pycache__/views.cpython-312.pyc | Bin 0 -> 4446 bytes authentication/admin.py | 5 +++ authentication/apps.py | 6 +++ authentication/managers.py | 38 ++++++++++++++++++ authentication/migrations/0001_initial.py | 35 ++++++++++++++++ .../migrations/0002_onetimepassword.py | 23 +++++++++++ authentication/migrations/__init__.py | 0 .../__pycache__/0001_initial.cpython-312.pyc | Bin 0 -> 2241 bytes .../0002_onetimepassword.cpython-312.pyc | Bin 0 -> 1293 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 173 bytes authentication/models.py | 37 +++++++++++++++++ authentication/serializers.py | 38 ++++++++++++++++++ authentication/tests.py | 3 ++ authentication/urls.py | 8 ++++ authentication/utils.py | 19 +++++++++ authentication/views.py | 38 ++++++++++++++++++ requirements.txt | 3 -- 30 files changed, 278 insertions(+), 3 deletions(-) create mode 100644 authentication/__init__.py create mode 100644 authentication/__pycache__/__init__.cpython-312.pyc create mode 100644 authentication/__pycache__/admin.cpython-312.pyc create mode 100644 authentication/__pycache__/apps.cpython-312.pyc create mode 100644 authentication/__pycache__/managers.cpython-312.pyc create mode 100644 authentication/__pycache__/models.cpython-312.pyc create mode 100644 authentication/__pycache__/serializers.cpython-312.pyc create mode 100644 authentication/__pycache__/urls.cpython-312.pyc create mode 100644 authentication/__pycache__/utils.cpython-312.pyc create mode 100644 authentication/__pycache__/views.cpython-312.pyc create mode 100644 authentication/admin.py create mode 100644 authentication/apps.py create mode 100644 authentication/managers.py create mode 100644 authentication/migrations/0001_initial.py create mode 100644 authentication/migrations/0002_onetimepassword.py create mode 100644 authentication/migrations/__init__.py create mode 100644 authentication/migrations/__pycache__/0001_initial.cpython-312.pyc create mode 100644 authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc create mode 100644 authentication/migrations/__pycache__/__init__.cpython-312.pyc create mode 100644 authentication/models.py create mode 100644 authentication/serializers.py create mode 100644 authentication/tests.py create mode 100644 authentication/urls.py create mode 100644 authentication/utils.py create mode 100644 authentication/views.py diff --git a/FD/__pycache__/settings.cpython-312.pyc b/FD/__pycache__/settings.cpython-312.pyc index 7e97e83cf99575efc3271dcd0c8697abe86edc6d..74370bcdebe7f6ad73007c059f68899be35a0249 100644 GIT binary patch delta 20 acmaDX{aBj&G%qg~0}zxP)85E^gBJinM+N-= delta 20 acmaDX{aBj&G%qg~0}!a~(b~v;gBJik+h`6Iz^FR2-9? znV44@O2k=4F<|$LkeT h{^GF7%}*)KNwq6t1)9hR#Kj=SM`lJw#v*1Q3jjAFDc1l1 literal 0 HcmV?d00001 diff --git a/authentication/__pycache__/admin.cpython-312.pyc b/authentication/__pycache__/admin.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bd708cd1c5ef6fb8f1eba3d1e8f274b024b6112f GIT binary patch literal 363 zcmYL^KTE?v7>D1tm&6clm-+z;P7&+}5b0v+QgCxAL5R6CUYbAU?lNR_a@DDyL7e;? zP7WdjIyrSKWb5Re8uAYJdmdiy_}zQI?}47??KHm8`E8^f@(<0OhBZ*2m_mjNM4&xN z(q2Xu1c71VwpY4L4m)m%k>sf5E?@DF5h}~Kw?=%d?T}_X-4`>qR zaWRgHTnTYaQu_;$B8WP;2ocPzQia-5w{9*WaGbqsj7^i6ONO`!{U7RIAc&KH zhl^tgI61iq(WR4jNgVoy_rCAm``*3x-kQxC2+Lj%?I#gGCHc2X39RQ_F#?cM)B4LT*hQ;w4s}@k!(d z)*xi;sPFJm%()oSTK(Q4LBNe53FQrQrwPED!p&f@ef+L%E$TZD{ZPK-`Jt|+^>Rfi t4&_w~c|)SSuqDv4+9`H94>tsr2j%jZ-zq}r1NMK`6tw$z@(Uv5sy`9rfT{oh literal 0 HcmV?d00001 diff --git a/authentication/__pycache__/managers.cpython-312.pyc b/authentication/__pycache__/managers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..804c46cea677eb37a61d66d2f41d6503e93874b1 GIT binary patch literal 2344 zcma)7&2JM&6rb4-XB|7)G?zgw#`Sh)@Y}>U;a)d=S)*v~ShSz;#`y(NdP3dxy{oc1?%a@5y9u>?CK82{3$SQCun(07*W*p}y1 zD?^-Daj1Jb9AYLbTaF7O=?Ug}?4oD7R(+-vdJd%&b=C|}<_RMOYLkLw(^*oGZ3(1e ztF|&r3+jxVOZce`tCwP9^pSuM_q_H!hcBM93FVm2(>=1hp3!qGWb zK=_?-R5ccyl26T?4UxiuK(a4l2L-LNr)O9075eJeK6-2z`*=y>Rr; z^lQ%^==+`!iX**yZ+yL+J=Gfcywy9roF0BG4R(=Uqjxq5cuw=4*v+*_wX|%mwk^s@Z z@$C?p6M(^>w=_G^fPLG_4XL60O0UsNYD1mG__wr1dta0QqHD`Ebisf3i=(&II1aS? zmgG7{>wTxsF$1r)VwG7)zA#b0q$wF$Pvdq-K4%+ z6HaB~Cdp|WD-6eAe7#cTWyt1wl*<9Pe6?J)nd_%vX>%%a4`jBcnJ=66*v$k_qlX?8 z=-t=as5DdoZkgi_b8X=(xCw#OF@nbNB>Yr~Gs|NpPS$r}z)`u$ZAx$>sFP`+6oCJJuR_ztwwuIemOP+@qe%YR`dY&w<4=t)92%)O%WbRom0l z_ACsoXsYH}g-OW#j@ zb4jqgr|+0ct-per9ho#+yS=O{1tGTQ~XXKoz2Frn(Ofa7~;E7^r7en@dBB8BF?lIsPEl|QSI4% z=)(hXncUMdt6H|HWfwb_23NGh>;3LK%8GV$y`Ne-y`mji?{_VEE82UH^z59ss%M*e zc2Qe8c&D$akK8SS2-ve7UpTIwqoeT7X{L!;Zkm3lX+pZyT-4L1`9sZe!L65VPcoq^KkGNxECjJT){m6T%TWn(Op;+mlvhqM9#ND;{^o z^7zE$gH#tB3%LJk8^BqfT`aLFeD+27x^0?_9-Jx{Va4c+ENhkbp@@NE%bOiV-adZG~E;u>%1TAPH5qO<8TcV`rW9ZkSyI zi9m8CRHf>nsd@;99yl5$QqxP-s{a6pODrroni92Dd&!M9^uncmvunEn(ZWc6``+8% zdpmF5$M}9z6OUl+eEV8<#f{K!OsFqUjoJAWm=z==nNv}o6F83X4%Lx&3eLPsa9Ofb zb>}^T*Wz8OFYg!pz`JFS%I5<@fI|*+63O11NcLeTcCET>??TYh`$5lRE*GtT{NLxQ zvxaP52*iWiOo(&Tlh zW#5{=ju!$ig@QCetS|D_^irs13w7{0`*N)DjeUjx2Nh_bTI8S*k(=b^tV3u8i#@9~ zx58dIbQ1|}au{g)LQ6bCT`axZe*<7@u_=|86t#OumZ1uaxRdgE>6)lwEo;tFPeD^I z7cdQ8!DL1^u&7CSOnuDU=FUroaaAXB+(DYa9SH#$fvWP#I3pEQlNFd$Fm+MWuZof^ zgY2uz)K{qcabva#>YJ~*OV_H!3W4#g^mkZKtf1>CXQ`hilh6y}agOW-HGITnU^Qbf z%kC%d4ZkzZc5F<~DU!A@oy5kRsn1W35RChiijgi@9Wo93n#G!_q$Lv?X4>wn#QXyF ziXt?YC@uz>n?y}&2j@l*ND*y@TEDpQ*^Lcf#}3Hosok^owsx-pvx3Y(GK>1U zj_Ucm!I?0I>~)>1Yj^8%^|4fk!LyE&xvw2?B93tw3tnP3IQ2;BG)u9Iur8*)j7~(B z0qTV4AWha=(PVP~(zzcngq5bj_SxM{*y&S`_1ajp21LRZ*bCz*kRo~*IarMxE=LZR zTn{4sOWxnxqqmNKb^T%6{#%Eu(L_0#c+hs_54SVSKSEAFPddPAJid?mM60qyk@BJl z2VYPz@Ig_$T#!^&X`)EjAyVF|))57(GUY_l#&rJSS&3LGEBk~ch6dl)brnmR4fQDo zq)^IaXpnJ*c}%Rx10X?ZD;WjL)DHqoPvr`xO-aebQ%WA&<^dT*F{djUz<5r6?2 zX>@F@6Ga6Kw`Nmqx0=&T_D`)Ve-(kGp&1!6~5<{HU1P40SDCsfN1Bp{~-K>*l>f zcTeB%uY^7p=1B zW~6U9R*m$PBYh=Yi5x4Q*$nqCd#d5ya=5pYs)XMtj&4R`3==CyV(ZCDKfa;$jzm%UxZ@y+&wxA~2>*Nf+VZQlQRzIgIa7w7KW@wnY~%B^VA5NKo7 zEYmIBne6KY%!_NRflpV4YZlT8Au;7k+Xy}|~<-0M@IO^9m z5pGs&;%(MxQ}q$YF0Ao5PhNp1>0>0uh+Uo|I>Aa-JA)Uj7^!!7XmWTcnW8RMZzK*# z>eS77GQezD`P(&coNpa@euXPIZ4av333eq zdyM^0v0{8C_$R&-cFVs@lJE^SAdO={9yvLV+d>^bqtPuixP{)@LWdr^_Hq11;J`*_ Y|6>H~<3R^ExSUy0zf}K1z*^_<52sX4xc~qF literal 0 HcmV?d00001 diff --git a/authentication/__pycache__/serializers.cpython-312.pyc b/authentication/__pycache__/serializers.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0f54ed11cf8609484d59f51f5902b0266ca43a23 GIT binary patch literal 7947 zcmcgxTWlNWm7XCvyi25bwJcepC11o?RwBoC>{#nKmSoG4rDko#O&J3O#TiMIhNSb) zD7uuw3PB#|qG&9nfK)V#3X47{unV|P^;@&(Vi90p$Wj1>fs7hh_rWiPyHe4@FT3ab z!x>&o+37B@Kct`k<^1<^&Uen4|7d8iGmw7s?{7>E`xxdwv7#leRM-_+hFN2HhG#`4 z$wpWf%LdVqG)9cHY!ppNF2W_v5i_luL`%{du_kR18?AGqJ?V%zXxS_}lMRsuTDFLd zNms;0%U02yY>G6|vQ6|Py%8@h+eKf}AMw+&LkuK?ksy?ve1mx4R|b|bFjpDA@pFcE z3I3T=+Sbdz_l;^fa*$HGfwBoG&z32)l1|x5DLp{xea)PQD5Vc5{jZ^Hqm%)l48Df4 zeepoJ`5A61%qq^?V zM2<=_Q502=|!PD)qitqjM?i1C`K zADHy_29+ja<}IHy5zC@AY*Tz6!hZUR09Sld-`F#p`Pn7N#=kZy_GBUz z73l$?99z)K^91fDnofy}Ra-7C!|Wb_3Cdd*j1kimh|#~W5pK~CHoj=Cw{*{_AjiT+ z#X1N3O2v|b!U=fYiuF$E^~b(8E7oWf`=U|B9*ri`{G5noXEgfBTudzWm~eW9gI+;Y z%##U0yCokhijdnGL}A!rXqazgewv+ed}Ii=~;T{Afj4I~U%T)Pq|(-%d#+#P^yjgj%) zX_~2|(%MX=Ld6fyCYTKSOZF%1vLR!*$%GAK$#e}^QZYUHAkuIFTtd3i1!7HlejUKfVPc5}Uw!-YVykBJQ zW{R%kdHg%S*#lYDwC(a{9rO;y!d4OjQgRrx4$K-MQ*8ayF>(bThTu%}s>oYd!sDpd zhZ`bZEo*{;gx3o9>^hSDVbOIY?>h33eKhgy_?GL&%Ftg;2JY=YGmz~z8aSUi2M({| zP&_TB-E)wYIdp%adSJL;dZq}+8$o!?i>5HA_-_G2OfFJ=a8u`vXJ(l7YnCAUSG_#G zB`&=?43n+F8_wu&53Qxdv)WS21{)*mJTX&;6Y7o67|NSnHfD@7xSN`^Y|5DGX%DHH zH)c#xeI=lB)C`5{{ZbRlWK1_1xk00t!AGr0yh%IFXKZKXY1}o0P(8ws0QcDa!t#bV>-x ziKGzHv=+fH{=qQYUKZIY{M)B|I$IoK>qFR*%L*3d*0r@*}G*wxzfLDa&sfBD)LBN zCGr|i@BSO~{JkQ-zhaj=z@gNN1(kZ3gVN=wofX71WP?(H6EVUewRm z(^H95h4T4tI3GC!*?8DMRS;>0N~x^aP$2&IkbR)eiYj88NGmRkdK?V5cpR*wsEj6> z(m;esB1qF}b&RwlUCjd{NRlg%^}&BNkPhz)q!ys1>hw?FQJwCiaCO}d>XNAo!u8a& zQBy?ACQ#EFdolIY?LKN+SB=YDhMPg_p(aTVSy#VczLj?I84J{|8d9ic^c_`=t5BFQ z4gs_#Y3h904E-pzH3?eLys1fYP;1D5If*Q$?XRlGTG_+fz5>7i7%1OX9TOH{zzHx= zK6%9nbd_%(tKc`V%|DqFK-IFe@kQIUl9mTkg+~V>CQ98QnJk84Q?W#9$wL7a%A~=wXnCSvUhOvs6L(uA%kkKhXlpuoY$feeLvZ!IhB^>t@ zGQbA!fwl9i=hq_zZ&%h@Q}YDYx>vi4o{qeyW8-|mb24jrZZg`gu{+)%f&}10-rKqP zQO?_0@Se>MJhw7#U(wb6dsq98=UDFeg@UIq=j?mp@)lhkc~{5Axh+>W?YUa;4CS0d zPn!H|EvqeG9?F`Zo0-7Dwa98@`#|e28h+NW)7rja-dMnj1U7B?!>0;| z-u|#wRBo_J-odg(H@VTLZV6oahV@HB3@POl7Anxe*UOk1u&X!Ca6yHNCY<-J`G`@cE8 zR&8!7t2d0+V9&2I*_eAiY+(O?~6(PAB8cdbFruHqN2Ss%ZU4PTF6novhn z|BnR>G3Xd#rY5X~ny_t}p+lYO8>%3c;iPI0ni|{&}f=LVXH|Q!xa0#e!m0(=)umQ zMNpHpTY+d73wIi->4s{8rBDK|t)`g{tyKOkLCI0Ag?DH#@ENN^iF1r{BY+20_^5l8 zfTp*jV7Xv0D4m*2kR-&C%yS8T;_PXKqxu};HRjp*7@3lmEYq@l=iN8oT=G&$3N5DR zi0)l1b@z*j_-qK`Ss^iznhi||VtPI#r$co9YGQlXq*$am>clIii8Q~c*e6Lk8C3%z z1_*bJz$U|9ibWL=gkVZg5EMg#CwRjcubCB8%ZrR*H*%_C=F<>UNfBgK;=l~;3B@%y z`u?@+(V_3%9FKl@bMOX63{t#$Ejqjb1ePI--WUa{k8qYBU`8*{iea-T#NE*R6Jv7X zQz4262itH$N{e$^Cp}VBJFH!;;*qHlSOPuzLqiiP@*RJThS5MU3fqw&Oo|}r_M>OB ze!SrB$a2q3-j^WmD22GUvI9^3Z#=m0Q2x#QujdOV-YfVoWv}e`-Yocfi@v_RukX=# z!FM@3xD8Myu8PILv3%gz#$qARo4vXdJiciu1m7wK-^mBx$qwzbcIJ-s>L*KT3+n8(qes=h&FSL2+QDkfUc5z(H zkBd1npZjEC%eM$?I-9;2zB~Nm>rZ?w>*K$O{4A35cIBL1;FJOUz8WIYy!^61)PI`! z-RbrLFJNT-n2ep;Mru%ozAiP*FX6-1*z7m44KsXo38fDdwX-8TQgn9YogD?|k(I%} z8Vp2aZvzL*wrCGKlxlG7SCv2+97#WHcO1fQ80k^$CALe0mp=dv1g&P` z#|-hZ>cE=TsuLo`2=0^tLZrs9=|we`zO(@qOKI#3(jZW zfGp{VKhxRNdmk19qXVTWTH$d{GgFqD(>xl>%OgRX7ls#pi%b1p>T2f)-2#fKbSAsGbYO zH|o$RPJ}=W5y=G@Q{}f%{?qqDccKQi>iaF#+WTdm*0lFO!8+1ckiF!>K$Ob4>MXiL zd3R_dSa654+%~uwt*foYro;KB!y8?NrW0B7(}uuSuzT}XA$Ypba3*WekGUHk7T&v+yZHTFd}^yo zYRCS_1a}z!p2^Aj)-M!W&gNUr?lDl=YxA-r?D|Nt^;Ev~)Eo7;F@{}dU%t<)G2&g4QQEBlU{W7)--T?km& zU1_JCwLuI24jagt5I0&`U3Z4=3ggTICf69I*m>{!zBBuo@7H)diuhW5$#*_rgno%; zy_8k4_`{2LNJlz0P!AttjC4s47}946BMAZ4WeA+D-RskQpRDSMv@CpY5P}JU>MC;A#=LyPJK3Jh#@oV_j-+Ln1jXiu#=M1Q$kuBlbq>VjEQz0}&- z@{hJwPJv-+J$RandNKV>Gdot%p$4OZ*$WN`44HScz5;77phsNt!WwIdnU^^7K_3cE ze-eKzQ2!aXpRI`N0=N#Z9ALI@ygoTtg8P#g2jJ20AWZfVEks&Z`v-eXaVcwdiDr(P zhrqhF)o(tc0EI)s+74sHGMk!XpMq(Vwq}b~Jf+wlT}7%rOLq*Y(Jg+Ph@8FVpUVKo z_$SKzKwG~fD5#v@o~G{3Q^i@T=%((wa_PGqIluE>ou^B)bjeNcxN=!Y+i&;h$?Pnd zb(48lzQ?7hlAdPD^GtP?sk)h(D?jkXooVICywaFe8gAwJ^u^$tJiLsB64IL(y~Y3k E7ft}#>i_@% literal 0 HcmV?d00001 diff --git a/authentication/__pycache__/utils.cpython-312.pyc b/authentication/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e29c661f3516acb2c2b77f1d2efd61b0614ebaff GIT binary patch literal 1251 zcmZuw&1)M+6rbIlmF!hyMM>*mMAF5rNj6X&dk7}9wcRi3Bqa7Bia=fOj^vehcg)O) zAhQA;dZ@`EmqG%i(4&ieNdAM~ds*QGg^5D~&BeEZZa#Hp<*}gc1I_!G_nv<9esBLQ z7A+uT^!$2jPY2*H=_nC?CZiidJpn%OwGc#FP1B^Uhk8x-4IhR^1ZyxdYi1TV!(5cF z<%OO55!9x-x|2VaZ5)lCTZ!BtTt$?+Ep+QxlTn?QZpUaXh|nFE(ubJ%9IjJDMEVp( zj0JIvdNNZ1rfitxEheEbsu+# z{huawKy3%|Jnd|>k;gdPza@NY!k&S@Ulkr4huqB-R|-k39BJSKkiMgh~Dx&^k;GO!BKf`Sat?w zr~lo1t30$S1FQ0M<U3c84F`} z3TN4IGh;b>=Jn^ HC9C@fDLpRZ literal 0 HcmV?d00001 diff --git a/authentication/__pycache__/views.cpython-312.pyc b/authentication/__pycache__/views.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f1923ab52a310d90b535395556ed05e7e2ed74f1 GIT binary patch literal 4446 zcmcJSO>7&-6@X`d_#={%7>lG7>!+LFnu;wsjuN!4lhoFq+r*+OQ%cOLSgg4tX{9A8 z&n)doV2U1ma8GGZc7Oow(SlW^1p=f!wJ3sIdZ7TK5*9L0py?q%4+_Lya_W0SQd-GW z4gYijzJ2rN&CK%6H*aSC*4i2(@O=Jd|NON!LjH+`#^b9iyMG1cHZh4Q*(4`rBnfrd zmUBu*5xQclIbX&nbk+9f0-1o&eReR{l4$|mZwBnp@8r4`MolK;#0=gfW(!sBDYdaO z5n&C1HSDpr3Tp(ctsZNeu(pA<-D7PR)+kt?@mQlP$C4di;~+`N^)s3`DFZEVe)2~% z7QF##aEkKfLY~t#aNv&N6ggPiIJoKsXIZB?!?M4YYCTB(+1dx!BwF#=O@O#fD9K1B z$;ed6sHSw2WPB@fQgNeGbl&0)Wz(E895pM8RyBdq#whHbgx+owr_m=VNsC^;dXshO zUfttExg;+(=Bi$cFH>Gu8b0hI_yjzab@g8TEfJ`1GRlXF)6!^FQcU$(C^Y(%h!ghI z`Aq*OvJ+`o7J;V2&wAiqeT>WAw(?^1uqPp&o{l|O>+LBSoPa4)O~WzLptLi?;#8*} zWa+YF74oi6%-rfljJlR()NrV&@nSYhIiD-q_DV>bw5h?VmR+FPC2gfpWSVHB%@vrI z%2O?E<)}7kaDJn}%%sfP;fvfr4mvRADf2@q8FFJdz_955GG&%wTdS1ST!R+ZuY6TEC?+aP8q{FaibU0=}G;}=~wmPsnPT4(Gk}VEenjhF;O#g`m}!G{D?j^`jhF= z%ju*F-B5eZ4KRAGNV(&NVC4FMu3CfwSD{}Go^Rh8K0Ava#%Gr-Bfl~`Liv(YSe_kY zl%5)~c(%wnL}}J2Itw)KSXl#uHhUfL{&T9NHx&vs&Dds;1@e8sYPu{)p zX=$xgiJ!X_+6u=v!+jg!zPnC2+*b(?ZAB6vrGA;JM0&S6dT+n|>$h)>Y)6jY8TxJD z<2NgjC!M{UiBlViQ`LYH8{8pE>tHoRqFr}Fn_bC`uHfbUjxvi#F;JUB6Z|>{<sBItGY(txR&K1*^CM)53)t)Nk=z1RJoCA-%WlEvOa-}m?Q)NJv4dP)W+ z7`mhxu6))3Vzt-EW(&oiKxT5IeD*+Sl-A2@ZO#f}hk zJ1}S>djA_$)(_#^N0hw+#U@xDJ1`Lf%3<`p0s^5tad-Yxd(HmKmGb0!<&pO*@hi>Y zyz}GVq&`l4Hnur5wlOqT=@>6Z#=mOsY@S??0`gV-wU;AU{pL_n) zQ=Yt1{^`|nDqD%0%^@!LT&m2>Zq6)h%q&zota8M9WQ1RbF^@+0HLOMP4*Yf-IrVi! zSl&k%9iABB;X;1SVmX1Z3LQ2cOjjbkZiDi0giBIMhC{maiQ-AIYDsN+Iq;^~C&yTQ z;`2aK5ee1x7cKMZ8$+(oDJ;=E^KjjLh->c=zB$A7xIPlYNuTF@a2Dduet<%3thn95 zN#Vd*;UL?dnH2f?J!rux2rt_JcHY^6+$fLA-w-k>jmcF)665kwq<{a5mp3n@H!h?r z9nJ#*LbbMt;69z!wV4$rc^XkV!WN*~QW4 z7#z6aR@s4ViVr??GWs@nu67T2i;aLY-k7-oVn>lA={|YkJ~?rpoV-t-`xhD9 zCdao)yy|b0Qqr$(R0+PT&xf%zUnTgiUX)s}I9?_At|o#Y?|>`5tFcllU)Af+k2>$1MZ_EUi1qv?)?1 zDLYb^6v)uATRmoN?V;T?dJNEo0t867DL|)e0-T~imi8Sf!Ag+^;lO+E?)%=oKR*63 zG!$asIs1LK7#v`jKdI7t{9WPfCI~+>h(WBzl-Mj=;<6meaLfjSxaSPwF}o}DNayP@ z@5x!d>Pz`wQzgZbP)RMCime*DMTC-pux91YISdo+DrfgOdMReIEMl@8VzWHrifq=$ zG1$Kw?x%P%;t@R?*cIP%#v&H^k#I6_?DI|w916JT(f6Q(fDWAu_5uKG*u{?h4|eDs z?C=M$BPZf9#6=&l8gY>qJ-z>p{3oNwthWQW3m=Ia`$*jQN8&EJxJ&(>c`{;8qJ92d z`T*{7A1>zNV(+tuCeS2`qXdebB#*nz1d&T0U`)A;PtcWqMlp=0kzZv`W_s0wo*AxQ zO?^s&4|<)q34Q2OD)tVUSy)JaCM%kaO}S#htDu^eE$d1N%O&NhtYN(fFy9k6Bb--t%Ltj+vQj*J7U#DK6eJBtYGw{*VB5!jsC1WgQwOB7sL;DaDzzle-+% zk}X>)6jCf1K)9e(G@FF!W~GcxYLE}?&Y znkE&oEh(^qOC{B^=tKarqC-XjD`6$SBN+uSgjKU^b>2&dY+;-DsPS5emT}&_GbIrJ zwx;NNB)EgMvTWn05VeUlS|hInqdkqLx9aV2w&7(>qz2_aZ)#V*F5^6;DF2R+1;ad# zZoW3+dT>swxpw*)kg09UHX*}MXrEg_0rIXPiwqcLcN9KMjgWxs^_@&YvJ8{4qG4Gk zVOidO^y;QT-`+;ZfJnXZ{R?vjmoRT1tzOSP zfz(;KJr!`dHEiwKMmcxK#CUE^wel6WxH+09tlMhdrRUE71GxqG3gvEO^W~~Zzkl~d z1?yKBgwL5*!=ukOIsy~C-r>11;n|m+5ED%P7-|I)jXC%{W`Q87nwqu^pLk;&Vq^t@vCcK3BhR zy6Jqo-HhiQ5&ie*<@IKK%MrhE_ZKK-p%GuGk2mAD9PxHLl60mrFD|yGGL5NB{Yi6b z)e+a)k%S{Hz7Sf{VnbT2uQ#O?M_g@3CY?n3sL)EJ8;Nv%rkS|yh|4ZEL$R5LlsTPl zN*j)Nw;hQ)$;G3CR&ueCT&!<4lb(gk&P4j?^VUSVF_EtOIt+7<)i_wb=hk^xvix^+ z!Tdg5Zu{=~-Z0GMJqnE6_q8KeoSCH;TdkR;#>~>`wDZ*=9Bgopsf7aP8rtVpDD^R) z=Ye_^Q6+-;&gG%8;ZA~yOdPF4F~*1AOkD_uI{f&k@WXxcMz44eVNM3ouA&!>d9>~J zsN0rad6y{6#eMZ0QH^k|`7*JgH EzXX14a{vGU literal 0 HcmV?d00001 diff --git a/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc b/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..bb0da61083e8cdb006eedbd62d5c127ff5b7c2e2 GIT binary patch literal 1293 zcmZux&ube;6rTN|)$UrBrI1=u4{mFGkW?z%f-a>LR7p;n#!g9W)5wBltr@RJUhOI~ ztF-E=g&cCsEg}Cwun);8`4bAg?1Evo z#YG+PHGNUF{t*HAJ2K`gH3QSz96SaPK*0tbfxkst97r8Wh@0r381W8_RfwuWaYY>w3YO53M1bV}JmNwY6k&9xTvij5`ICamN%~k$QVv9@#?iS86f+1l zD4*!ZyjRiUkqdF;mBhZdBfYHZbKX(j-mE$D4w2*GUfg4rSzllO+`ta@uxYcUTMpXB1N6N~>5ndfOvG?|gNfg^ zDl*IPRM+ScWO@j)g(1NMlZ=fcG-et_gy-X*5zE|1WZ$L8aLfT>lIsm$E4=7fS@S2u zMvmnjGG*l8dn3f;5zpjjn(G)`YUCj%dMW5IH@JD2dc$3(dIR&1jHFG|?>c4YnHmCL0ko)0&4SNua*r5`-=1 zJANSr_D#ofnF#lp8W+X~lBJuCJIzL`O`&DKV_HASHR;zuJ>FyBCM)w(* z@(}Ruhvt{v9ezdHJ;J6l?zRv;^4wu}gCO)-3)9|+QqJ7{zbf7HZ=lQd`ND7=2lIQ0}leBy_`zDpv^ogWhQGUHaD)VA1 zDk~+et-<|T@_EPD>6Iz^FR2-9? znV44@ Date: Thu, 7 Nov 2024 00:17:21 +0330 Subject: [PATCH 02/60] feat: add user login and reset password --- .gitignore | 1 + authentication/serializers.py | 102 +++++++++++++++++++++++++++++++++- authentication/urls.py | 3 + authentication/views.py | 33 +++++++++++ 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 .gitignore diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..33ceb8f --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +Makefile \ No newline at end of file diff --git a/authentication/serializers.py b/authentication/serializers.py index 3ad064e..a6c4957 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -1,3 +1,14 @@ +from django.contrib.auth import authenticate +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.utils.encoding import smart_str, smart_bytes +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.contrib.sites.shortcuts import get_current_site +from django.urls import reverse +from django.core.mail import EmailMessage +from django.core.validators import MaxLengthValidator + + +from rest_framework.exceptions import AuthenticationFailed from rest_framework import serializers from FD import settings @@ -35,4 +46,93 @@ def validate(self, attrs): except OneTimePassword.DoesNotExist: raise serializers.ValidationError('invalid one time password') - \ No newline at end of file + + +class UserLoginSerializer(serializers.ModelSerializer): + username = serializers.CharField(max_length=255) + password = serializers.CharField(max_length=255, write_only=True) + access_token = serializers.CharField(max_length=255, read_only=True) + refresh_token = serializers.CharField(max_length=255, read_only=True) + + class Meta: + model = User + fields = ['username', 'password', 'access_token', 'refresh_token'] + + def validate(self, attrs): + username = attrs.get('username') + password = attrs.get('password') + request = self.context.get('request') + + try: + user = User.objects.get(username=username) + except User.DoesNotExist: + raise AuthenticationFailed('Invalid credentials, try again') + + if not user.is_verified: + raise AuthenticationFailed('Email not verified') + + if not user.check_password(password): + raise AuthenticationFailed('Invalid credentials, try again') + + tokens = user.tokens() + + return { + 'email': user.email, + 'username': user.username, + 'access_token': str(tokens['access']), + 'refresh_token': str(tokens['refresh']), + } + +class PasswordResetRequestSerializer(serializers.ModelSerializer): + email = serializers.EmailField() + class Meta: + model = User + fields = ['email'] + + def validate(self, attrs): + email = attrs.get('email') + + try: + user = User.objects.get(email=email) + + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + request = self.context.get('request') + site_url = get_current_site(request).domain + relative_link = reverse('password_reset_confirm', kwargs={'uidb64': uidb64, 'token': token}) + absolute_url = f'http://{site_url}{relative_link}' + + email_subject = 'Reset your password' + email_body = f'Click the link below to reset your password\n{absolute_url}' + + email = EmailMessage(subject=email_subject, body=email_body, from_email=settings.EMAIL_HOST_USER, to=[email]) + email.send() + + return {'email': user.email} + + except User.DoesNotExist: + raise AuthenticationFailed('Invalid credentials, try again') + + +class PasswordResetConfirmSerializer(serializers.ModelSerializer): + uidb64 = serializers.CharField(max_length=255, validators=[MaxLengthValidator(255)], read_only=True) + password = serializers.CharField(min_length=8, max_length=64, write_only=True) + class Meta: + model = User + fields = ['password', 'uidb64'] + + def validate(self, attrs): + password = attrs.get('password') + token = self.context.get('token') + uidb64 = self.context.get('uidb64') + + id = smart_str(urlsafe_base64_decode(uidb64)) + user = User.objects.get(id=id) + + if not PasswordResetTokenGenerator().check_token(user, token): + raise AuthenticationFailed('The reset link is invalid', 401) + + user.set_password(password) + user.save() + + return user \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 2013715..a282e64 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -5,4 +5,7 @@ urlpatterns = [ path('register/', RegisterUserView.as_view()), path('verify-email/', VerifyEmailView.as_view()), + path('login/', LoginUserView.as_view()), + path('password-reset/', PasswordResetRequestView.as_view()), + path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), ] \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index 163adda..feead42 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -1,3 +1,4 @@ +from django.shortcuts import render from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status @@ -35,4 +36,36 @@ def post(self, request): 'message': 'account verified successfully', }, status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LoginUserView(APIView): + serializer_class = UserLoginSerializer + + def post(self, request): + serializer = UserLoginSerializer(data=request.data, context={'request': request}) + + if serializer.is_valid(raise_exception=True): + return Response(serializer.data, status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class PasswordResetRequestView(APIView): + serializer_class = PasswordResetRequestSerializer + def post(self, request): + serializer = PasswordResetRequestSerializer(data=request.data, context={'request': request}) + + if serializer.is_valid(raise_exception=True): + return Response("password reset link has been sent to your email", status=status.HTTP_200_OK) + + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + +class PasswordResetConfirmView(APIView): + serializer_class = PasswordResetConfirmSerializer + def post(self, request, uidb64, token): + serializer = PasswordResetConfirmSerializer(data=request.data, context={'request': request, 'uidb64': uidb64, 'token': token}) + + if serializer.is_valid(raise_exception=True): + return Response("password reset successfully", status=status.HTTP_200_OK) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file From 51e898b5bb7439806d8b7dc04a42dd9001905949 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Thu, 7 Nov 2024 01:30:45 +0330 Subject: [PATCH 03/60] feat: swagger added to project --- .gitignore | 5 +- FD/__pycache__/__init__.cpython-312.pyc | Bin 150 -> 160 bytes FD/__pycache__/settings.cpython-312.pyc | Bin 3555 -> 3565 bytes FD/__pycache__/urls.cpython-312.pyc | Bin 791 -> 801 bytes FD/__pycache__/wsgi.cpython-312.pyc | Bin 395 -> 405 bytes FD/settings.py | 1 + FD/urls.py | 2 + .../__pycache__/__init__.cpython-312.pyc | Bin 162 -> 172 bytes .../__pycache__/admin.cpython-312.pyc | Bin 363 -> 373 bytes .../__pycache__/apps.cpython-312.pyc | Bin 484 -> 494 bytes .../__pycache__/managers.cpython-312.pyc | Bin 2344 -> 2354 bytes .../__pycache__/models.cpython-312.pyc | Bin 2509 -> 2519 bytes .../__pycache__/serializers.cpython-312.pyc | Bin 7947 -> 7957 bytes .../__pycache__/urls.cpython-312.pyc | Bin 820 -> 830 bytes .../__pycache__/utils.cpython-312.pyc | Bin 1251 -> 1261 bytes .../__pycache__/views.cpython-312.pyc | Bin 4446 -> 4455 bytes .../__pycache__/0001_initial.cpython-312.pyc | Bin 2241 -> 2251 bytes .../0002_onetimepassword.cpython-312.pyc | Bin 1293 -> 1303 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 173 -> 183 bytes requirements.txt | 1 + schema.yml | 377 ++++++++++++++++++ 21 files changed, 385 insertions(+), 1 deletion(-) create mode 100644 schema.yml diff --git a/.gitignore b/.gitignore index 33ceb8f..56087e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,4 @@ -Makefile \ No newline at end of file +Makefile +venv/ +.env +**/__pycache__/ \ No newline at end of file diff --git a/FD/__pycache__/__init__.cpython-312.pyc b/FD/__pycache__/__init__.cpython-312.pyc index e15dc01b3322dabd92249582a6f49b714e526a35..5a8e30c0dd25176ea1985f76d90e1ec518d5fce2 100644 GIT binary patch delta 72 zcmbQnxPX!SG%qg~0}yy$)Sk#~Y-sOd6%(45S(aKylZVTv}Y5nV%Qql$e~InwK&$)erz7Z5U?& delta 62 zcmZ3$IE|6}G%qg~0}xF7r7@A)Sl!gwDkiizwWv5IJ2Nq_GR7sfIJ+djAjYjIHC5Lo QvpBi5xHvOEZ(_b708tzj3IG5A diff --git a/FD/__pycache__/settings.cpython-312.pyc b/FD/__pycache__/settings.cpython-312.pyc index 74370bcdebe7f6ad73007c059f68899be35a0249..e03a343c9e8d537f196bfa9d71aae160a26a6702 100644 GIT binary patch delta 74 zcmaDX{Z^XmG%qg~0}%XpxRGlHx1qg@RZM7JW?5=cab`(njA=}8ep*R+Vo_?0TTyDN cu1jWda%pjKW`16bQ(|&-I`<5rZK cs_T+joLpL5oSB~&ylZVTv}Y5nV%Qql$e~InwK&$)fNC6ZWvJj delta 62 zcmZ3(xQLPaG%qg~0}y=Ps5Oz>Sl!XtDkiizwWv5IJ2Nq_GR7sfIJ+djAjYjIHC5Lo QvpBi5xHvOEZ(_bJ0A5HHRR910 diff --git a/authentication/__pycache__/admin.cpython-312.pyc b/authentication/__pycache__/admin.cpython-312.pyc index bd708cd1c5ef6fb8f1eba3d1e8f274b024b6112f..202a16241b0e2a28ad8137043539825e25f098d9 100644 GIT binary patch delta 73 zcmaFO^p%PGG%qg~0}!xW(w@lu(9pxhDkd~9vn;i!IJ2ZO#xy24Kdq!Zu_!ghttd5B b*Cn$!xwN=AGe0lJDKR-aH7{kdFk=J&fD##8 delta 63 zcmey$^qPtLG%qg~0}y=Ps5O!Mp}L*3RZM7cYEf}ac4lH;WsFN|adt_5L5y2bYO1bF RW^r<9adBpT-eg(E2mrKt7GMAX diff --git a/authentication/__pycache__/apps.cpython-312.pyc b/authentication/__pycache__/apps.cpython-312.pyc index 68c31b18db4f53cff26186c62659531ee5eea3dd..da9e31e5f0f141d0ef0cf09f5388ddc744fda713 100644 GIT binary patch delta 74 zcmaFD{EnIXG%qg~0}!xW(%#4|&S>cFVigmbmsyrtRGe8-8Dkm~oS#-wo>-I`<5rZK cs_T+joLpL5oSB~&OV delta 64 zcmaFI{DhhNG%qg~0}y=PsI`$>oKfA@*(xTqIJKxaCOb1RuQJ9ZwK%&ZzaYk~C^c2r SC9^oWw757kKW}m{<7@z$Nfsjj diff --git a/authentication/__pycache__/managers.cpython-312.pyc b/authentication/__pycache__/managers.cpython-312.pyc index 804c46cea677eb37a61d66d2f41d6503e93874b1..1dad1645af709a8e9c84052c96c8c6d45d6e22df 100644 GIT binary patch delta 75 zcmZ1>v`L8jG%qg~0}!xW(%#5@jnUBC#VRH=FS9JQs5rBvGR8C}I6tkVJh3P>#;qtd dRo5l6IJvaAI5R&l#wjs5J2fw5GY69bI{=3S81w)D delta 65 zcmdlav_gpcG%qg~0}y=PsI`&%8l$?SvsFxJacWUoC8e%@vwCIxl?ygwEA diff --git a/authentication/__pycache__/models.cpython-312.pyc b/authentication/__pycache__/models.cpython-312.pyc index a381413a6f3bc4fdb89c9acf659b67a3ee1d5583..e9d13a8a7950e3e652ae5bc8649713dc9faac863 100644 GIT binary patch delta 75 zcmX>rd|jCPG%qg~0}!xW(%#5@k=fAG#VRH=FS9JQs5rBvGR8C}I6tkVJh3P>#;qtd dRo5l6IJvaAI5R&l#wjs5J2fw5Gc(I6RsfwP8Q=f_ delta 65 zcmcaEd{&tIG%qg~0}y=PsI`&%BD1=^vsFxJacWUoC8e%@wYmQ$<%&zctG diff --git a/authentication/__pycache__/serializers.cpython-312.pyc b/authentication/__pycache__/serializers.cpython-312.pyc index 0f54ed11cf8609484d59f51f5902b0266ca43a23..2ee05e341c4e5e77a5864f26b09c081044b8a910 100644 GIT binary patch delta 189 zcmV;u07CzZK9xQW%MA?*00000meMO{^b@fT?E^7RL^@nmZfSO9a&u{Pd0aJIQ*UN; zcVTj6Tt;$bWi3Q$b7OULb7^mGTtZ=EYh`X^vmyi=0Rb43J_Vrx2(#b?-~s_4lfek` z0Ufin3E%+%IkS}uM*{&pv)v9)0Rc0!Ef8`60a&x35kUk2T(ja8W&r_Kvq=}H0Reck r@ffZG0eq978}|WqvzQza0ul)U6qq~L8#d7!HuL}q0TlEYle->JurEIl delta 179 zcmV;k08IatK8rpM%MA?*00000x5g@GN))jU?E@!7Lpoelb7gXKTx)4zZh2fpWpitE zZ*W{ja%E*LL}_zlb#rrRZ*H?J1RDVX8IL8e0V9*k2=W0Sv%U%70RcR- zp9@C=0YJ0o4o?9AHnTSnasdHbv#Jq61OZ>O>=kAK0a>$C7pDOMd$ao(t^xsnld2o{ h0eG{b91#K%3;`6FJJ%aF(Hl1O01N>X^d6JN9#GNEJ9Gd5 diff --git a/authentication/__pycache__/urls.cpython-312.pyc b/authentication/__pycache__/urls.cpython-312.pyc index 7303e12a012c9b128953a4c5f004fad387c8a5ec..b4608d83ddc6c69e268c6fe93dfd045e8a22ef74 100644 GIT binary patch delta 74 zcmdnOwvUbbG%qg~0}#x+q`i?ln90!H#VRH=FS9JQs5rBvGR8C}I6tkVJh3P>#;qtd cRo5l6IJvaAI5R&l#wjs5J2fw5ax2q%0CyW1?EnA( delta 64 zcmdnTwuO!RG%qg~0}y=PsI`$hm`UB%*(xTqIJKxaCOb1RuQJ9ZwK%&ZzaYk~C^c2r SC9^oWw757kKW}nB(|Q1p+!j{= diff --git a/authentication/__pycache__/utils.cpython-312.pyc b/authentication/__pycache__/utils.cpython-312.pyc index e29c661f3516acb2c2b77f1d2efd61b0614ebaff..ea820a22c958305f5d87c629e9c928b60f32ab10 100644 GIT binary patch delta 75 zcmaFN`IeLWG%qg~0}!xW(%#4&!))l`VigmbmsyrtRGe8-8Dkm~oS#-wo>-I`<5rZK ds_T+joLpL5oSB~&W< gUTY>s$H|HOYZ&b(^9ej-Oql#tz=~03vZ0_Z0Mke*bN~PV delta 119 zcmaE^bWe%zG%qg~0}yOKrj=gEw~_A(v$~zLRZM7cYEf}ac4lH;WsFN|adt_5L5y2b zYO1bFW^r<9adBpT-ezHz?W~N>lTULlV074=!==Q;=sUTeXFj9%W_@03CdTl|+5Br5 XLnliJJY!6p{9nL|$%ApSm7p#FWA7&A diff --git a/authentication/migrations/__pycache__/0001_initial.cpython-312.pyc b/authentication/migrations/__pycache__/0001_initial.cpython-312.pyc index ca2b355f9bb275a34bcc3048df0e9137aa36b821..d23c06c525fe52a764fcc804a995e6834aa481b9 100644 GIT binary patch delta 75 zcmX>ocv_JAG%qg~0}!xW(%#5z$z~YkVigmbmsyrtRGe8-8Dkm~oS#-wo>-I`<5rZK ds_T+joLpL5oSB~&^I988ZL? delta 65 zcmX>tcuTNo1nu;&&x diff --git a/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc b/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc index bb0da61083e8cdb006eedbd62d5c127ff5b7c2e2..4a6f85385c74f077a41b3800ea5de19b8b91abb0 100644 GIT binary patch delta 75 zcmeC>n$E?2nwOW00SH(wX>a5XV>V23v5E=J%PdPRD$Xpaj4_P~&QB{TPb^A}aVttq d)pf}%PA)Aj&dkq?aY{_iPR&c%+`-(+2moGb87BY$ delta 65 zcmbQv)yu_wnwOW00SG>C)Y`}$#;hLUY!wq)oLW>IlbxBER~h4yTAW>yUl8L~l$xsR Tl3AQwT3no&pSO7ub1NeNnr0R& diff --git a/authentication/migrations/__pycache__/__init__.cpython-312.pyc b/authentication/migrations/__pycache__/__init__.cpython-312.pyc index c02780048f46c90592bac3a493f7ce22a97fa1ff..fd8264971f0065dcb7a456b5f51c28e206fe3479 100644 GIT binary patch delta 72 zcmZ3>xSf&vG%qg~0}!xW(w@j|Y#8QZ6%(45S(aKylZVTv}Y5nV%Qql$e~InwK&$)dK(`&lr6G delta 62 zcmdnaxR#OoG%qg~0}y=Ps5Oz>Sl!3jDkiizwWv5IJ2Nq_GR7sfIJ+djAjYjIHC5Lo QvpBi5xHvOEZ(_a&0A*hlfdBvi diff --git a/requirements.txt b/requirements.txt index 5ccb610..6f22560 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,6 +3,7 @@ attrs==24.2.0 Django==5.1.2 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 +drf-spectacular==0.27.2 inflection==0.5.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 diff --git a/schema.yml b/schema.yml new file mode 100644 index 0000000..29e21f1 --- /dev/null +++ b/schema.yml @@ -0,0 +1,377 @@ +openapi: 3.0.3 +info: + title: FD API + version: 0.0.0 +paths: + /api/schema/: + get: + operationId: api_schema_retrieve + description: |- + OpenApi3 schema for this API. Format can be selected via content negotiation. + + - YAML: application/vnd.oai.openapi + - JSON: application/vnd.oai.openapi+json + parameters: + - in: query + name: format + schema: + type: string + enum: + - json + - yaml + - in: query + name: lang + schema: + type: string + enum: + - af + - ar + - ar-dz + - ast + - az + - be + - bg + - bn + - br + - bs + - ca + - ckb + - cs + - cy + - da + - de + - dsb + - el + - en + - en-au + - en-gb + - eo + - es + - es-ar + - es-co + - es-mx + - es-ni + - es-ve + - et + - eu + - fa + - fi + - fr + - fy + - ga + - gd + - gl + - he + - hi + - hr + - hsb + - hu + - hy + - ia + - id + - ig + - io + - is + - it + - ja + - ka + - kab + - kk + - km + - kn + - ko + - ky + - lb + - lt + - lv + - mk + - ml + - mn + - mr + - ms + - my + - nb + - ne + - nl + - nn + - os + - pa + - pl + - pt + - pt-br + - ro + - ru + - sk + - sl + - sq + - sr + - sr-latn + - sv + - sw + - ta + - te + - tg + - th + - tk + - tr + - tt + - udm + - ug + - uk + - ur + - uz + - vi + - zh-hans + - zh-hant + tags: + - api + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/vnd.oai.openapi: + schema: + type: object + additionalProperties: {} + application/yaml: + schema: + type: object + additionalProperties: {} + application/vnd.oai.openapi+json: + schema: + type: object + additionalProperties: {} + application/json: + schema: + type: object + additionalProperties: {} + description: '' + /authentication/login/: + post: + operationId: authentication_login_create + tags: + - authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserLogin' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserLogin' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserLogin' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserLogin' + description: '' + /authentication/password-reset/: + post: + operationId: authentication_password_reset_create + tags: + - authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetRequest' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PasswordResetRequest' + multipart/form-data: + schema: + $ref: '#/components/schemas/PasswordResetRequest' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetRequest' + description: '' + /authentication/password-reset-confirm/{uidb64}/{token}/: + post: + operationId: authentication_password_reset_confirm_create + parameters: + - in: path + name: token + schema: + type: string + required: true + - in: path + name: uidb64 + schema: + type: string + required: true + tags: + - authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + multipart/form-data: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/PasswordResetConfirm' + description: '' + /authentication/register/: + post: + operationId: authentication_register_create + tags: + - authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/UserCreate' + multipart/form-data: + schema: + $ref: '#/components/schemas/UserCreate' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/UserCreate' + description: '' + /authentication/verify-email/: + post: + operationId: authentication_verify_email_create + tags: + - authentication + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyEmail' + application/x-www-form-urlencoded: + schema: + $ref: '#/components/schemas/VerifyEmail' + multipart/form-data: + schema: + $ref: '#/components/schemas/VerifyEmail' + required: true + security: + - jwtAuth: [] + - {} + responses: + '200': + content: + application/json: + schema: + $ref: '#/components/schemas/VerifyEmail' + description: '' +components: + schemas: + PasswordResetConfirm: + type: object + properties: + password: + type: string + writeOnly: true + maxLength: 64 + minLength: 8 + uidb64: + type: string + readOnly: true + maxLength: 255 + required: + - password + - uidb64 + PasswordResetRequest: + type: object + properties: + email: + type: string + format: email + required: + - email + UserCreate: + type: object + properties: + username: + type: string + maxLength: 255 + email: + type: string + format: email + title: Email Address + maxLength: 255 + password: + type: string + writeOnly: true + maxLength: 64 + minLength: 8 + required: + - email + - password + - username + UserLogin: + type: object + properties: + username: + type: string + maxLength: 255 + password: + type: string + writeOnly: true + maxLength: 255 + access_token: + type: string + readOnly: true + maxLength: 255 + refresh_token: + type: string + readOnly: true + maxLength: 255 + required: + - access_token + - password + - refresh_token + - username + VerifyEmail: + type: object + properties: + otp: + type: string + maxLength: 6 + required: + - otp + securitySchemes: + jwtAuth: + type: http + scheme: bearer + bearerFormat: JWT From 7b28b0ff00b7ebb1f5efe946e7226464b1e3357f Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 01:50:12 +0330 Subject: [PATCH 04/60] feat: CICD added to project --- workflows/liara.yaml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) create mode 100644 workflows/liara.yaml diff --git a/workflows/liara.yaml b/workflows/liara.yaml new file mode 100644 index 0000000..1fe21bc --- /dev/null +++ b/workflows/liara.yaml @@ -0,0 +1,20 @@ +name: CD-Liara +on: + push: + branches: + - Develop +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v3 + - uses: actions/setup-node@v3 + with: + node-version: "18" + - name: update-liara + env: + LIARA_TOKEN: ${{ secrets.LIARA_API_TOKEN }} + run: | + npm i -g @liara/cli@7 + liara deploy --app="free-discussion" --api-token="$LIARA_TOKEN" --no-app-logs --port=3000 \ No newline at end of file From a6611ff6076cd87e4cd7c90631d99fd588e01faa Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 01:55:11 +0330 Subject: [PATCH 05/60] Update liara.yaml --- workflows/liara.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/workflows/liara.yaml b/workflows/liara.yaml index 1fe21bc..5c6fdbd 100644 --- a/workflows/liara.yaml +++ b/workflows/liara.yaml @@ -1,6 +1,6 @@ name: CD-Liara on: - push: + merge: branches: - Develop jobs: @@ -17,4 +17,4 @@ jobs: LIARA_TOKEN: ${{ secrets.LIARA_API_TOKEN }} run: | npm i -g @liara/cli@7 - liara deploy --app="free-discussion" --api-token="$LIARA_TOKEN" --no-app-logs --port=3000 \ No newline at end of file + liara deploy --app="free-discussion" --api-token="$LIARA_TOKEN" --no-app-logs --port=3000 From 5b08a6ed23d352f781d9ef904ff19a66fd997fc9 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 7 Nov 2024 01:57:03 +0330 Subject: [PATCH 06/60] update liara.yaml --- workflows/liara.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/liara.yaml b/workflows/liara.yaml index 5c6fdbd..d393a61 100644 --- a/workflows/liara.yaml +++ b/workflows/liara.yaml @@ -1,6 +1,6 @@ name: CD-Liara on: - merge: + push: branches: - Develop jobs: From 5802c2d1a7696a0ac9054de054c238e8858cb1b0 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 02:29:48 +0330 Subject: [PATCH 07/60] Update liara.yaml --- workflows/liara.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/liara.yaml b/workflows/liara.yaml index d393a61..90ce000 100644 --- a/workflows/liara.yaml +++ b/workflows/liara.yaml @@ -1,6 +1,6 @@ name: CD-Liara on: - push: + merge_request: branches: - Develop jobs: From fa8d86b857b8c240099c26866d6d88a87a830a77 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 02:30:40 +0330 Subject: [PATCH 08/60] testing (#5) --- test | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 test diff --git a/test b/test new file mode 100644 index 0000000..e69de29 From 39e8d80c281008d8765f4563825d7ae08f4005d3 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 02:34:14 +0330 Subject: [PATCH 09/60] Update liara.yaml --- workflows/liara.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workflows/liara.yaml b/workflows/liara.yaml index 90ce000..d393a61 100644 --- a/workflows/liara.yaml +++ b/workflows/liara.yaml @@ -1,6 +1,6 @@ name: CD-Liara on: - merge_request: + push: branches: - Develop jobs: From 0dd137b792df619fed7dd7b331cb6c64b37241f2 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 7 Nov 2024 02:41:00 +0330 Subject: [PATCH 10/60] fix: hopefully last commit to fix CICD --- {workflows => .github/workflows}/liara.yaml | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename {workflows => .github/workflows}/liara.yaml (100%) diff --git a/workflows/liara.yaml b/.github/workflows/liara.yaml similarity index 100% rename from workflows/liara.yaml rename to .github/workflows/liara.yaml From 651bb154894619d05cb22c6c785aae5129b847a6 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 02:57:14 +0330 Subject: [PATCH 11/60] change CICD to trigger on merge instead of push --- .github/workflows/liara.yaml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/liara.yaml b/.github/workflows/liara.yaml index d393a61..1557bf0 100644 --- a/.github/workflows/liara.yaml +++ b/.github/workflows/liara.yaml @@ -1,8 +1,10 @@ name: CD-Liara on: - push: + pull_request: branches: - - Develop + - develop + types: + - closed jobs: deploy: runs-on: ubuntu-latest From 2ef87efa99a1c6d7349abaea7dac16d8cb3ef73b Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 7 Nov 2024 03:13:02 +0330 Subject: [PATCH 12/60] Delete test --- test | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 test diff --git a/test b/test deleted file mode 100644 index e69de29..0000000 From 2daa30c2fda5ddaff1928169dac243d3bfbf7354 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 7 Nov 2024 03:19:09 +0330 Subject: [PATCH 13/60] update .gitignore --- .gitignore | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 56087e2..1c5a0e4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ Makefile venv/ .env -**/__pycache__/ \ No newline at end of file +__pycache__/ \ No newline at end of file From 17a7acfc536353fed2a01b0c7e57b8712de38438 Mon Sep 17 00:00:00 2001 From: Kian Date: Thu, 7 Nov 2024 16:23:21 +0330 Subject: [PATCH 14/60] update .gitignore --- .gitignore | 2 +- FD/__pycache__/__init__.cpython-312.pyc | Bin 160 -> 0 bytes FD/__pycache__/settings.cpython-312.pyc | Bin 3565 -> 0 bytes FD/__pycache__/urls.cpython-312.pyc | Bin 801 -> 0 bytes FD/__pycache__/wsgi.cpython-312.pyc | Bin 405 -> 0 bytes FD/settings.py | 2 +- .../__pycache__/__init__.cpython-312.pyc | Bin 172 -> 0 bytes authentication/__pycache__/admin.cpython-312.pyc | Bin 373 -> 0 bytes authentication/__pycache__/apps.cpython-312.pyc | Bin 494 -> 0 bytes .../__pycache__/managers.cpython-312.pyc | Bin 2354 -> 0 bytes .../__pycache__/models.cpython-312.pyc | Bin 2519 -> 0 bytes .../__pycache__/serializers.cpython-312.pyc | Bin 7957 -> 0 bytes authentication/__pycache__/urls.cpython-312.pyc | Bin 830 -> 0 bytes authentication/__pycache__/utils.cpython-312.pyc | Bin 1261 -> 0 bytes authentication/__pycache__/views.cpython-312.pyc | Bin 4455 -> 0 bytes .../__pycache__/0001_initial.cpython-312.pyc | Bin 2251 -> 0 bytes .../0002_onetimepassword.cpython-312.pyc | Bin 1303 -> 0 bytes .../__pycache__/__init__.cpython-312.pyc | Bin 183 -> 0 bytes 18 files changed, 2 insertions(+), 2 deletions(-) delete mode 100644 FD/__pycache__/__init__.cpython-312.pyc delete mode 100644 FD/__pycache__/settings.cpython-312.pyc delete mode 100644 FD/__pycache__/urls.cpython-312.pyc delete mode 100644 FD/__pycache__/wsgi.cpython-312.pyc delete mode 100644 authentication/__pycache__/__init__.cpython-312.pyc delete mode 100644 authentication/__pycache__/admin.cpython-312.pyc delete mode 100644 authentication/__pycache__/apps.cpython-312.pyc delete mode 100644 authentication/__pycache__/managers.cpython-312.pyc delete mode 100644 authentication/__pycache__/models.cpython-312.pyc delete mode 100644 authentication/__pycache__/serializers.cpython-312.pyc delete mode 100644 authentication/__pycache__/urls.cpython-312.pyc delete mode 100644 authentication/__pycache__/utils.cpython-312.pyc delete mode 100644 authentication/__pycache__/views.cpython-312.pyc delete mode 100644 authentication/migrations/__pycache__/0001_initial.cpython-312.pyc delete mode 100644 authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc delete mode 100644 authentication/migrations/__pycache__/__init__.cpython-312.pyc diff --git a/.gitignore b/.gitignore index 1c5a0e4..56087e2 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,4 @@ Makefile venv/ .env -__pycache__/ \ No newline at end of file +**/__pycache__/ \ No newline at end of file diff --git a/FD/__pycache__/__init__.cpython-312.pyc b/FD/__pycache__/__init__.cpython-312.pyc deleted file mode 100644 index 5a8e30c0dd25176ea1985f76d90e1ec518d5fce2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 160 zcmX@j%ge<81l||5(?RrO5P=Rpvj9b=GgLBYGWxA#C}INgK7-W!vUjnH3C+taOD!tS zEUAn!jS0?AD=AMbN{w+VN=?;u$t+GTEiTT?&x>(NOwLZtONnuFiHVQT%*!l^kJl@x h{Ka9Do1apelWJGQ3N()qh>JmtkIamWj77{q764kpC$|6q diff --git a/FD/__pycache__/settings.cpython-312.pyc b/FD/__pycache__/settings.cpython-312.pyc deleted file mode 100644 index e03a343c9e8d537f196bfa9d71aae160a26a6702..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3565 zcma)8&2JmW6<>aTe~6-F%9dowR$^0*R#ls%A1Iuz$fd-1MKau_6)yxVR-6&NGQYgL zw5ml9EYL%W9x9)dgHJi7NdA*vWGH~uDFVpI$(Mj~E|EC$y-6qf!KZt7vX43Mk*gm^ z3?jD}s!(FMLLtvrHZcOd7y2mjiDSqQk{bn}4Sr=)Y$)`^S+R*R6c)!(1Vu0OKi2nu zAtLrx#Q0kg@wXx-`Uo`n#I4xGIJzWGpahzNy(G|OtG!~i(~7O)6erP)cnQsl31DRk z{+H2J@d}y~r!U;cd_RIXb0Oy105f|b=K270^+L?T05f+X=Dh)C{zASHXK{&u-9qhFzwCl2wweP{C5FU*um+K5kAY8c_FE`#7K z$ohTg1J#C%B-GGx{ZWqic{WvK1?diZPe}TVINFu#l?GjI)OA(are#!9>c;q}wx!=U zu8b&66I|Ez{U+9oOCysS)-r7IR>fF+2c(K0wjgPY zTRrR`p-nR1;MpYE_{|7-K;Rn;`g2Jq@vJhbJ8UjZoatUl6kS9B+&R zS9@ATq2*>1xV4g$co5!^TUFgi^unU;bLwjQ+QTaGq*tXJv%y+}63-S%05k=L=Qs1k zw546o^MW-fpKDOx(_m6`&V5pLMsE~23tx8K#%hEi8*tr~~x75)CX{U`5g z-@R~!-Wtyx{OMgC9Bj;nuI*sum#o%Ci7Usc_P!|ATN#OXt*h@0w+Q{QaH=4LEHx=3p z&zJAx8oc(#?_Yf~B~5|LiFfyOdgRnU-4wuZ`0=!pc$&>HB~Fm|)HByrgcD`X+%CyQ*R$nxu>`Y|)Sw9YbVwkQer3M`*|pLii5v03d@ z2ZSM>Ce*sBvaLEz$=xk>s<|R!Z)n6TNoHY@B;o;@8r4UbxSOz=x=y^S4A2g@m$)m~ zERm|)v~s1{)zTUXF&vlQWYgBk@+6$i@dBto0e7A!{*7!p&9R$IktM-mJ}*e6BA3eN zGQ=ma8wHLLfTrjszm_%eT#rEFOEUsv#^p)G+MJmt(ndbbawK6X`e`EFWwQ-rkO);?fU`=O%OSC$W6VpLEX$?UY1pGW zV>Yv9bJc9-%w`@M5{6?Cq)ZVWB6xQ0kT_ppQv#DJaZFL-SwYC=)?o8|7PiPrkWl}k zegZq{mrv4v*ecbPM_AS1RoMDyi*M}cW-Z>zs2DG$6|LN|z6-WiyCq{Qlit#>u0!3? z=;r<}-ao?aYE|)Vl>>if25CP++i}=zw(qIg@2Lc||D}A-sleYt&jXPI?>}xo3(P+a z%pV7?KMO2A4JFP;9Fy&Zm; z@P_See8VYyFy`D_-t4xc7=)1wEc)mbY`isL_2gE9Bcbd0})`t7iznK4&Y$o!y_s- z%FIdZ%E7h6;8m4Toc z#@4ahZ@AA~E|cv}lyU{X_#9T6fJMg7{M77?uOEIlv+^J4Kl^TW`DAM0%;OB(|LRvi Hg>(NO7sVsT diff --git a/FD/__pycache__/urls.cpython-312.pyc b/FD/__pycache__/urls.cpython-312.pyc deleted file mode 100644 index adfbabfde26852de3e5b3fd2291ff1fe79a70c7c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 801 zcmZuvO=}ZD7@pmo?B=6sOKW9O1A=;p(gdOfO{7H$6+wy+DiRjLbZ44u+>bD`+mPJ6 z^=MDQpCIuEcoDpN*^)qUK?-{E7U``gXYxT(@IAcyywCG~4*NZwP6BS*zaG@TN&x&8 z!ORj?l>mfmJw;6+7mrRn^g|ngnD(AcjP0uFjgxzs>&~G>vO&^V&pN^!;goatJZg6DzDQn_gCyVen*yrN@l85Qr)ZzD>K zT}sGi*G^ue`9-OEAcKmSfkxkAPt7aKECL$E1yrAsnwD6aQ*w(N$&Qr_pFwW;W$j`W z6PlM7=gH014w*eW@Kc%&A|DT!SDeCQ#)59*L4QbiwvR{#Pl}s UUSY6%$ftCfPo(NOwLZtONmJ=Ey+mDE6GewECC6} r$7kkcmc+;F6;%G>u*uC&Da}c>D`Ewj%?QNBAjU^#Mn=XWW*`dyrROZu diff --git a/authentication/__pycache__/admin.cpython-312.pyc b/authentication/__pycache__/admin.cpython-312.pyc deleted file mode 100644 index 202a16241b0e2a28ad8137043539825e25f098d9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 373 zcmYL^F-yZh6vy9d(i*DmQa?b!sfgWNL>#0Pq2MB2N)Tf1HC{|Ea(7WOI=Sl9&mevj zCj}7#ot(NAx^?nS4SmD=|Ni&x9{+o5wVGhc{n(E#jec8GmGcj)seu_VV3_ zK$1U{LcwwC$eo8+lXqnkrFD zEmd_bYj=|PlKL^Tam)h0m~y{GuCVV02Dvas0ihl@y37@wL7!2NWG%cP8tRp-ija>- z6rrq!kkBNa#0`XQ66}?gRYDz{crg<406F<6P1c^lOs+Z~^p31^pWV`kvpBW(t+T+1 z?{Gw|enjbBk8wNUT&!tzv3*T_Vl6IW;V|Szm`2jgJZJ*&rU+v&T|ao!)~5B1hh8Lq z^7>F$vwC@~6i3qELf()lZ)^!PuO1gyokS~wN^7~a^;?Ayet_+tB?WFi9{vK6a@8M~ C^n<1V diff --git a/authentication/__pycache__/managers.cpython-312.pyc b/authentication/__pycache__/managers.cpython-312.pyc deleted file mode 100644 index 1dad1645af709a8e9c84052c96c8c6d45d6e22df..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2354 zcma)8&2JM&6rb4-XB|7)G?d%>A)844+wI5sNycO!Jf?FF8_K&dWY8J4#v2t3Izd zv2sUVqeLR-iKR{xE5_(OIZ&04PjzIqzj2bKo_@v@?2=&oOS58*GLB2%nvQLmu3fF1 zkCH zwMDTe1l)Gvj9L7iRjh(pbFZ?BYZv_>6v{zpL~eY_KfAtn)0O46hDL)k#c?1v$TLZf z57Q^fy>o+qCf|DbL_hGHP+aNVfA{O9^!e7nXRY3$rR2~v8JE`u%HB+LliLU0lZ)tI zG%ykg_^o{dw?0c6RHQay;f8rdAUC6%jlMT&LwXHXiZIa^u<~oLa)gP#fF)z?7)NFj z(r9bFE&-w)`<)P29e_cnw+$=S0J|;auGCO|rMKyIwV{ro|Jz2RZ5Jhg=-RdoQ_$c2 z>gs(p@&n~AkZi|Dz3<{xmcg)A%rXno@CwTa=GJ%P&N(c*Ez7>ZSEdk?Rc>wGfh^Q& zp(2hpEmBXd3A-}7NU|EoUc=EDPp?#Y8S1(oR<)m7o?5P2%<+t&!=IckTfm5yC(@V+IkM&fzcA_=#VXOD#Qu5?ZxTig-m7YV*obL4k`q2Ez2ZK$0=;0SYW)vUS-_-i&-d)xX!t74` zR(vk?OW#j@GjWi7r0<$ZtbuuCWJ{S~Li%O$5*R2GWvtz>5F8APTGG9+T|xMF#z3F^ zPmJ{t8{st(`DqbffoyAIK&2jkvEb%=^`jGSkc_o>8!klRz zP~X2pMzwbjpbi(v4f04!t!U|{mY(liIJ>MJ+n9GhP?oj9jd^0>;<9#pW8SskE^8k= z)zdTXik@!j>3MD8@PodlKK!r%#Lu3c@`dB+SvmqQo@N+W<%Z#P8U~bG%|SkC7(diZ zC)f$z82=6qgB;(51U2|o#Ib4l1UsDQ1u{*ZONu&7pC`N3)QgO!rUE!3N3xyXUTe&( zj8=2SYQ^RDl^i}XIlt5e#|G}b*#>ZyW7mpo9RC1_;GNsD89q2wE`pBH7fRM#+YveF z02Sf*4;ROD@q0~R@Kz_*wT}beKjB9KhovIWVR(%wT_q=0$?;Wk444kbp@@NE%ZM#fX-KwnCLc>_C77$VXMVDXWcl?5tVuhS@bC zQIK2-RjGPtsvg3j2aZOG)SRkT{Rco?;$q3xl&G!ROK!BG7cT9aUE2+a7Dn>h_ul;8 z%)EIYv%4rf-^r(#nhh}wyh$-WYsN-oJ6B@jXNlw;vSyS~f`KGJ_-k-sS zfo~duA}%d5c><>i*5{_QYgpaktVMmyd`wiu3??aSSBtu9!tS?#TtgToISD0gHiYe2 z&t9O#OPs{!oRWQ=tqfzs(Qw0MS~Xkxy|iKFe2JCkzpc!jTn!udy3JZO={0Tk?cKxT zCC`Spj!*ht3k6{UUthIW(`%ucP1NC+oXfVsH_jFOAC#|wYLWcOkklkKXKcwo_qx1oh0F@XQFoxIZrIsk~VuGiSsU6oE7YC!E8oA*VzG zN@vEZu-N<}bqfNNm>?|q*%icUQafy;`+*ctHPHIStOkgT!H)wO$d*?NPj!{A%TN!-^qxEtFzGzd4bN}amIREh=L zL0COgPg)}aiv_iVcMvdhRxnscfWRIE3}H#CvvYQL6BY;RGN<(=vsJ__flcTqfE3W< z(BVqxXeo5G=y(+BUvdB59=>zxtL4XS2k#uMgkz;}>`~kCKb-a;{{-2+Jm~XtZ%1UTNR$&u8`F7*=R{(v%se10Fw}oW z(-bVK7Sto_5JEAXrhdld=P@xo_kjc%BBy06Q7;J4M%XYqMFLGIVkXB+?7y z9x`1w@09qMmJt>f{U;!|QPtnMGE(t(mi(Q?*3GF0-2LN^&LzwKj|z!upljtuCD2t0 zbQRy;G#(tefBs>AIq+fO+;cPEU-@q7hmXsF3ryd`EP6_Tp5j0`Z~`=~k=4OUYoyc~ zDdx+q1BHv#P~U2#66!03`ii(5I$5|-4fd|OD#6}Tu(y~f2j41;RzneniIhT-&3HNV zE?`>vR@*8qeWjMZqET)+Svdd8{;tA!wf*p2eyi1_%eZH(G;h<4{e_39S}JE0r1+IwMaaE38}KE4j6@i*(sM*3Sk9;y@S^D>bq^0s4iCi> z)WPzNL;*?dnlVoXm<>yRD+f+7j*%Yt=`kRX1HO)x!HTb=4l{Unelc8=q=QOD0{bQ=wBqj$E^k*AIW f9KYo|wAI=F6ao8m(8dj}rq`4&mA??M<~{raQgTtX diff --git a/authentication/__pycache__/serializers.cpython-312.pyc b/authentication/__pycache__/serializers.cpython-312.pyc deleted file mode 100644 index 2ee05e341c4e5e77a5864f26b09c081044b8a910..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7957 zcmcgxU2GfIm7XCv{Fg}a*Ro`bmi!~evJyGAW5-^{u_RlTEH!J%Zps=UD9%WtJS3ev zqe|3LfDq(?E{euNib_SZsIcgR0=t0sY5mp|-G>1CLWTk;3}n>6dLR6fi4`qz<-(50GR!K&GdwFY zNjAc=ST=};q%mToWus_HauF_Rj+klPBwCWzh&5@8*l3*-?MX+(LCa>*nQV$Q(XvHs zPP!s4TDFSrWJ{!lmTjUZ>5X`4*)IB${)nHJ9bzCEj0B zBXU%diQ-U;lNni%pvON)L@72UL?>gCaOPB$7vgDNc%@lLVKYqRo)+Y2e2x&9JSrvV z-R-Z_xuD~k-57Z7x zAPJybgwddCg{{bm)U@;*_zZW=z7^Ll1)n+}egu_OMqnZ=&qNFcMljA=s|nRI&+-P| z_|T-^Cr8hogR?h3FsL*UGjI8fiC8k$uubvZg!S|j0giZFU)XaT`S~TtCcZK%_GBUz z73l_{99z)K^90T&nofzCnkAQ~VRrVx0F@;R#)xSe#OPnx2$wO0jW1gpP2D>x$g!|d zvChG|Qn93|Z4aqV!ROz##Y zbY}pvRYpb*plP<6N^3Kf3sn!EO)y#Z*X&Q(B}3LQ&V&tPyw3tLGDNXa40Ix%a8OtJOP#K;xAB7!5) zqayEM3AdvjA1;W1npU$GC`fp$aL%s7xqm9T4i{X9AF+=oznR!{-B=#_v&q1{`$q<{ z9jk%!sbk>lHN1+u#k6x4vI?*6EnW`|56agR;dmp6kvU@ubBg~KFvL`bYKC#0H=dhe z)UQ~AM^tn3c$YZ!&M*wN3U@fG-#xUJ6VGZ>Eg5W#tnibenc#r3_EFaaGJ>X73wBzYS$#gUMr+DITwF(^|uc9j7D zG{hhwX`(m>(t7KLAK0nHG4l#+)J!+#pZ)K;|o)hZG_zp94Q4(6aptofxbeZ?@>G-=qm=^ z&t2W}?q9vIav?YH)Eg{$yT0{yZFL>HZ`yQ+KzFU$R%}0WYz5oyJ9f=Xuv2|GfAV@U zFq(Id{^gnLV99m7;5z<442zR^4wO763Z4`B-fKnAaNarm!ieoJB@Zm@ix1r&o@0J@ z*pKgjGcziEBh&k8*txzeK6&nh~{~x^fHJDMwZWC$8iBXS( z2^WupofMVPSW}vaFhvAuMy-yK4y3EQU<65W1+sJSuLaWKJ%Q8$v{aq`_jgpMdnjC8 zw}ZN58iH^mHEq-s(UJ+&w8macBXzrnn$}h05|`y>(R!#$l0(+jFBo#=ReZ_{!`r?DzyKJi zoK^#47GS^$Fi_cf)edx(Zy&4TH?Yz_nG-jai*hEm-sIy$f(hy`}Cqa`zvBc9USy+&%!KFgPKOhZa zA$cDun&UG<{7zJZ7sX74L8=U@7{O-&ZHVANE@Gw&5E;fQVh%z3UqVKw08xersv{TM z8_A-E4Y(TV8DxMB-hHd*R?e+Oir(&=wXWs~toE$*lsugUPv`o%qUS`;^1@`aU1PVs zK?DiFhl00jt{Dz zJ+$X)(KD2H4n1x0uePnUeQ_XXeqm+;`&T0?k*$60ziRqL({_8ux_Nz}5PGNB-nV@q zv>w>76%L&&9(ebUHqLE-!EiSFUv`6Z!cf5#Ld1cE;k4z!5o@ji)*!F^Hq`HDe(&$- zKLsG;*c)Ujr>x(qYIt6PLF)C=Wo*L?@kEM2R%>_~H(jTehpJ6cyE> zFJ!|v}?ud z((39{Q;phl$yBkLwVQgqN(YEwW*gYdji80sfndojd$pBhbpUCk&X+6|watTfkmY#p zOT$Awdaz{WEm_MinXL7f%s(5KzzSKi^R}!FYKA3;j9Q+itUc?H(LkeV7KN=YWewBp zL;Hg=1fT~yixxp$(oO~9UM!qxsID8T36??$+_su#JGD~fS%#7$S_|*cZs1c^hZ5%) z-T9P%SSqhTX`iikVMCbR|WQ zRfz*Lv?mnT;OK|fu1ANyH$D-)IX-v;BL*p6Jr*6_00JwJMNfnd4rVystpq5ejkd*`mZKMWe=^YHTEpA81C4Z*E##~R?Sm7OzusRme(XEE*MAbT^o@hmt{TqRI44dKs+Mm_ks zw;N{SIiNJ_l;DU#C>_G0yj2HBD{lh_%a*Z+9ZD@Y_Nz*u0*<5~mOBAqH;nWs_A=Y0 z!OI_j27*>I@neQ~S+ikHYc&X#Vk~#s0KrmY*z~d%OJ7-lilsDmhVu6sKutmS0{kU( z0CYfYUz^Fd9NQC9F)%?f^~WGX-vv|u3is)6cDiIOM$S}lIIFuGtVN& z)Fo|Z?1y--WlW8iQgw?L57-rj$71UQ;EReV6lD@ps2uQ0tiXoAEItclDiGlD5wu{c z0E9w@L-kxJzEOupaUuk2h)B-EJ2id_W8X}WEqH2t4KeF3_MqseZurSVJ`oHIr|4@_z83V3Dfa} z2|r;@|H;g-P&@ixOmLg=@0x6^Z|!`k?M$KV%q|0!-F;qmgk2jcwVy1spWJ1zw%c=v wwXYp7?LS@Ee|ndJ%I+1moxR4c|FCrAQsKy@T?T8r7yYbHYx)xd73y#PAJgTfi2wiq diff --git a/authentication/__pycache__/urls.cpython-312.pyc b/authentication/__pycache__/urls.cpython-312.pyc deleted file mode 100644 index b4608d83ddc6c69e268c6fe93dfd045e8a22ef74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 830 zcma)&zmF0@6vtT1ogUw~Ct{mjbQ z3a+-6PJv@-}pnF{MqdIGenV;GVK<^69 zV3K?wQ2!Y>A1{d;0=NM$U0_aNJU_X!01qY!4#1<~L0Ie~Iu&W1-QT^TH7&wpVZ<3} zRqcu0afUht?H&av?h@8^86&o(-PYUu#fGN4&H-2sY3q)V#iL67(OIn4vkljTI^E#k z72&g2d^-tYjDMos547<+hQi9xXJX4xwN?zugCzrp=v7;;R)Oof%%a*r5=QebUbRG4H+kX1=$7 z7K;`T0-t}<+SUR1TRKXFpUKfxp`HLA_*w`et)^+x) z*Yd*7{QzncUERqa$~KP1Pc24n5H2A~-4?oisL80#OLt5x?LXI%1Xelp}O}Bc1FejxBf%5JJyMj=(}*|_^#rW%^vh~iT(@t zRqvVXBbAxJ#7wjVw6xv)4je^$xg^(~Q5j>sb8Kp$3!I7D!I#%c;84DnAi+eEGeHyV z2v)d(*~V5`FlI~2Y{l8uH!JII{E*sBOl*Q$0cD5?>TDd_O@fdgP_IL2fa7XFs@6T+ zA@+Zo*a5X2$n&(b)2wyvYs92sgKyK^PKwAD+iqYs!rlVnmJx&xtBmpp`SlAw{w8L)xQ>x^XbCL>(sBg-a`SQ?snKZido=gE$bZ?gtWTR)1Ly#~!g`5|s$eYoh z#nK1+<(XmG8I+y=w+B{vXjKMQ<>}&{HQT?wZxw&^ewuq+eN=r~-m@zG>j#C>HP<3 z*%gP(T;7iU_&B}ir9qso;}V)VEa!I)IFQ(IC`Nz;S?KN=z S0#k2*^BUMku&ABS>fQqh*)iV$ diff --git a/authentication/__pycache__/views.cpython-312.pyc b/authentication/__pycache__/views.cpython-312.pyc deleted file mode 100644 index 58d3087380529b751d1c4fbe514b79312fae8a8b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4455 zcmcJSO>7&-6@X`V`6rT+7>lG7>!+LFnzAi9j-0lxQ`feB+@zLNSyE$G#dggdN-Hl( zWoBtb0#o$hgL`UwvI7K2juxyUEfApXsX>riFH}KP!a@cLG(809L4nvyPJM646;}!s z!#~}_n>TOX{Ors(Z)X11+8QSCefsy~^DjmS`4?u~&tF$|KLzCuF^C~qBqyaM33b_$ zb4pqfx?=fq{aMcR-l16j7X?-EpT9EoPwE@>8TJhUWg1AE{NlOMv z%T!7G4Cxk0`&Z4!tyvc3KW;kWoYep8eY652)XJPjQw04KsZkwbeEqeXxRo12Z zb&q%DlDz1yRlO7wLta;0A9fjhf}X;S`ZkrOZftcC{ zWqu>@zHCQb%VL|WHPZFq{dyl)y>;cqSi{HFJS`n@ZH?61QYthKOXf3lTb~A{o#`g0 z8hs~2mu$0;cl=`IRxjhMs~JXhn;I%FW-^rXY|*k-!s?_&bxzgH0?ph|R|-X@iaKhx zz|@I6Rj17yRVQ`MmkZ2D$*dhl;skThfId%I0CK6Y6UPoNiT1BjX6lx?N?FY{WOD5$ z7!1e9^_!H1pa#PwW4I~gr&B&?hFV!C$mrWe%55hMJ=Z&Qd_~A`AGE8&_toX$vvV_f6FTLly)t)Z?rI@x zFYAoXjWSA44x2nvYM-aqx9wjhWLj7OXJ)7MRxvQz2n}C=h8OkamuLdW&pz9xwaiyS|8jLaMo<*JH$l z_E}iD?8*oRm#>8cTtpEP{sGEU4+*{YUO+H>hToKI#8P~V;!tpNwRAn;*}Qu-A#Chz z`G&uzrDkEk(^4up&d_C=VGLj$AXa*C))NBO&>pPl@EEYR{>>_pO8K$iJ-1COL)Bv{mY30GL8NhzGH#mKs3}8=7sld1(Xw5wpGKIWN-?8gJ zi?bo*bl{|k+52WxSwF7d9-{0xWSd}lWZyyvC{Lp2H4q5pv3v6$S!>oO*UFPWEf2p{ zNnC3V=iN6xn0RmE?^(%|YaDG(F%->KKbgo{fG_S(V)>iF;=f2~H*1l%b`c%N}ZPxN$VEqmZoxcSF z8GiN8oqz55OHX<7TKQ+!%M+PO!e|b0x#vn{c5ZWaVPkfo(qWdP<|89~2Kqc4;a9N~ z1-k9JG4%{0EH@BFhbKn(QX!u;SxzAAg9hCX!<9gI86j@;XC4{!bNB~`#_I24DT?!`yoN$Wfu`})GfvD{)bsO&0lrXRcBW`^ zhD{Vr)#oFao39c~t35548>7fK6y85Ow&Nu0kYI3Pds7lB6D5ipw+BLj`~xbel#o`IrR~Nub~dbco!xbU z-3ygC^vJC_a&41Ck3Qyz)XTcEWNS`HJ#n)HRV6O%n_XKm6-De}=DnHkd-MMM{QJ;Q zh=EV|W40I^V3MwIhNs=O$KpK8N_3D&)*}R?Z><) zXZfm-^1Y%;iX)+tS~L|~HFS&kO9sN4l|SP!Oth=KzQfT+F_UExljRVbn0d^lo5Je9s+=SmZfJN7@=p?9#u zAHa^Bh{q5Y{lIF(MV|Nc{uTL7MvqzV1aRj*5;yjdxbcs~op*5;`aSbx#GXX^{JZc0 z+{Hdz%*DmtXAe!FNfbv36gx>Ccbf?!mp;Ilav7hXOZ|*u7)>Le%AU;hss}wYT)mw7 zlmzegI&Ta5P*5uNE}2tfeUIdu%5bg-4q7A~` zUP}oi3~WQrn^>_iB7T*Ig;anY5F;9MzI7 zTPYM$EEzz!pj0%Qgy~_Wj7@5g41xyUsRb4M4}zlPZS???K?D)WdxolG1Z*!(-8hsL zgh)tJAUT>*RCTxuxH*73;xC#;rEJxdtz9g6vSjb7mPDVDcCcpXMN6`cd1=`@I#e}H zDq>qwU5A?U3yT)c}P+I9UlvZc^2J# zZN&B9j8=2)^fMq+JC#GD(n(7OSKW5JOJ}M{#~cZ-sKsSy)D8L=3AsJz z$-&iK#q`ARieYG2(LHTw4YIYRmawOe+*S1ImO)qVAY?$K-uV87c@2*+Z?kJxa}RZB z9@A3oYVPyg1EXLcDkjctm>AEksaC$?mN&PeI z;&YAoT>aeXmh;_CGoE)u^xvx&SDW!|NBq`3U!atQMtq?@-i+UH#GCC%(wWLUKi`_l zG^R53hs~)~M_g-15{|U^+~1NG8`5HZy(z6Y;%YlG=_Jxeg;pZnNTlmC&BRSdTz0V; zip?~n%;|Jf+H}NQ?MU27E*>4Ul8cSxVtuQb^ekL-CelY=v?kJxiF94)FwAXM<6!%N zTjycR^54@9^ZRtWDclm?FwEp_3XI$l+L24n%+mAi*3434X6ba=`Q{KVHo5!MLVx@?ZvX%Q diff --git a/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc b/authentication/migrations/__pycache__/0002_onetimepassword.cpython-312.pyc deleted file mode 100644 index 4a6f85385c74f077a41b3800ea5de19b8b91abb0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1303 zcmZux&ube;6rTN|)$UrBrI1=u4sL6FkQ5@EfG#Bj6iIH=#!iWUKxDzP){NJqtag=| zRZ{iTLJmFV*3kc;*oWkl{0W6#c8g)nKxv`9o z`FS1iCI4~N`d0+tpU9c5)YLe=&yAk}1W>R+N8nG<76(#C5~7%7%Y#%WHBdT=07UR5 zfbvrSQz+HX&VbIIhf-p9(qo~byosERiojHgJP$h-t%yt?V2hX@b{)!;feVpMduIRu z#C&<+bXka4p_oxegn}h>BoQFFKg&2*1w|O0DHm~qGXJHZa+E%llahTAs&RJi9K_Uw z8kCRpL*A=s@yxk6^HSoU-;v&5JntuVKXXB>ctPyS$1#-cFMS+6%AB#Wq@($n$NP~R zXDbU#Tc355w>N8!yaVKTxEJ@BWmZ>LuN&CG9yV>ZaL++oc!0h&DShG+$V3dc)|hyw zRgqbSm%2ueAk#yT%?$}2m}G1`L}R8=M0h>^9Y496TGCb{15jlxOC%9`I9 zHgYWQfGHyfKN=w>k9Z|N(_F{kQX>yB(Q|n~j?0yOjBH3ZDtV?FF`{8G-7xrEjBLd1 zjA1+)nRb*Mc@Pbe1Ci6ih>}Q6HrI^#u1AAJqKRgi+t_OGo2*69OluyPBr)|hmmq98 z-|!15ux~n+%S5=x)R-6_NS1CkHk*xBn?z&G78=`IUm4q*?fb_1y;l1!)7>GOj_$AE zl&66Ad#x|J+YaVEq1YRD>)lOv-+N*b)LkP8U29?58&S%!ySL5WL(aYXp+4QSx1r0G zx?#8u^0+|5sX(>in56k1#hc0v`U~Hlf{6mOs}o7Qto(3yq66*IpY%{&3e=^O{PN%V zPy9ml+2gQK4GPuQ@^34C$M79;T%dl=JIOEl#qx6{ES7^}`9(NOwLZtONmJ=Ey+mDE6GewECC7U zW~LW`S;aB&@tJv Date: Fri, 8 Nov 2024 18:51:18 +0330 Subject: [PATCH 15/60] fix: corsheaders add --- FD/settings.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/FD/settings.py b/FD/settings.py index f2a29c7..76dedc0 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -23,9 +23,11 @@ 'rest_framework', 'drf_spectacular', 'authentication', + 'corsheaders' ] MIDDLEWARE = [ + 'corsheaders.middleware.CorsMiddleware', 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -35,6 +37,9 @@ 'django.middleware.clickjacking.XFrameOptionsMiddleware', ] +CORS_ALLOW_ALL_ORIGINS = True + + ROOT_URLCONF = 'FD.urls' TEMPLATES = [ From 2204403206ed4d1cd2602cfc89c53fcec3f27a85 Mon Sep 17 00:00:00 2001 From: Kian Date: Sat, 9 Nov 2024 18:03:10 +0330 Subject: [PATCH 16/60] test --- authentication/managers.py | 6 ---- authentication/migrations/0001_initial.py | 35 ------------------- .../migrations/0002_onetimepassword.py | 23 ------------ authentication/migrations/__init__.py | 0 authentication/models.py | 8 ++++- authentication/serializers.py | 16 +++------ authentication/utils.py | 9 ++--- authentication/views.py | 12 +++---- 8 files changed, 23 insertions(+), 86 deletions(-) delete mode 100644 authentication/migrations/0001_initial.py delete mode 100644 authentication/migrations/0002_onetimepassword.py delete mode 100644 authentication/migrations/__init__.py diff --git a/authentication/managers.py b/authentication/managers.py index 835db80..41504f1 100644 --- a/authentication/managers.py +++ b/authentication/managers.py @@ -12,12 +12,6 @@ def validate_email(self, email): raise ValueError(_("You must provide a valid email address")) def create_user(self, username, email, password, **extra_fields): - if not username: - raise ValueError(_("The Username must be set")) - if not email: - raise ValueError(_("The Email must be set")) - if not password: - raise ValueError(_("The Password must be set")) email = self.normalize_email(email) self.validate_email(email) diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py deleted file mode 100644 index b74941b..0000000 --- a/authentication/migrations/0001_initial.py +++ /dev/null @@ -1,35 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-04 23:06 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - initial = True - - dependencies = [ - ('auth', '0012_alter_user_first_name_max_length'), - ] - - operations = [ - migrations.CreateModel( - name='User', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('email', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')), - ('username', models.CharField(max_length=255, unique=True, verbose_name='Username')), - ('password', models.CharField(max_length=255, verbose_name='Password')), - ('is_staff', models.BooleanField(default=False)), - ('is_superuser', models.BooleanField(default=False)), - ('is_verified', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=False)), - ('date_joined', models.DateTimeField(auto_now_add=True)), - ('last_login', models.DateTimeField(auto_now=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), - ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), - ], - options={ - 'abstract': False, - }, - ), - ] diff --git a/authentication/migrations/0002_onetimepassword.py b/authentication/migrations/0002_onetimepassword.py deleted file mode 100644 index 787c13c..0000000 --- a/authentication/migrations/0002_onetimepassword.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-05 22:17 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='OneTimePassword', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('otp', models.CharField(max_length=6, unique=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/authentication/models.py b/authentication/models.py index 70cd427..7d24f9b 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -30,8 +30,14 @@ def tokens(self): 'access': str(refresh.access_token), } + +class TempUser(models.Model): + username = models.CharField(max_length=255, unique=True) + email = models.EmailField(max_length=255, unique=True) + password = models.CharField(max_length=255) + class OneTimePassword(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + temp_user = models.ForeignKey(TempUser, on_delete=models.CASCADE) otp = models.CharField(max_length=6, unique=True) diff --git a/authentication/serializers.py b/authentication/serializers.py index a6c4957..ee6f4d0 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -22,8 +22,8 @@ class Meta: fields = ['username', 'email', 'password'] def create(self, validated_data): - user = User.objects.create_user(**validated_data) - return user + temp_user = TempUser.objects.create(**validated_data) + return temp_user class VerifyEmailSerializer(serializers.Serializer): @@ -34,15 +34,9 @@ def validate(self, attrs): try: otp_obj = OneTimePassword.objects.get(otp=otp) - user = otp_obj.user - - if not user.is_verified: - user.is_verified = True - user.save() - return user - - else: - raise serializers.ValidationError('account already verified') + temp_user = otp_obj.temp_user + user = user.objects.create_user(username=temp_user.username, email=temp_user.email, password=temp_user.password) + return user except OneTimePassword.DoesNotExist: raise serializers.ValidationError('invalid one time password') diff --git a/authentication/utils.py b/authentication/utils.py index b38f2a7..d90187b 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -1,6 +1,7 @@ -from django.core.mail import EmailMessage +from email.message import EmailMessage +import smtplib import secrets -from .models import OneTimePassword, User +from .models import * from django.conf import settings def send_otp_email(email): @@ -9,8 +10,8 @@ def send_otp_email(email): otp = secrets.randbelow(10**6) otp = str(otp).zfill(6) - user = User.objects.get(email=email) - OneTimePassword.objects.create(user=user, otp=otp) + temp_user = TempUser.objects.get(email=email) + OneTimePassword.objects.create(temp_user=temp_user, otp=otp) email_subject = 'One Time Password' email_body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' diff --git a/authentication/views.py b/authentication/views.py index feead42..5d3ecae 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -12,24 +12,24 @@ class RegisterUserView(APIView): serializer_class = UserCreateSerializer def post(self, request): - serializer = UserCreateSerializer(data=request.data) + serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): serializer.save() - user = serializer.data - send_otp_email(user['email']) + temp_user = serializer.data + send_otp_email(temp_user['email']) return Response({ - 'data': user, + 'data': temp_user, 'message': 'User created successfully\n Please check your email for One Time Password', }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class VerifyEmailView(APIView): +class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer def post(self, request): - serializer = VerifyEmailSerializer(data=request.data) + serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): return Response({ From eb2eaebc3ea23dd59c1bf1ad863bee3587242abf Mon Sep 17 00:00:00 2001 From: Kian Date: Sun, 10 Nov 2024 11:58:33 +0330 Subject: [PATCH 17/60] feat: registration policy modified --- authentication/admin.py | 1 + authentication/managers.py | 1 - authentication/migrations/0001_initial.py | 52 +++++++++++++++++++ .../migrations/0002_tempuser_date_joined.py | 20 +++++++ authentication/migrations/__init__.py | 0 authentication/models.py | 2 +- authentication/serializers.py | 52 ++++++++++++++++--- authentication/utils.py | 16 ++++-- authentication/views.py | 4 +- 9 files changed, 133 insertions(+), 15 deletions(-) create mode 100644 authentication/migrations/0001_initial.py create mode 100644 authentication/migrations/0002_tempuser_date_joined.py create mode 100644 authentication/migrations/__init__.py diff --git a/authentication/admin.py b/authentication/admin.py index 188819b..13339c3 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -3,3 +3,4 @@ admin.site.register(models.User) +admin.site.register(models.TempUser) diff --git a/authentication/managers.py b/authentication/managers.py index 41504f1..3536bb3 100644 --- a/authentication/managers.py +++ b/authentication/managers.py @@ -25,7 +25,6 @@ def create_superuser(self, username, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) - extra_fields.setdefault("is_verified", True) user = self.create_user(username, email, password, **extra_fields) user.save(using= self._db) diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py new file mode 100644 index 0000000..00bfc86 --- /dev/null +++ b/authentication/migrations/0001_initial.py @@ -0,0 +1,52 @@ +# Generated by Django 5.1.2 on 2024-11-10 07:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('auth', '0012_alter_user_first_name_max_length'), + ] + + operations = [ + migrations.CreateModel( + name='TempUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=255, unique=True)), + ('email', models.EmailField(max_length=255, unique=True)), + ('password', models.CharField(max_length=255)), + ], + ), + migrations.CreateModel( + name='User', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('email', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')), + ('username', models.CharField(max_length=255, unique=True, verbose_name='Username')), + ('password', models.CharField(max_length=255, verbose_name='Password')), + ('is_staff', models.BooleanField(default=False)), + ('is_superuser', models.BooleanField(default=False)), + ('is_active', models.BooleanField(default=False)), + ('date_joined', models.DateTimeField(auto_now_add=True)), + ('last_login', models.DateTimeField(auto_now=True)), + ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), + ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OneTimePassword', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('otp', models.CharField(max_length=6, unique=True)), + ('temp_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='authentication.tempuser')), + ], + ), + ] diff --git a/authentication/migrations/0002_tempuser_date_joined.py b/authentication/migrations/0002_tempuser_date_joined.py new file mode 100644 index 0000000..c3f01f7 --- /dev/null +++ b/authentication/migrations/0002_tempuser_date_joined.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.2 on 2024-11-10 08:23 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tempuser', + name='date_joined', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/authentication/migrations/__init__.py b/authentication/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/authentication/models.py b/authentication/models.py index 7d24f9b..c168e82 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -10,7 +10,6 @@ class User(AbstractBaseUser, PermissionsMixin): password = models.CharField(max_length=255, verbose_name=_('Password')) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) - is_verified = models.BooleanField(default=False) is_active = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) last_login = models.DateTimeField(auto_now=True) @@ -35,6 +34,7 @@ class TempUser(models.Model): username = models.CharField(max_length=255, unique=True) email = models.EmailField(max_length=255, unique=True) password = models.CharField(max_length=255) + date_joined = models.DateTimeField(auto_now_add=True) class OneTimePassword(models.Model): diff --git a/authentication/serializers.py b/authentication/serializers.py index ee6f4d0..9f86c8c 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -11,15 +11,51 @@ from rest_framework.exceptions import AuthenticationFailed from rest_framework import serializers +import datetime + from FD import settings from .models import * + + class UserCreateSerializer(serializers.ModelSerializer): password = serializers.CharField(min_length=8, max_length=64, write_only=True) class Meta: - model = User + model = TempUser fields = ['username', 'email', 'password'] + + def validate(self, attrs): + temp_user_ttl = datetime.datetime.now() - datetime.timedelta(minutes=5) + username = attrs.get('username') + email = attrs.get('email') + password = attrs.get('password') + + user_username = User.objects.filter(username=username).first() + user_email = User.objects.filter(email=email).first() + + temp_user_username = TempUser.objects.filter(username=username).first() + temp_user_email = TempUser.objects.filter(email=email).first() + + if user_username: + raise serializers.ValidationError('username already exists') + + if user_email: + raise serializers.ValidationError('email already exists') + + if temp_user_username and temp_user_username.created_at > temp_user_ttl: + raise serializers.ValidationError('user with this username already exists') + + if temp_user_email and temp_user_email.created_at > temp_user_ttl: + raise serializers.ValidationError('user with this email already exists') + + if temp_user_username and temp_user_username.created_at < temp_user_ttl: + temp_user_username.delete() + + if temp_user_email and temp_user_email.created_at < temp_user_ttl: + temp_user_email.delete() + + return attrs def create(self, validated_data): temp_user = TempUser.objects.create(**validated_data) @@ -35,7 +71,8 @@ def validate(self, attrs): try: otp_obj = OneTimePassword.objects.get(otp=otp) temp_user = otp_obj.temp_user - user = user.objects.create_user(username=temp_user.username, email=temp_user.email, password=temp_user.password) + user = User.objects.create_user(username=temp_user.username, email=temp_user.email, password=temp_user.password) + temp_user.delete() return user except OneTimePassword.DoesNotExist: @@ -60,13 +97,14 @@ def validate(self, attrs): try: user = User.objects.get(username=username) except User.DoesNotExist: - raise AuthenticationFailed('Invalid credentials, try again') - - if not user.is_verified: - raise AuthenticationFailed('Email not verified') + temp_user = TempUser.objects.filter(username=username).exists() + if temp_user: + raise AuthenticationFailed('please verify your email') + else: + raise AuthenticationFailed('wrong password') if not user.check_password(password): - raise AuthenticationFailed('Invalid credentials, try again') + raise AuthenticationFailed('Invalid password, try again') tokens = user.tokens() diff --git a/authentication/utils.py b/authentication/utils.py index d90187b..9c05583 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -6,15 +6,21 @@ def send_otp_email(email): from_email = settings.EMAIL_HOST_USER - + password = settings.EMAIL_HOST_PASSWORD otp = secrets.randbelow(10**6) otp = str(otp).zfill(6) temp_user = TempUser.objects.get(email=email) OneTimePassword.objects.create(temp_user=temp_user, otp=otp) - email_subject = 'One Time Password' - email_body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' + subject = 'One Time Password' + body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' - confirmation_email = EmailMessage(subject=email_subject, body=email_body, from_email=from_email, to=[email]) - confirmation_email.send() \ No newline at end of file + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = from_email + msg['To'] = email + msg.set_content(body) + with smtplib.SMTP_SSL('smtp.gmail.com',465) as server: + server.login(from_email, password) + server.send_message(msg) \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index 5d3ecae..92a37da 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -50,6 +50,7 @@ def post(self, request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class PasswordResetRequestView(APIView): serializer_class = PasswordResetRequestSerializer def post(self, request): @@ -59,7 +60,8 @@ def post(self, request): return Response("password reset link has been sent to your email", status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + + class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer def post(self, request, uidb64, token): From aba78f44532d7316192eac9b941a19a000a61fd4 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sun, 10 Nov 2024 15:55:41 +0330 Subject: [PATCH 18/60] fix: registration modified * test * feat: registration policy modified * fix: database connection modified * fix: timing issue fixed --- FD/settings.py | 2 +- authentication/admin.py | 1 + authentication/managers.py | 7 -- authentication/migrations/0001_initial.py | 21 ++++- .../migrations/0002_onetimepassword.py | 23 ------ .../migrations/0002_tempuser_date_joined.py | 20 +++++ authentication/models.py | 13 +++- authentication/serializers.py | 76 ++++++++++++++----- authentication/utils.py | 25 +++--- authentication/views.py | 16 ++-- requirements.txt | 3 + 11 files changed, 137 insertions(+), 70 deletions(-) delete mode 100644 authentication/migrations/0002_onetimepassword.py create mode 100644 authentication/migrations/0002_tempuser_date_joined.py diff --git a/FD/settings.py b/FD/settings.py index 76dedc0..adfb1e5 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -62,7 +62,7 @@ DATABASES = { 'default': { - 'ENGINE': 'django.db.backends.mysql', + 'ENGINE': 'mysql.connector.django', 'NAME': os.getenv('DB_NAME'), 'USER': os.getenv('DB_USER'), 'PASSWORD': os.getenv('DB_PASSWORD'), diff --git a/authentication/admin.py b/authentication/admin.py index 188819b..13339c3 100644 --- a/authentication/admin.py +++ b/authentication/admin.py @@ -3,3 +3,4 @@ admin.site.register(models.User) +admin.site.register(models.TempUser) diff --git a/authentication/managers.py b/authentication/managers.py index 835db80..3536bb3 100644 --- a/authentication/managers.py +++ b/authentication/managers.py @@ -12,12 +12,6 @@ def validate_email(self, email): raise ValueError(_("You must provide a valid email address")) def create_user(self, username, email, password, **extra_fields): - if not username: - raise ValueError(_("The Username must be set")) - if not email: - raise ValueError(_("The Email must be set")) - if not password: - raise ValueError(_("The Password must be set")) email = self.normalize_email(email) self.validate_email(email) @@ -31,7 +25,6 @@ def create_superuser(self, username, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) extra_fields.setdefault("is_active", True) - extra_fields.setdefault("is_verified", True) user = self.create_user(username, email, password, **extra_fields) user.save(using= self._db) diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py index b74941b..00bfc86 100644 --- a/authentication/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,5 +1,6 @@ -# Generated by Django 5.1.2 on 2024-11-04 23:06 +# Generated by Django 5.1.2 on 2024-11-10 07:25 +import django.db.models.deletion from django.db import migrations, models @@ -12,6 +13,15 @@ class Migration(migrations.Migration): ] operations = [ + migrations.CreateModel( + name='TempUser', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('username', models.CharField(max_length=255, unique=True)), + ('email', models.EmailField(max_length=255, unique=True)), + ('password', models.CharField(max_length=255)), + ], + ), migrations.CreateModel( name='User', fields=[ @@ -21,7 +31,6 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=255, verbose_name='Password')), ('is_staff', models.BooleanField(default=False)), ('is_superuser', models.BooleanField(default=False)), - ('is_verified', models.BooleanField(default=False)), ('is_active', models.BooleanField(default=False)), ('date_joined', models.DateTimeField(auto_now_add=True)), ('last_login', models.DateTimeField(auto_now=True)), @@ -32,4 +41,12 @@ class Migration(migrations.Migration): 'abstract': False, }, ), + migrations.CreateModel( + name='OneTimePassword', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('otp', models.CharField(max_length=6, unique=True)), + ('temp_user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='authentication.tempuser')), + ], + ), ] diff --git a/authentication/migrations/0002_onetimepassword.py b/authentication/migrations/0002_onetimepassword.py deleted file mode 100644 index 787c13c..0000000 --- a/authentication/migrations/0002_onetimepassword.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-05 22:17 - -import django.db.models.deletion -from django.conf import settings -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.CreateModel( - name='OneTimePassword', - fields=[ - ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('otp', models.CharField(max_length=6, unique=True)), - ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)), - ], - ), - ] diff --git a/authentication/migrations/0002_tempuser_date_joined.py b/authentication/migrations/0002_tempuser_date_joined.py new file mode 100644 index 0000000..c3f01f7 --- /dev/null +++ b/authentication/migrations/0002_tempuser_date_joined.py @@ -0,0 +1,20 @@ +# Generated by Django 5.1.2 on 2024-11-10 08:23 + +import django.utils.timezone +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tempuser', + name='date_joined', + field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), + preserve_default=False, + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 70cd427..d032ba7 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -10,7 +10,6 @@ class User(AbstractBaseUser, PermissionsMixin): password = models.CharField(max_length=255, verbose_name=_('Password')) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) - is_verified = models.BooleanField(default=False) is_active = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) last_login = models.DateTimeField(auto_now=True) @@ -30,8 +29,18 @@ def tokens(self): 'access': str(refresh.access_token), } + +class TempUser(models.Model): + username = models.CharField(max_length=255) + email = models.EmailField(max_length=255) + password = models.CharField(max_length=255) + date_joined = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.username + class OneTimePassword(models.Model): - user = models.ForeignKey(User, on_delete=models.CASCADE) + temp_user = models.ForeignKey(TempUser, on_delete=models.CASCADE) otp = models.CharField(max_length=6, unique=True) diff --git a/authentication/serializers.py b/authentication/serializers.py index a6c4957..aaebb93 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -1,4 +1,3 @@ -from django.contrib.auth import authenticate from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.utils.encoding import smart_str, smart_bytes from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -6,24 +5,67 @@ from django.urls import reverse from django.core.mail import EmailMessage from django.core.validators import MaxLengthValidator +from django.utils import timezone + from rest_framework.exceptions import AuthenticationFailed from rest_framework import serializers +import datetime + from FD import settings from .models import * + + class UserCreateSerializer(serializers.ModelSerializer): password = serializers.CharField(min_length=8, max_length=64, write_only=True) class Meta: - model = User - fields = ['username', 'email', 'password'] + model = TempUser + fields = ['username', 'email', 'password', 'date_joined'] + + def validate(self, attrs): + temp_user_ttl = timezone.now() - datetime.timedelta(minutes=1) + + username = attrs.get('username') + email = attrs.get('email') + password = attrs.get('password') + + user_username = User.objects.filter(username=username).first() + user_email = User.objects.filter(email=email).first() + + temp_user_username = TempUser.objects.filter(username=username).first() + print(temp_user_username) + temp_user_email = TempUser.objects.filter(email=email).first() + + if temp_user_username: + print(timezone.now() -temp_user_username.date_joined) + + if user_username: + raise serializers.ValidationError('username already exists') + + if user_email: + raise serializers.ValidationError('email already exists') + + if temp_user_username and temp_user_username.date_joined > temp_user_ttl: + raise serializers.ValidationError('username already exists') + + if temp_user_email and temp_user_email.date_joined > temp_user_ttl: + raise serializers.ValidationError('email already exists') + + if temp_user_username and temp_user_username.date_joined < temp_user_ttl: + temp_user_username.delete() + + if temp_user_email and temp_user_email.date_joined < temp_user_ttl: + temp_user_email.delete() + + return attrs def create(self, validated_data): - user = User.objects.create_user(**validated_data) - return user + temp_user = TempUser.objects.create(**validated_data) + return temp_user class VerifyEmailSerializer(serializers.Serializer): @@ -34,15 +76,10 @@ def validate(self, attrs): try: otp_obj = OneTimePassword.objects.get(otp=otp) - user = otp_obj.user - - if not user.is_verified: - user.is_verified = True - user.save() - return user - - else: - raise serializers.ValidationError('account already verified') + temp_user = otp_obj.temp_user + user = User.objects.create_user(username=temp_user.username, email=temp_user.email, password=temp_user.password) + temp_user.delete() + return user except OneTimePassword.DoesNotExist: raise serializers.ValidationError('invalid one time password') @@ -66,13 +103,14 @@ def validate(self, attrs): try: user = User.objects.get(username=username) except User.DoesNotExist: - raise AuthenticationFailed('Invalid credentials, try again') - - if not user.is_verified: - raise AuthenticationFailed('Email not verified') + temp_user = TempUser.objects.filter(username=username).exists() + if temp_user: + raise AuthenticationFailed('please verify your email') + else: + raise AuthenticationFailed('wrong password') if not user.check_password(password): - raise AuthenticationFailed('Invalid credentials, try again') + raise AuthenticationFailed('Invalid password, try again') tokens = user.tokens() diff --git a/authentication/utils.py b/authentication/utils.py index b38f2a7..9c05583 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -1,19 +1,26 @@ -from django.core.mail import EmailMessage +from email.message import EmailMessage +import smtplib import secrets -from .models import OneTimePassword, User +from .models import * from django.conf import settings def send_otp_email(email): from_email = settings.EMAIL_HOST_USER - + password = settings.EMAIL_HOST_PASSWORD otp = secrets.randbelow(10**6) otp = str(otp).zfill(6) - user = User.objects.get(email=email) - OneTimePassword.objects.create(user=user, otp=otp) + temp_user = TempUser.objects.get(email=email) + OneTimePassword.objects.create(temp_user=temp_user, otp=otp) - email_subject = 'One Time Password' - email_body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' + subject = 'One Time Password' + body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' - confirmation_email = EmailMessage(subject=email_subject, body=email_body, from_email=from_email, to=[email]) - confirmation_email.send() \ No newline at end of file + msg = EmailMessage() + msg['Subject'] = subject + msg['From'] = from_email + msg['To'] = email + msg.set_content(body) + with smtplib.SMTP_SSL('smtp.gmail.com',465) as server: + server.login(from_email, password) + server.send_message(msg) \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index feead42..92a37da 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -12,24 +12,24 @@ class RegisterUserView(APIView): serializer_class = UserCreateSerializer def post(self, request): - serializer = UserCreateSerializer(data=request.data) + serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): serializer.save() - user = serializer.data - send_otp_email(user['email']) + temp_user = serializer.data + send_otp_email(temp_user['email']) return Response({ - 'data': user, + 'data': temp_user, 'message': 'User created successfully\n Please check your email for One Time Password', }, status=status.HTTP_201_CREATED) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class VerifyEmailView(APIView): +class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer def post(self, request): - serializer = VerifyEmailSerializer(data=request.data) + serializer = self.serializer_class(data=request.data) if serializer.is_valid(raise_exception=True): return Response({ @@ -50,6 +50,7 @@ def post(self, request): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + class PasswordResetRequestView(APIView): serializer_class = PasswordResetRequestSerializer def post(self, request): @@ -59,7 +60,8 @@ def post(self, request): return Response("password reset link has been sent to your email", status=status.HTTP_200_OK) return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + + class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer def post(self, request, uidb64, token): diff --git a/requirements.txt b/requirements.txt index 6f22560..a89b586 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,12 +1,14 @@ asgiref==3.8.1 attrs==24.2.0 Django==5.1.2 +django-cors-headers==4.6.0 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 inflection==0.5.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +mysql-connector-python==9.1.0 mysqlclient==2.2.5 PyJWT==2.9.0 python-dotenv==1.0.1 @@ -15,3 +17,4 @@ referencing==0.35.1 rpds-py==0.20.1 sqlparse==0.5.1 tzdata==2024.2 +uritemplate==4.1.1 From 3c35baa2c0cf07a76663beae36cb18e8e630898a Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sun, 10 Nov 2024 15:56:47 +0330 Subject: [PATCH 19/60] Update CICD configuration --- .github/workflows/liara.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/liara.yaml b/.github/workflows/liara.yaml index 1557bf0..38f9db3 100644 --- a/.github/workflows/liara.yaml +++ b/.github/workflows/liara.yaml @@ -19,4 +19,4 @@ jobs: LIARA_TOKEN: ${{ secrets.LIARA_API_TOKEN }} run: | npm i -g @liara/cli@7 - liara deploy --app="free-discussion" --api-token="$LIARA_TOKEN" --no-app-logs --port=3000 + liara deploy --app="freediscussion" --api-token="$LIARA_TOKEN" --no-app-logs --port=3000 From bff6c5bdc4656c886d169defb857fab4ecd34986 Mon Sep 17 00:00:00 2001 From: Kian Date: Tue, 12 Nov 2024 23:07:03 +0330 Subject: [PATCH 20/60] fix: refresh token added --- authentication/urls.py | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/authentication/urls.py b/authentication/urls.py index a282e64..64d7448 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -1,11 +1,19 @@ from django.urls import path from .views import * - +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) urlpatterns = [ path('register/', RegisterUserView.as_view()), path('verify-email/', VerifyEmailView.as_view()), + path('login/', LoginUserView.as_view()), + path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + + path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), + path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ] \ No newline at end of file From 7d00e3bdf623e4434844d9aa53ad6e577f788744 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Wed, 13 Nov 2024 15:24:37 +0330 Subject: [PATCH 21/60] User CRUD * feat: logout added * feat: user retrieval added * feat: user deletion added * feat: user update added --- FD/settings.py | 9 ++- ..._tempuser_email_alter_tempuser_username.py | 23 +++++++ authentication/migrations/0004_user_level.py | 18 +++++ .../0005_user_city_user_neighborhood.py | 23 +++++++ ...alter_user_city_alter_user_neighborhood.py | 23 +++++++ authentication/models.py | 3 + authentication/serializers.py | 34 +++++++++- authentication/urls.py | 5 ++ authentication/views.py | 66 ++++++++++++++++++- 9 files changed, 201 insertions(+), 3 deletions(-) create mode 100644 authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py create mode 100644 authentication/migrations/0004_user_level.py create mode 100644 authentication/migrations/0005_user_city_user_neighborhood.py create mode 100644 authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py diff --git a/FD/settings.py b/FD/settings.py index adfb1e5..98f874b 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -20,14 +20,19 @@ 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', + 'rest_framework', 'drf_spectacular', + 'rest_framework_simplejwt.token_blacklist', + + 'corsheaders', + 'authentication', - 'corsheaders' ] MIDDLEWARE = [ 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'django.middleware.common.CommonMiddleware', @@ -121,5 +126,7 @@ SIMPLE_JWT = { "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + 'ROTATE_REFRESH_TOKENS': True, + 'BLACKLIST_AFTER_ROTATION': True, "AUTH_HEADER_TYPES": ("Bearer",), } \ No newline at end of file diff --git a/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py b/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py new file mode 100644 index 0000000..4d3aaba --- /dev/null +++ b/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-12 20:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_tempuser_date_joined'), + ] + + operations = [ + migrations.AlterField( + model_name='tempuser', + name='email', + field=models.EmailField(max_length=255), + ), + migrations.AlterField( + model_name='tempuser', + name='username', + field=models.CharField(max_length=255), + ), + ] diff --git a/authentication/migrations/0004_user_level.py b/authentication/migrations/0004_user_level.py new file mode 100644 index 0000000..55f46ed --- /dev/null +++ b/authentication/migrations/0004_user_level.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-11-12 21:13 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_tempuser_email_alter_tempuser_username'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='level', + field=models.CharField(default='a1', max_length=2), + ), + ] diff --git a/authentication/migrations/0005_user_city_user_neighborhood.py b/authentication/migrations/0005_user_city_user_neighborhood.py new file mode 100644 index 0000000..e6757c1 --- /dev/null +++ b/authentication/migrations/0005_user_city_user_neighborhood.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-12 21:17 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0004_user_level'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='city', + field=models.CharField(default='', max_length=255), + ), + migrations.AddField( + model_name='user', + name='neighborhood', + field=models.CharField(default='', max_length=255), + ), + ] diff --git a/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py b/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py new file mode 100644 index 0000000..e5a7235 --- /dev/null +++ b/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py @@ -0,0 +1,23 @@ +# Generated by Django 5.1.2 on 2024-11-12 21:41 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0005_user_city_user_neighborhood'), + ] + + operations = [ + migrations.AlterField( + model_name='user', + name='city', + field=models.CharField(blank=True, max_length=255, null=True), + ), + migrations.AlterField( + model_name='user', + name='neighborhood', + field=models.CharField(blank=True, max_length=255, null=True), + ), + ] diff --git a/authentication/models.py b/authentication/models.py index d032ba7..c34a6b6 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -13,6 +13,9 @@ class User(AbstractBaseUser, PermissionsMixin): is_active = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) last_login = models.DateTimeField(auto_now=True) + level = models.CharField(max_length=2, default='a1') + city = models.CharField(max_length=255, blank=True, null=True) + neighborhood = models.CharField(max_length=255, blank=True, null=True) USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email', 'password'] diff --git a/authentication/serializers.py b/authentication/serializers.py index aaebb93..7224f87 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -120,6 +120,11 @@ def validate(self, attrs): 'access_token': str(tokens['access']), 'refresh_token': str(tokens['refresh']), } + + +class UserLogoutSerializer(serializers.Serializer): + refresh_token = serializers.CharField() + class PasswordResetRequestSerializer(serializers.ModelSerializer): email = serializers.EmailField() @@ -173,4 +178,31 @@ def validate(self, attrs): user.set_password(password) user.save() - return user \ No newline at end of file + return user + + +class UserRetriveSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['username', 'email', 'level', 'date_joined', 'city', 'neighborhood'] + + +class UserDeleteSerializer(serializers.ModelSerializer): + password = serializers.CharField(min_length=8, max_length=64, write_only=True) + class Meta: + model = User + fields = ['password'] + + +class UserUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['level', 'city', 'neighborhood'] + + def update(self, instance, validated_data): + for key, value in validated_data.items(): + if value is not None: + setattr(instance, key, value) + instance.save() + return self.instance + \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 64d7448..4cc2132 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -10,10 +10,15 @@ path('verify-email/', VerifyEmailView.as_view()), path('login/', LoginUserView.as_view()), + path('logout/', LogoutUserView.as_view()), path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('users/', UserRetriveView.as_view()), + path('users//delete', UserDeleteView.as_view()), + path('users//update', UserUpdateView.as_view()), + path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), ] \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index 92a37da..1a84ed2 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -70,4 +70,68 @@ def post(self, request, uidb64, token): if serializer.is_valid(raise_exception=True): return Response("password reset successfully", status=status.HTTP_200_OK) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) \ No newline at end of file + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class LogoutUserView(APIView): + serializer_class = UserLogoutSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + refresh_token = serializer.validated_data.get('refresh_token') + try: + token = RefreshToken(refresh_token) + token.blacklist() + return Response("logout successfully", status=status.HTTP_200_OK) + except: + return Response("invalid token", status=status.HTTP_400_BAD_REQUEST) + +class UserRetriveView(APIView): + serializer_class= UserRetriveSerializer + + def get(self, request, *args, **kwargs): + username = kwargs.get('username') + user = User.objects.get(username=username) + serializer = self.serializer_class(user) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class UserDeleteView(APIView): + serializer_class= UserDeleteSerializer + + def post(self, request, *args, **kwargs): + try: + username = kwargs.get('username') + user = User.objects.get(username=username) + serializer = self.serializer_class(data = request.data) + + serializer.is_valid(raise_exception=True) + + if user.check_password(serializer.validated_data.get('password')): + return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) + + user.delete() + return Response("user deleted successfully", status=status.HTTP_200_OK) + except User.DoesNotExist: + return Response("user not found", status=status.HTTP_404_NOT_FOUND) + + +class UserUpdateView(APIView): + serializer_class= UserUpdateSerializer + + def put(self, request, *args, **kwargs): + try: + username = kwargs.get('username') + user = User.objects.get(username=username) + serializer = self.serializer_class(user, data = request.data, partial=True) + + if serializer.is_valid(): + serializer.update(user, serializer.validated_data) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + except User.DoesNotExist: + return Response("user not found", status=status.HTTP_404_NOT_FOUND) \ No newline at end of file From 13e7ea19d0bcba5c1629836a48fa797db4b976ea Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 14 Nov 2024 13:20:17 +0330 Subject: [PATCH 22/60] Dev/group crud --- FD/settings.py | 1 + FD/urls.py | 3 + groups/__init__.py | 0 groups/admin.py | 6 + groups/apps.py | 6 + groups/migrations/0001_initial.py | 29 ++++ .../migrations/0002_remove_group_location.py | 17 +++ groups/migrations/__init__.py | 0 groups/models.py | 16 +++ groups/serializers.py | 74 ++++++++++ groups/tests.py | 3 + groups/urls.py | 9 ++ groups/views.py | 126 ++++++++++++++++++ 13 files changed, 290 insertions(+) create mode 100644 groups/__init__.py create mode 100644 groups/admin.py create mode 100644 groups/apps.py create mode 100644 groups/migrations/0001_initial.py create mode 100644 groups/migrations/0002_remove_group_location.py create mode 100644 groups/migrations/__init__.py create mode 100644 groups/models.py create mode 100644 groups/serializers.py create mode 100644 groups/tests.py create mode 100644 groups/urls.py create mode 100644 groups/views.py diff --git a/FD/settings.py b/FD/settings.py index 98f874b..1a4639b 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -28,6 +28,7 @@ 'corsheaders', 'authentication', + 'groups', ] MIDDLEWARE = [ diff --git a/FD/urls.py b/FD/urls.py index 07f8bb6..73efbbc 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -4,7 +4,10 @@ urlpatterns = [ path('admin/', admin.site.urls), + path('authentication/', include('authentication.urls')), + path('groups/', include('groups.urls')), + path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), ] diff --git a/groups/__init__.py b/groups/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groups/admin.py b/groups/admin.py new file mode 100644 index 0000000..4d079fd --- /dev/null +++ b/groups/admin.py @@ -0,0 +1,6 @@ +from django.contrib import admin +from . import models + + +admin.site.register(models.Group) + diff --git a/groups/apps.py b/groups/apps.py new file mode 100644 index 0000000..86b49bc --- /dev/null +++ b/groups/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class GroupsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'groups' diff --git a/groups/migrations/0001_initial.py b/groups/migrations/0001_initial.py new file mode 100644 index 0000000..63d20b5 --- /dev/null +++ b/groups/migrations/0001_initial.py @@ -0,0 +1,29 @@ +# Generated by Django 5.1.2 on 2024-11-13 14:18 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Group', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('title', models.CharField(max_length=255)), + ('description', models.TextField(blank=True, null=True)), + ('level', models.CharField(max_length=255)), + ('location', models.CharField(max_length=255)), + ('city', models.CharField(blank=True, max_length=255, null=True)), + ('neighborhood', models.CharField(blank=True, max_length=255, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('max_members', models.IntegerField()), + ('meeting_url', models.URLField(blank=True, null=True)), + ], + ), + ] diff --git a/groups/migrations/0002_remove_group_location.py b/groups/migrations/0002_remove_group_location.py new file mode 100644 index 0000000..f79334d --- /dev/null +++ b/groups/migrations/0002_remove_group_location.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2024-11-13 14:19 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='group', + name='location', + ), + ] diff --git a/groups/migrations/__init__.py b/groups/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/groups/models.py b/groups/models.py new file mode 100644 index 0000000..645f95c --- /dev/null +++ b/groups/models.py @@ -0,0 +1,16 @@ +from django.db import models + +class Group(models.Model): + title = models.CharField(max_length=255) + description = models.TextField(blank=True, null=True) + level = models.CharField(max_length=255) + city = models.CharField(max_length=255, blank=True, null=True) + neighborhood = models.CharField(max_length=255, blank=True, null=True) + created_at = models.DateTimeField(auto_now_add=True) + max_members = models.IntegerField() + meeting_url = models.URLField(blank=True, null=True) + + + + def __str__(self): + return self.title \ No newline at end of file diff --git a/groups/serializers.py b/groups/serializers.py new file mode 100644 index 0000000..035fb27 --- /dev/null +++ b/groups/serializers.py @@ -0,0 +1,74 @@ +from rest_framework import serializers +from groups.models import * + +VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + + +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = '__all__' + + def validate(self, data): + # For create operation + if not self.instance: + if Group.objects.filter(title=data.get('title')).exists(): + raise serializers.ValidationError('group title already exists') + + if 'level' in data and data['level'] not in VALID_LEVELS: + raise serializers.ValidationError( + f'Invalid level. Please select one of the following: {", ".join(VALID_LEVELS)}' + ) + return data + + + + + + + + + + + + + + + + + + +# class GroupSerializer(serializers.ModelSerializer): +# class Meta: +# model = Group +# fields = '__all__' + +# def create(self, validated_data): +# if Group.objects.filter(title=validated_data['title']).exists(): +# raise serializers.ValidationError('group title already exists') + +# if validated_data['level'] not in VALID_LEVELS: +# raise serializers.ValidationError('Invalid level. please select one of the following: A1, A2, B1, B2, C1, C2') + +# group = Group.objects.create(**validated_data) +# return group + +# class GroupUpdateSerializer(serializers.ModelSerializer): +# class Meta: +# model = Group +# fields = ['level', 'city', 'neighborhood'] + +# def update(self, instance, validated_data): +# # if Group.objects.filter(title=validated_data['title']).exists(): +# # raise serializers.ValidationError('group title already exists') + +# # if validated_data['level'] not in VALID_LEVELS: +# # raise serializers.ValidationError('Invalid level. please select one of the following: A1, A2, B1, B2, C1, C2') + +# instance.title = validated_data.get('title', instance.title) +# instance.level = validated_data.get('level', instance.level) +# instance.city = validated_data.get('city', instance.city) +# instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) +# instance.save() + +# return instance \ No newline at end of file diff --git a/groups/tests.py b/groups/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/groups/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/groups/urls.py b/groups/urls.py new file mode 100644 index 0000000..9f3a4e9 --- /dev/null +++ b/groups/urls.py @@ -0,0 +1,9 @@ +from django.urls import path, include +from .views import * + +urlpatterns = [ + path('create/', GroupView.as_view()), + path('/', GroupView.as_view()), #retrieve + path('/delete/', GroupView.as_view()), + path('/update/', GroupView.as_view()), +] \ No newline at end of file diff --git a/groups/views.py b/groups/views.py new file mode 100644 index 0000000..b71f146 --- /dev/null +++ b/groups/views.py @@ -0,0 +1,126 @@ +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +from .serializers import * +from .models import * + + +from rest_framework import status +from rest_framework.response import Response +from rest_framework.views import APIView + +class GroupView(APIView): + serializer_class = GroupSerializer + + def post(self, request): + """Create a new group""" + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data, status=status.HTTP_201_CREATED) + + def get(self, request, title=None): + """Retrieve a group or list all groups""" + if title: + try: + group = Group.objects.get(title=title) + serializer = self.serializer_class(group) + return Response(serializer.data) + except Group.DoesNotExist: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) + + groups = Group.objects.all() + serializer = self.serializer_class(groups, many=True) + return Response(serializer.data) + + def patch(self, request, title): + """Update a group partially""" + try: + group = Group.objects.get(title=title) + serializer = self.serializer_class( + group, + data=request.data, + partial=True + ) + serializer.is_valid(raise_exception=True) + serializer.save() + return Response(serializer.data) + except Group.DoesNotExist: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) + + def delete(self, request, title): + """Delete a group""" + try: + group = Group.objects.get(title=title) + group.delete() + return Response("group deleted successfully", status=status.HTTP_200_OK) + except Group.DoesNotExist: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) + + + + + + + + + + + + + + + +# class GroupCreateView(APIView): +# serializer_class = GroupSerializer + +# def post(self, request): +# serializer = self.serializer_class(data=request.data) + +# if serializer.is_valid(raise_exception=True): +# serializer.save() +# return Response(serializer.data, status=status.HTTP_201_CREATED) + +# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +# class GroupRetriveView(APIView): +# serializer_class = GroupSerializer + +# def get(self, request, *args, **kwargs): +# title = kwargs.get('title') +# try: +# group = Group.objects.get(title=title) +# serializer = self.serializer_class(group) +# return Response(serializer.data, status=status.HTTP_200_OK) +# except Group.DoesNotExist: +# return Response("group not found", status=status.HTTP_404_NOT_FOUND) + +# class GroupDeleteView(APIView): +# serializer_class = GroupSerializer + +# def delete(self, request, *args, **kwargs): +# title = kwargs.get('title') +# try: +# group = Group.objects.get(title=title) +# serializer = self.serializer_class(group) +# serializer.delete(group) +# return Response("group deleted successfully", status=status.HTTP_200_OK) +# except Group.DoesNotExist: +# return Response("group not found", status=status.HTTP_404_NOT_FOUND) + + +# class GroupUpdateView(APIView): +# serializer_class = GroupUpdateSerializer + +# def patch(self, request, *args, **kwargs): +# title = kwargs.get('title') +# try: +# group = Group.objects.get(title=title) +# serializer = self.serializer_class(group, data=request.data, partial=True) +# serializer.is_valid(raise_exception=True) +# serializer.save() +# return Response(serializer.data, status=status.HTTP_200_OK) +# except Group.DoesNotExist: +# raise Response("group not found", status=status.HTTP_404_NOT_FOUND) \ No newline at end of file From 73caa99cc1aeabf6a1dbfd25c9f2c974c7233b0b Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sun, 17 Nov 2024 17:04:44 +0330 Subject: [PATCH 23/60] Dev/core *feat: group membership setup * feat: public group join * feat: accept and decline join request, leave, kik * feat: home setup * feat: user timeslot added * feat: group timeslots added * feat: base suggestion added --------- Co-authored-by: Mahziar --- FD/settings.py | 3 +- FD/urls.py | 1 + authentication/managers.py | 1 - authentication/migrations/0001_initial.py | 11 +- .../migrations/0002_tempuser_date_joined.py | 20 -- ..._tempuser_email_alter_tempuser_username.py | 23 -- authentication/migrations/0004_user_level.py | 18 -- .../0005_user_city_user_neighborhood.py | 23 -- ...alter_user_city_alter_user_neighborhood.py | 23 -- authentication/models.py | 7 +- authentication/serializers.py | 2 +- authentication/urls.py | 6 +- authentication/views.py | 5 +- groups/migrations/0001_initial.py | 10 +- .../migrations/0002_remove_group_location.py | 17 -- groups/models.py | 28 +++ groups/permissions.py | 9 + groups/serializers.py | 153 ++++++++---- groups/urls.py | 15 +- groups/views.py | 232 +++++++++++------- home/__init__.py | 0 home/admin.py | 3 + home/apps.py | 6 + home/migrations/0001_initial.py | 27 ++ home/migrations/__init__.py | 0 home/models.py | 23 ++ home/permissions.py | 9 + home/serializers.py | 36 +++ home/services.py | 104 ++++++++ home/tests.py | 3 + home/urls.py | 15 ++ home/views.py | 103 ++++++++ 32 files changed, 642 insertions(+), 294 deletions(-) delete mode 100644 authentication/migrations/0002_tempuser_date_joined.py delete mode 100644 authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py delete mode 100644 authentication/migrations/0004_user_level.py delete mode 100644 authentication/migrations/0005_user_city_user_neighborhood.py delete mode 100644 authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py delete mode 100644 groups/migrations/0002_remove_group_location.py create mode 100644 groups/permissions.py create mode 100644 home/__init__.py create mode 100644 home/admin.py create mode 100644 home/apps.py create mode 100644 home/migrations/0001_initial.py create mode 100644 home/migrations/__init__.py create mode 100644 home/models.py create mode 100644 home/permissions.py create mode 100644 home/serializers.py create mode 100644 home/services.py create mode 100644 home/tests.py create mode 100644 home/urls.py create mode 100644 home/views.py diff --git a/FD/settings.py b/FD/settings.py index 1a4639b..ca350e0 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -29,6 +29,7 @@ 'authentication', 'groups', + 'home', ] MIDDLEWARE = [ @@ -125,7 +126,7 @@ } SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(minutes=5), + "ACCESS_TOKEN_LIFETIME": timedelta(days=1), "REFRESH_TOKEN_LIFETIME": timedelta(days=1), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, diff --git a/FD/urls.py b/FD/urls.py index 73efbbc..5331e0b 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -7,6 +7,7 @@ path('authentication/', include('authentication.urls')), path('groups/', include('groups.urls')), + path('', include('home.urls')), path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/authentication/managers.py b/authentication/managers.py index 3536bb3..987035a 100644 --- a/authentication/managers.py +++ b/authentication/managers.py @@ -24,7 +24,6 @@ def create_user(self, username, email, password, **extra_fields): def create_superuser(self, username, email, password=None, **extra_fields): extra_fields.setdefault("is_staff", True) extra_fields.setdefault("is_superuser", True) - extra_fields.setdefault("is_active", True) user = self.create_user(username, email, password, **extra_fields) user.save(using= self._db) diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py index 00bfc86..78cb461 100644 --- a/authentication/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-10 07:25 +# Generated by Django 5.1.2 on 2024-11-17 10:52 import django.db.models.deletion from django.db import migrations, models @@ -17,9 +17,10 @@ class Migration(migrations.Migration): name='TempUser', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), - ('username', models.CharField(max_length=255, unique=True)), - ('email', models.EmailField(max_length=255, unique=True)), + ('username', models.CharField(max_length=255)), + ('email', models.EmailField(max_length=255)), ('password', models.CharField(max_length=255)), + ('date_joined', models.DateTimeField(auto_now_add=True)), ], ), migrations.CreateModel( @@ -31,9 +32,11 @@ class Migration(migrations.Migration): ('password', models.CharField(max_length=255, verbose_name='Password')), ('is_staff', models.BooleanField(default=False)), ('is_superuser', models.BooleanField(default=False)), - ('is_active', models.BooleanField(default=False)), ('date_joined', models.DateTimeField(auto_now_add=True)), ('last_login', models.DateTimeField(auto_now=True)), + ('level', models.CharField(default='A1', max_length=2)), + ('city', models.CharField(blank=True, max_length=255, null=True)), + ('neighborhood', models.CharField(blank=True, max_length=255, null=True)), ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], diff --git a/authentication/migrations/0002_tempuser_date_joined.py b/authentication/migrations/0002_tempuser_date_joined.py deleted file mode 100644 index c3f01f7..0000000 --- a/authentication/migrations/0002_tempuser_date_joined.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-10 08:23 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tempuser', - name='date_joined', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - ] diff --git a/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py b/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py deleted file mode 100644 index 4d3aaba..0000000 --- a/authentication/migrations/0003_alter_tempuser_email_alter_tempuser_username.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-12 20:20 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0002_tempuser_date_joined'), - ] - - operations = [ - migrations.AlterField( - model_name='tempuser', - name='email', - field=models.EmailField(max_length=255), - ), - migrations.AlterField( - model_name='tempuser', - name='username', - field=models.CharField(max_length=255), - ), - ] diff --git a/authentication/migrations/0004_user_level.py b/authentication/migrations/0004_user_level.py deleted file mode 100644 index 55f46ed..0000000 --- a/authentication/migrations/0004_user_level.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-12 21:13 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0003_alter_tempuser_email_alter_tempuser_username'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='level', - field=models.CharField(default='a1', max_length=2), - ), - ] diff --git a/authentication/migrations/0005_user_city_user_neighborhood.py b/authentication/migrations/0005_user_city_user_neighborhood.py deleted file mode 100644 index e6757c1..0000000 --- a/authentication/migrations/0005_user_city_user_neighborhood.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-12 21:17 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0004_user_level'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='city', - field=models.CharField(default='', max_length=255), - ), - migrations.AddField( - model_name='user', - name='neighborhood', - field=models.CharField(default='', max_length=255), - ), - ] diff --git a/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py b/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py deleted file mode 100644 index e5a7235..0000000 --- a/authentication/migrations/0006_alter_user_city_alter_user_neighborhood.py +++ /dev/null @@ -1,23 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-12 21:41 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0005_user_city_user_neighborhood'), - ] - - operations = [ - migrations.AlterField( - model_name='user', - name='city', - field=models.CharField(blank=True, max_length=255, null=True), - ), - migrations.AlterField( - model_name='user', - name='neighborhood', - field=models.CharField(blank=True, max_length=255, null=True), - ), - ] diff --git a/authentication/models.py b/authentication/models.py index c34a6b6..ca76b56 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -1,7 +1,9 @@ from django.db import models from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin from django.utils.translation import gettext_lazy as _ + from rest_framework_simplejwt.tokens import RefreshToken + from .managers import UserManager class User(AbstractBaseUser, PermissionsMixin): @@ -10,13 +12,12 @@ class User(AbstractBaseUser, PermissionsMixin): password = models.CharField(max_length=255, verbose_name=_('Password')) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) - is_active = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) last_login = models.DateTimeField(auto_now=True) - level = models.CharField(max_length=2, default='a1') + level = models.CharField(max_length=2, default='A1') city = models.CharField(max_length=255, blank=True, null=True) neighborhood = models.CharField(max_length=255, blank=True, null=True) - + USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['email', 'password'] diff --git a/authentication/serializers.py b/authentication/serializers.py index 7224f87..742b538 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -204,5 +204,5 @@ def update(self, instance, validated_data): if value is not None: setattr(instance, key, value) instance.save() - return self.instance + return validated_data \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 4cc2132..1094f95 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -15,9 +15,9 @@ path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('users/', UserRetriveView.as_view()), - path('users//delete', UserDeleteView.as_view()), - path('users//update', UserUpdateView.as_view()), + path('users//', UserRetriveView.as_view()), + path('users//delete/', UserDeleteView.as_view()), + path('users//update/', UserUpdateView.as_view()), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), diff --git a/authentication/views.py b/authentication/views.py index 1a84ed2..983090c 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -43,8 +43,7 @@ class LoginUserView(APIView): serializer_class = UserLoginSerializer def post(self, request): - serializer = UserLoginSerializer(data=request.data, context={'request': request}) - + serializer = self.serializer_class(data=request.data, context={'request': request}) if serializer.is_valid(raise_exception=True): return Response(serializer.data, status=status.HTTP_200_OK) @@ -121,7 +120,7 @@ def post(self, request, *args, **kwargs): class UserUpdateView(APIView): serializer_class= UserUpdateSerializer - def put(self, request, *args, **kwargs): + def patch(self, request, *args, **kwargs): try: username = kwargs.get('username') user = User.objects.get(username=username) diff --git a/groups/migrations/0001_initial.py b/groups/migrations/0001_initial.py index 63d20b5..6cc8278 100644 --- a/groups/migrations/0001_initial.py +++ b/groups/migrations/0001_initial.py @@ -1,5 +1,7 @@ -# Generated by Django 5.1.2 on 2024-11-13 14:18 +# Generated by Django 5.1.2 on 2024-11-17 10:52 +import django.db.models.deletion +from django.conf import settings from django.db import migrations, models @@ -8,6 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -18,12 +21,15 @@ class Migration(migrations.Migration): ('title', models.CharField(max_length=255)), ('description', models.TextField(blank=True, null=True)), ('level', models.CharField(max_length=255)), - ('location', models.CharField(max_length=255)), ('city', models.CharField(blank=True, max_length=255, null=True)), ('neighborhood', models.CharField(blank=True, max_length=255, null=True)), ('created_at', models.DateTimeField(auto_now_add=True)), ('max_members', models.IntegerField()), ('meeting_url', models.URLField(blank=True, null=True)), + ('private', models.BooleanField(default=False)), + ('members', models.ManyToManyField(related_name='joined_groups', to=settings.AUTH_USER_MODEL)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='owned_groups', to=settings.AUTH_USER_MODEL)), + ('pending_members', models.ManyToManyField(related_name='pending_groups', to=settings.AUTH_USER_MODEL)), ], ), ] diff --git a/groups/migrations/0002_remove_group_location.py b/groups/migrations/0002_remove_group_location.py deleted file mode 100644 index f79334d..0000000 --- a/groups/migrations/0002_remove_group_location.py +++ /dev/null @@ -1,17 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-13 14:19 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('groups', '0001_initial'), - ] - - operations = [ - migrations.RemoveField( - model_name='group', - name='location', - ), - ] diff --git a/groups/models.py b/groups/models.py index 645f95c..7367048 100644 --- a/groups/models.py +++ b/groups/models.py @@ -9,6 +9,34 @@ class Group(models.Model): created_at = models.DateTimeField(auto_now_add=True) max_members = models.IntegerField() meeting_url = models.URLField(blank=True, null=True) + private = models.BooleanField(default=False) + + owner = models.ForeignKey('authentication.User', on_delete=models.CASCADE, related_name='owned_groups') + members = models.ManyToManyField('authentication.User', related_name='joined_groups') + pending_members = models.ManyToManyField('authentication.User', related_name='pending_groups') + + + def add_pending_member(self, user): + self.pending_members.add(user) + + def remove_pending_member(self, user): + self.pending_members.remove(user) + + def accept_pending_member(self, user): + self.members.add(user) + self.pending_members.remove(user) + + def add_member(self, user): + self.members.add(user) + + def remove_member(self, user): + self.members.remove(user) + + def owner_username(self): + return self.owner.username + + def members_usernames(self): + return [member.username for member in self.members.all()] diff --git a/groups/permissions.py b/groups/permissions.py new file mode 100644 index 0000000..4ee7820 --- /dev/null +++ b/groups/permissions.py @@ -0,0 +1,9 @@ +from rest_framework.permissions import BasePermission + +class IsGroupOwner(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.owner == request.user + +class IsGroupMember(BasePermission): + def has_object_permission(self, request, view, obj): + return request.user in obj.members.all() \ No newline at end of file diff --git a/groups/serializers.py b/groups/serializers.py index 035fb27..1a4963a 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -4,71 +4,118 @@ VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] -class GroupSerializer(serializers.ModelSerializer): +class GroupCreateSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = '__all__' + fields = ['title', 'description', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] def validate(self, data): - # For create operation - if not self.instance: - if Group.objects.filter(title=data.get('title')).exists(): - raise serializers.ValidationError('group title already exists') - - if 'level' in data and data['level'] not in VALID_LEVELS: - raise serializers.ValidationError( - f'Invalid level. Please select one of the following: {", ".join(VALID_LEVELS)}' - ) + if Group.objects.filter(title=data.get('title')).exists(): + raise serializers.ValidationError('group title already exists') return data + + def create(self, validated_data): + user = self.context['request'].user + Group.objects.create( + title=validated_data.get('title'), + description=validated_data.get('description'), + level=validated_data.get('level'), + city=validated_data.get('city'), + neighborhood=validated_data.get('neighborhood'), + max_members=validated_data.get('max_members'), + meeting_url=validated_data.get('meeting_url'), + private=validated_data.get('private'), + owner=user + ) + group = Group.objects.get(title=validated_data.get('title')) + group.add_member(user) + # user.joined_groups.add(group) + return group + +class GroupRetrieveSerializer(serializers.ModelSerializer): + owner_username = serializers.SerializerMethodField(read_only=True) + member_usernames = serializers.SerializerMethodField(read_only=True) + + class Meta: + model = Group + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', + 'created_at', 'max_members', 'meeting_url', 'private', + 'owner_username', 'member_usernames'] + def get_owner_username(self, obj): + return obj.owner.username if obj.owner else None + def get_member_usernames(self, obj): + return [member.username for member in obj.members.all()] +class GroupUpdateSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['title', 'description', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] + + def validate(self, data): + if 'level' in data and data['level'] not in VALID_LEVELS: + raise serializers.ValidationError( + f'Invalid level. Please select one of the following: {", ".join(VALID_LEVELS)}' + ) + + if 'title' in data and Group.objects.filter(title=data['title']).exists(): + raise serializers.ValidationError('group title already exists') + + return data + + def update(self, instance, validated_data): + instance.title = validated_data.get('title', instance.title) + instance.description = validated_data.get('description', instance.description) + instance.level = validated_data.get('level', instance.level) + instance.city = validated_data.get('city', instance.city) + instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) + instance.max_members = validated_data.get('max_members', instance.max_members) + instance.meeting_url = validated_data.get('meeting_url', instance.meeting_url) + instance.private = validated_data.get('private', instance.private) + instance.save() + return instance + +class GroupPendingRequestSerializer(serializers.ModelSerializer): + pending_members = serializers.SerializerMethodField(read_only=True) + class Meta: + model = Group + fields = ['pending_members'] + + def get_pending_members(self, obj): + return [member.username for member in obj.pending_members.all()] + +class GroupAcceptRequestSerializer(serializers.Serializer): + username = serializers.CharField() + + def validate(self, data): + pending_usernames = [member.username for member in self.instance.pending_members.all()] + if data['username'] not in pending_usernames: + print(data['username']) + print(pending_usernames) + raise serializers.ValidationError('user not in pending members') + return data + +class GroupDeclineRequestSerializer(serializers.Serializer): + username = serializers.CharField() + + def validate(self, data): + pending_usernames = [member.username for member in self.instance.pending_members.all()] + if data['username'] not in pending_usernames: + raise serializers.ValidationError('user not in pending members') + return data - - - - - - - - - -# class GroupSerializer(serializers.ModelSerializer): -# class Meta: -# model = Group -# fields = '__all__' - -# def create(self, validated_data): -# if Group.objects.filter(title=validated_data['title']).exists(): -# raise serializers.ValidationError('group title already exists') - -# if validated_data['level'] not in VALID_LEVELS: -# raise serializers.ValidationError('Invalid level. please select one of the following: A1, A2, B1, B2, C1, C2') - -# group = Group.objects.create(**validated_data) -# return group - -# class GroupUpdateSerializer(serializers.ModelSerializer): -# class Meta: -# model = Group -# fields = ['level', 'city', 'neighborhood'] +class GroupKickSerializer(serializers.Serializer): + username = serializers.CharField() -# def update(self, instance, validated_data): -# # if Group.objects.filter(title=validated_data['title']).exists(): -# # raise serializers.ValidationError('group title already exists') - -# # if validated_data['level'] not in VALID_LEVELS: -# # raise serializers.ValidationError('Invalid level. please select one of the following: A1, A2, B1, B2, C1, C2') - -# instance.title = validated_data.get('title', instance.title) -# instance.level = validated_data.get('level', instance.level) -# instance.city = validated_data.get('city', instance.city) -# instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) -# instance.save() - -# return instance \ No newline at end of file + def validate(self, data): + usernames = [member.username for member in self.instance.members.all()] + if data['username'] not in usernames: + raise serializers.ValidationError('user not in members') + return data + \ No newline at end of file diff --git a/groups/urls.py b/groups/urls.py index 9f3a4e9..fee9d07 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -2,8 +2,15 @@ from .views import * urlpatterns = [ - path('create/', GroupView.as_view()), - path('/', GroupView.as_view()), #retrieve - path('/delete/', GroupView.as_view()), - path('/update/', GroupView.as_view()), + path('create/', GroupCreateView.as_view()), + path('/', GroupRetrieveView.as_view()), #retrieve + path('/delete/', GroupDeleteView.as_view()), + path('/update/', GroupUpdateView.as_view()), + + path('/join/', GroupJoinRequestView.as_view()), + path('/leave/', GroupLeaveView.as_view()), + + path('/pending/', GroupPendingRequestView.as_view()), + path('/accept/', GroupAcceptRequestView.as_view()), + path('/decline/', GroupDeclineRequestView.as_view()), ] \ No newline at end of file diff --git a/groups/views.py b/groups/views.py index b71f146..c14f3a6 100644 --- a/groups/views.py +++ b/groups/views.py @@ -1,126 +1,168 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from rest_framework.permissions import IsAuthenticated + +from authentication.models import User from .serializers import * from .models import * +from .permissions import * -from rest_framework import status -from rest_framework.response import Response -from rest_framework.views import APIView - -class GroupView(APIView): - serializer_class = GroupSerializer +class GroupCreateView(APIView): + permission_classes = [IsAuthenticated] - def post(self, request): - """Create a new group""" - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data, status=status.HTTP_201_CREATED) + serializer_class = GroupCreateSerializer - def get(self, request, title=None): - """Retrieve a group or list all groups""" - if title: - try: - group = Group.objects.get(title=title) - serializer = self.serializer_class(group) - return Response(serializer.data) - except Group.DoesNotExist: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + def post(self, request): + serializer = self.serializer_class(data=request.data, context={'request': request}) + if serializer.is_valid(raise_exception=True): + serializer.save(owner=request.user) + return Response(serializer.data, status=status.HTTP_201_CREATED) - groups = Group.objects.all() - serializer = self.serializer_class(groups, many=True) - return Response(serializer.data) + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + +class GroupRetrieveView(APIView): + serializer_class = GroupRetrieveSerializer - def patch(self, request, title): - """Update a group partially""" - try: - group = Group.objects.get(title=title) + def get(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + serializer = self.serializer_class(group) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) + + +class GroupUpdateView(APIView): + permission_classes = [IsAuthenticated, IsGroupOwner] + + serializer_class = GroupUpdateSerializer + + def patch(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) serializer = self.serializer_class( group, data=request.data, partial=True ) - serializer.is_valid(raise_exception=True) - serializer.save() - return Response(serializer.data) - except Group.DoesNotExist: + if serializer.is_valid(raise_exception=True): + serializer.update(group, serializer.validated_data) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: return Response("group not found", status=status.HTTP_404_NOT_FOUND) - - def delete(self, request, title): - """Delete a group""" - try: - group = Group.objects.get(title=title) - group.delete() - return Response("group deleted successfully", status=status.HTTP_200_OK) - except Group.DoesNotExist: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) - - - - - - - - - +class GroupDeleteView(APIView): + permission_classes = [IsAuthenticated, IsGroupOwner] + def delete(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + if request.user.owned_groups.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + group.delete() + return Response("group deleted successfully", status=status.HTTP_200_OK) + else: + return Response("you are not the owner of this group", status=status.HTTP_400_BAD_REQUEST) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) + +class GroupJoinRequestView(APIView): + permission_classes = [IsAuthenticated] + + def post(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + if group.max_members < group.members.count(): + return Response("group is full", status=status.HTTP_400_BAD_REQUEST) + if group.level != request.user.level: + return Response(f"your level is not compatible with this group your level is {request.user.level} and group level is {group.level}", status=status.HTTP_400_BAD_REQUEST) + + if group.private: + group.add_pending_member(request.user) + return Response("join request sent successfully", status=status.HTTP_200_OK) + else: + group.add_member(request.user) + return Response("you are now a member of this group", status=status.HTTP_200_OK) + + +class GroupPendingRequestView(APIView): + permission_classes = [IsAuthenticated, IsGroupOwner] + + def get(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + serializer = GroupPendingRequestSerializer(group) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) -# class GroupCreateView(APIView): -# serializer_class = GroupSerializer +class GroupAcceptRequestView(APIView): + serializer_class = GroupAcceptRequestSerializer + permission_classes = [IsAuthenticated, IsGroupOwner] -# def post(self, request): -# serializer = self.serializer_class(data=request.data) + def post(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + serializer = self.serializer_class(group, data=request.data) + if serializer.is_valid(raise_exception=True): + user = User.objects.get(username=serializer.validated_data['username']) + group.accept_pending_member(user) + return Response("request accepted successfully", status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) -# if serializer.is_valid(raise_exception=True): -# serializer.save() -# return Response(serializer.data, status=status.HTTP_201_CREATED) -# return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - +class GroupDeclineRequestView(APIView): + serializer_class = GroupDeclineRequestSerializer + permission_classes = [IsAuthenticated, IsGroupOwner] -# class GroupRetriveView(APIView): -# serializer_class = GroupSerializer - -# def get(self, request, *args, **kwargs): -# title = kwargs.get('title') -# try: -# group = Group.objects.get(title=title) -# serializer = self.serializer_class(group) -# return Response(serializer.data, status=status.HTTP_200_OK) -# except Group.DoesNotExist: -# return Response("group not found", status=status.HTTP_404_NOT_FOUND) + def post(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + serializer = self.serializer_class(group, data=request.data) + if serializer.is_valid(raise_exception=True): + user = User.objects.get(username=serializer.validated_data['username']) + group.decline_pending_member(user) + return Response("request declined successfully", status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) -# class GroupDeleteView(APIView): -# serializer_class = GroupSerializer - -# def delete(self, request, *args, **kwargs): -# title = kwargs.get('title') -# try: -# group = Group.objects.get(title=title) -# serializer = self.serializer_class(group) -# serializer.delete(group) -# return Response("group deleted successfully", status=status.HTTP_200_OK) -# except Group.DoesNotExist: -# return Response("group not found", status=status.HTTP_404_NOT_FOUND) + +class GroupLeaveView(APIView): + permission_classes = [IsAuthenticated, IsGroupMember] + def post(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + group.remove_member(request.user) + return Response("you left the group successfully", status=status.HTTP_200_OK) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) -# class GroupUpdateView(APIView): -# serializer_class = GroupUpdateSerializer - -# def patch(self, request, *args, **kwargs): -# title = kwargs.get('title') -# try: -# group = Group.objects.get(title=title) -# serializer = self.serializer_class(group, data=request.data, partial=True) -# serializer.is_valid(raise_exception=True) -# serializer.save() -# return Response(serializer.data, status=status.HTTP_200_OK) -# except Group.DoesNotExist: -# raise Response("group not found", status=status.HTTP_404_NOT_FOUND) \ No newline at end of file +class GroupKickView(APIView): + serializer_class = GroupKickSerializer + permission_classes = [IsAuthenticated, IsGroupOwner] + def post(self, request, *args, **kwargs): + if Group.objects.filter(title=kwargs.get('title')).exists(): + group = Group.objects.get(title=kwargs.get('title')) + serializer = self.serializer_class(group, data=request.data) + if serializer.is_valid(raise_exception=True): + user = User.objects.get(username=serializer.validated_data['username']) + group.remove_member(user) + return Response("user kicked successfully", status=status.HTTP_200_OK) + else: + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + else: + return Response("group not found", status=status.HTTP_404_NOT_FOUND) \ No newline at end of file diff --git a/home/__init__.py b/home/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/admin.py b/home/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/home/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/home/apps.py b/home/apps.py new file mode 100644 index 0000000..e5ea0af --- /dev/null +++ b/home/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class HomeConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'home' diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py new file mode 100644 index 0000000..6631972 --- /dev/null +++ b/home/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.2 on 2024-11-17 10:52 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='TimeSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('day_of_week', models.CharField(max_length=255)), + ('start_time', models.FloatField()), + ('end_time', models.FloatField()), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_slots', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/home/migrations/__init__.py b/home/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/home/models.py b/home/models.py new file mode 100644 index 0000000..35a8724 --- /dev/null +++ b/home/models.py @@ -0,0 +1,23 @@ +from django.db import models +from django.contrib.contenttypes.fields import GenericForeignKey +from django.contrib.contenttypes.models import ContentType + +class TimeSlot(models.Model): + day_of_week = models.CharField(max_length=255) + start_time = models.FloatField() + end_time = models.FloatField() + + user = models.ForeignKey('authentication.User', models.CASCADE, related_name='time_slots') + + def __str__(self): + return f"{self.day_of_week} {self.start_time} - {self.end_time}" + +class GroupTimeSlot(models.Model): + day_of_week = models.CharField(max_length=255) + start_time = models.FloatField() + end_time = models.FloatField() + + group = models.ForeignKey('groups.Group', models.CASCADE, related_name='time_slots') + + def __str__(self): + return f"{self.day_of_week} {self.start_time} - {self.end_time}" \ No newline at end of file diff --git a/home/permissions.py b/home/permissions.py new file mode 100644 index 0000000..4ee7820 --- /dev/null +++ b/home/permissions.py @@ -0,0 +1,9 @@ +from rest_framework.permissions import BasePermission + +class IsGroupOwner(BasePermission): + def has_object_permission(self, request, view, obj): + return obj.owner == request.user + +class IsGroupMember(BasePermission): + def has_object_permission(self, request, view, obj): + return request.user in obj.members.all() \ No newline at end of file diff --git a/home/serializers.py b/home/serializers.py new file mode 100644 index 0000000..74df0c2 --- /dev/null +++ b/home/serializers.py @@ -0,0 +1,36 @@ +from rest_framework import serializers + +from .models import * +from authentication.models import User +from groups.models import Group + +class HomeSerializer(serializers.Serializer): + joined_groups = serializers.SerializerMethodField() + pending_groups = serializers.SerializerMethodField() + + class meta: + model = User + fields = ['joined_groups', 'pending_groups'] + + def get_joined_groups(self, obj): + joined_groups = [group.title for group in obj.joined_groups.all()] + return joined_groups + + def get_pending_groups(self, obj): + pending_groups = [group.title for group in obj.pending_groups.all()] + return pending_groups + + +class TimeSlotSerializer(serializers.ModelSerializer): + class Meta: + model = TimeSlot + fields = ['id', 'day_of_week', 'start_time', 'end_time'] + read_only_fields = ['id'] + +class SuggestionSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'max_members', 'meeting_url', 'private'] + + + \ No newline at end of file diff --git a/home/services.py b/home/services.py new file mode 100644 index 0000000..5fb4634 --- /dev/null +++ b/home/services.py @@ -0,0 +1,104 @@ +from django.db.models import QuerySet + +from rest_framework.exceptions import ValidationError + +from .models import * +from groups.models import * + +class TimeSlotService: + @staticmethod + def validate_time_range(start_time: float, end_time: float) -> None: + if not (0 <= start_time <= 24 and 0 <= end_time <= 24): + raise ValidationError('Time must be between 0 and 24') + + if end_time <= start_time: + raise ValidationError('End time must be after start time') + + @staticmethod + def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) -> None: + + overlapping = TimeSlot.objects.filter( + user=user, + day_of_week=day_of_week.lower(), + start_time__lt=end_time, + end_time__gt=start_time + ) + + if overlapping.exists(): + raise ValidationError('This time slot overlaps with an existing slot') + + @classmethod + def create_time_slot(cls, user, day_of_week: str, start_time: float, end_time: float) -> TimeSlot: + + cls.validate_time_range(start_time, end_time) + cls.validate_overlap(user, day_of_week, start_time, end_time) + + time_slot = TimeSlot.objects.create( + user=user, + day_of_week=day_of_week.lower(), + start_time=start_time, + end_time=end_time + ) + + return time_slot + + @classmethod + def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> TimeSlot: + + cls.validate_time_range(start_time, end_time) + cls.validate_overlap(group, day_of_week, start_time, end_time) + + time_slot = GroupTimeSlot.objects.create( + group=group, + day_of_week=day_of_week.lower(), + start_time=start_time, + end_time=end_time + ) + + return time_slot + + def get_user_time_slots(user) -> QuerySet[TimeSlot]: + return TimeSlot.objects.filter(user=user) + + def get_group_time_slots(group) -> QuerySet[TimeSlot]: + return GroupTimeSlot.objects.filter(group=group) + + @staticmethod + def delete_time_slot(user, time_slot_id: int) -> None: + time_slot = TimeSlot.objects.filter( + id=time_slot_id, + user=user + ).first() + + if not time_slot: + raise ValidationError('Time slot not found') + + time_slot.delete() + +class SuggestionService: + + def get_suggestions(user) -> list[Group]: + user_time_slots = TimeSlotService.get_user_time_slots(user) + groups = Group.objects.exclude(members=user) + group_matches = [] + + for group in groups: + group_time_slots = TimeSlotService.get_group_time_slots(group) + total_overlap = 0 + + for user_slot in user_time_slots: + for group_slot in group_time_slots: + if user_slot.day_of_week == group_slot.day_of_week: + overlap = min(user_slot.end_time, group_slot.end_time) - max(user_slot.start_time, group_slot.start_time) + if overlap > 0: + total_overlap += overlap + + if total_overlap > 0: + group_matches.append((group, total_overlap)) + + group_matches.sort(key=lambda x: x[1], reverse=True) + + return group_matches + + + \ No newline at end of file diff --git a/home/tests.py b/home/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/home/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/home/urls.py b/home/urls.py new file mode 100644 index 0000000..a3e271a --- /dev/null +++ b/home/urls.py @@ -0,0 +1,15 @@ +from django.urls import include, path +from .views import * + +urlpatterns = [ + path('', HomeView.as_view()), + path('suggestions/', SuggestionsView.as_view()), + + path('time-slot/create/', TimeSlotCreateView.as_view()), + path('time-slots/', TimeSlotListView.as_view()), + path('time-slots//delete/', TimeSlotDeleteView.as_view()), + + path('group-time-slot/create/', GroupTimeSlotCreateView.as_view()), + path('group-time-slots/', GroupTimeSlotListView.as_view()), + path('group-time-slots//delete/', GroupTimeSlotDeleteView.as_view()), +] diff --git a/home/views.py b/home/views.py new file mode 100644 index 0000000..9bb6383 --- /dev/null +++ b/home/views.py @@ -0,0 +1,103 @@ +from rest_framework.views import APIView +from rest_framework import status +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from .serializers import * +from .services import * +from groups.models import* +from .permissions import * + +class HomeView(APIView): + serializer_class = HomeSerializer + + def get(self, request): + if request.user.is_authenticated: + serializer = self.serializer_class(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) + else: + return Response("landing page", status=status.HTTP_200_OK) + +class TimeSlotCreateView(APIView): + permission_classes = [IsAuthenticated] + + serializer_class = TimeSlotSerializer + + def post(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + time_slot = TimeSlotService.create_time_slot( + user=request.user, + **serializer.validated_data + ) + return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) + except ValidationError as e: + return Response(str(e), status=status.HTTP_400_BAD_REQUEST) + +class TimeSlotListView(APIView): + permission_classes = [IsAuthenticated] + + serializer_class = TimeSlotSerializer + + def get(self, request): + time_slots = TimeSlotService.get_user_time_slots(request.user) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class TimeSlotDeleteView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request, *args, **kwargs): + TimeSlotService.delete_time_slot(request.user, kwargs.get('id')) + return Response("time slot deleted successfully", status=status.HTTP_200_OK) + + + +class GroupTimeSlotCreateView(APIView): + permission_classes = [IsAuthenticated, IsGroupOwner] + + serializer_class = TimeSlotSerializer + + def post(self, request, *args, **kwargs): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + time_slot = TimeSlotService.create_group_time_slot( + group=Group.objects.get(title=kwargs.get('title')), + **serializer.validated_data + ) + return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) + except ValidationError as e: + return Response(str(e), status=status.HTTP_400_BAD_REQUEST) + + +class GroupTimeSlotListView(APIView): + permission_classes = [IsAuthenticated] + + serializer_class = TimeSlotSerializer + + def get(self, request): + time_slots = TimeSlotService.get_group_time_slots(request.user) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + + +class GroupTimeSlotDeleteView(APIView): + permission_classes = [IsAuthenticated, IsGroupOwner] + + def delete(self, request, *args, **kwargs): + TimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) + return Response("time slot deleted successfully", status=status.HTTP_200_OK) + + +class SuggestionsView(APIView): + permission_classes = [IsAuthenticated] + + serializer_class = SuggestionSerializer + + def get(self, requst): + suggestions = SuggestionService.get_suggestions(requst.user) + serializer = self.serializer_class(suggestions, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file From 3615aeffa8c85833c9d39dfa437f0eb322b0ff25 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Tue, 19 Nov 2024 02:54:38 +0330 Subject: [PATCH 24/60] Dev/profile pic-cloud storage * feat: profile picture added, liara cloud storage added --------- Co-authored-by: Kian --- FD/settings.py | 21 +++++++++++++++++++ authentication/migrations/0001_initial.py | 2 +- .../migrations/0002_user_profile_image.py | 18 ++++++++++++++++ authentication/models.py | 1 + authentication/serializers.py | 13 ++++++------ authentication/utils.py | 4 ++-- authentication/views.py | 4 ++++ groups/migrations/0001_initial.py | 2 +- home/migrations/0001_initial.py | 13 +++++++++++- home/urls.py | 2 ++ home/views.py | 17 ++++++++++++++- requirements.txt | 9 ++++++++ 12 files changed, 94 insertions(+), 12 deletions(-) create mode 100644 authentication/migrations/0002_user_profile_image.py diff --git a/FD/settings.py b/FD/settings.py index ca350e0..1acb091 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -26,6 +26,7 @@ 'rest_framework_simplejwt.token_blacklist', 'corsheaders', + 'storages', 'authentication', 'groups', @@ -131,4 +132,24 @@ 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, "AUTH_HEADER_TYPES": ("Bearer",), +} + +LIARA_ENDPOINT = os.getenv("LIARA_ENDPOINT") +LIARA_BUCKET_NAME = os.getenv("LIARA_BUCKET_NAME") +LIARA_ACCESS_KEY = os.getenv("LIARA_ACCESS_KEY") +LIARA_SECRET_KEY = os.getenv("LIARA_SECRET_KEY") + +AWS_ACCESS_KEY_ID = LIARA_ACCESS_KEY +AWS_SECRET_ACCESS_KEY = LIARA_SECRET_KEY +AWS_STORAGE_BUCKET_NAME = LIARA_BUCKET_NAME +AWS_S3_ENDPOINT_URL = LIARA_ENDPOINT +AWS_S3_REGION_NAME = 'us-east-1' + +STORAGES = { + "default": { + "BACKEND": "storages.backends.s3.S3Storage", + }, + "staticfiles": { + "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", + }, } \ No newline at end of file diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py index 78cb461..7d326f3 100644 --- a/authentication/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-17 10:52 +# Generated by Django 5.1.2 on 2024-11-18 18:24 import django.db.models.deletion from django.db import migrations, models diff --git a/authentication/migrations/0002_user_profile_image.py b/authentication/migrations/0002_user_profile_image.py new file mode 100644 index 0000000..eff884f --- /dev/null +++ b/authentication/migrations/0002_user_profile_image.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-11-18 22:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='user', + name='profile_image', + field=models.ImageField(blank=True, null=True, upload_to='profile_images/'), + ), + ] diff --git a/authentication/models.py b/authentication/models.py index ca76b56..6dfd775 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -9,6 +9,7 @@ class User(AbstractBaseUser, PermissionsMixin): email = models.EmailField(max_length=255, unique=True, verbose_name=_('Email Address')) username = models.CharField(max_length=255, unique=True, verbose_name=_('Username')) + profile_image = models.ImageField(upload_to='profile_images/', null=True, blank=True) password = models.CharField(max_length=255, verbose_name=_('Password')) is_staff = models.BooleanField(default=False) is_superuser = models.BooleanField(default=False) diff --git a/authentication/serializers.py b/authentication/serializers.py index 742b538..0d1f659 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -184,7 +184,7 @@ def validate(self, attrs): class UserRetriveSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['username', 'email', 'level', 'date_joined', 'city', 'neighborhood'] + fields = ['username', 'email', 'level', 'date_joined', 'city', 'neighborhood', 'profile_image'] class UserDeleteSerializer(serializers.ModelSerializer): @@ -197,12 +197,13 @@ class Meta: class UserUpdateSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['level', 'city', 'neighborhood'] + fields = ['level', 'city', 'neighborhood', 'profile_image'] def update(self, instance, validated_data): - for key, value in validated_data.items(): - if value is not None: - setattr(instance, key, value) + instance.level = validated_data.get('level', instance.level) + instance.city = validated_data.get('city', instance.city) + instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) + profile_image = validated_data.get('profile_image', instance.profile_image) instance.save() - return validated_data + return instance \ No newline at end of file diff --git a/authentication/utils.py b/authentication/utils.py index 9c05583..be6ffaa 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -1,4 +1,5 @@ from email.message import EmailMessage +import random import smtplib import secrets from .models import * @@ -7,8 +8,7 @@ def send_otp_email(email): from_email = settings.EMAIL_HOST_USER password = settings.EMAIL_HOST_PASSWORD - otp = secrets.randbelow(10**6) - otp = str(otp).zfill(6) + otp = random.randint(100000, 999999) temp_user = TempUser.objects.get(email=email) OneTimePassword.objects.create(temp_user=temp_user, otp=otp) diff --git a/authentication/views.py b/authentication/views.py index 983090c..7a4c570 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -1,7 +1,10 @@ from django.shortcuts import render + from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status +from rest_framework.parsers import MultiPartParser, FormParser + from authentication.utils import send_otp_email from .serializers import * @@ -119,6 +122,7 @@ def post(self, request, *args, **kwargs): class UserUpdateView(APIView): serializer_class= UserUpdateSerializer + parser_classes = [MultiPartParser, FormParser] def patch(self, request, *args, **kwargs): try: diff --git a/groups/migrations/0001_initial.py b/groups/migrations/0001_initial.py index 6cc8278..7b199b5 100644 --- a/groups/migrations/0001_initial.py +++ b/groups/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-17 10:52 +# Generated by Django 5.1.2 on 2024-11-18 18:24 import django.db.models.deletion from django.conf import settings diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py index 6631972..33d5467 100644 --- a/home/migrations/0001_initial.py +++ b/home/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-17 10:52 +# Generated by Django 5.1.2 on 2024-11-18 18:25 import django.db.models.deletion from django.conf import settings @@ -10,10 +10,21 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ('groups', '0001_initial'), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ + migrations.CreateModel( + name='GroupTimeSlot', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('day_of_week', models.CharField(max_length=255)), + ('start_time', models.FloatField()), + ('end_time', models.FloatField()), + ('group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='time_slots', to='groups.group')), + ], + ), migrations.CreateModel( name='TimeSlot', fields=[ diff --git a/home/urls.py b/home/urls.py index a3e271a..604bc4e 100644 --- a/home/urls.py +++ b/home/urls.py @@ -12,4 +12,6 @@ path('group-time-slot/create/', GroupTimeSlotCreateView.as_view()), path('group-time-slots/', GroupTimeSlotListView.as_view()), path('group-time-slots//delete/', GroupTimeSlotDeleteView.as_view()), + + path('neighborhoods/', get_table_data, name='table_data'), ] diff --git a/home/views.py b/home/views.py index 9bb6383..c1a5048 100644 --- a/home/views.py +++ b/home/views.py @@ -100,4 +100,19 @@ class SuggestionsView(APIView): def get(self, requst): suggestions = SuggestionService.get_suggestions(requst.user) serializer = self.serializer_class(suggestions, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) \ No newline at end of file + return Response(serializer.data, status=status.HTTP_200_OK) + + + + +from django.db import connection +from django.http import JsonResponse + +def get_table_data(request): + with connection.cursor() as cursor: + cursor.execute("SELECT * FROM neighborhood") + columns = [col[0] for col in cursor.description] + rows = cursor.fetchall() + + data = [dict(zip(columns, row)) for row in rows] + return JsonResponse(data, safe=False) diff --git a/requirements.txt b/requirements.txt index a89b586..da4a3a7 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,20 +1,29 @@ asgiref==3.8.1 attrs==24.2.0 +boto3==1.35.64 +botocore==1.35.64 Django==5.1.2 django-cors-headers==4.6.0 +django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 inflection==0.5.1 +jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 mysql-connector-python==9.1.0 mysqlclient==2.2.5 +pillow==11.0.0 PyJWT==2.9.0 +python-dateutil==2.9.0.post0 python-dotenv==1.0.1 PyYAML==6.0.2 referencing==0.35.1 rpds-py==0.20.1 +s3transfer==0.10.3 +six==1.16.0 sqlparse==0.5.1 tzdata==2024.2 uritemplate==4.1.1 +urllib3==2.2.3 From aa6a38bafebec0f59cefa22e1174d74c410d4916 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 21 Nov 2024 13:00:28 +0330 Subject: [PATCH 25/60] fix: user retrieve and update modified --- FD/urls.py | 2 +- authentication/urls.py | 6 +++--- authentication/views.py | 35 +++++++++++++++++++---------------- home/urls.py | 8 ++++---- 4 files changed, 27 insertions(+), 24 deletions(-) diff --git a/FD/urls.py b/FD/urls.py index 5331e0b..ac0c841 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -7,7 +7,7 @@ path('authentication/', include('authentication.urls')), path('groups/', include('groups.urls')), - path('', include('home.urls')), + path('scheduling/', include('home.urls')), path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/authentication/urls.py b/authentication/urls.py index 1094f95..b3d6480 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -15,9 +15,9 @@ path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('users//', UserRetriveView.as_view()), - path('users//delete/', UserDeleteView.as_view()), - path('users//update/', UserUpdateView.as_view()), + path('users/retrieve/', UserRetriveView.as_view()), + path('users/delete/', UserDeleteView.as_view()), + path('users/update/', UserUpdateView.as_view()), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), diff --git a/authentication/views.py b/authentication/views.py index 7a4c570..a16aecd 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -94,7 +94,9 @@ class UserRetriveView(APIView): serializer_class= UserRetriveSerializer def get(self, request, *args, **kwargs): - username = kwargs.get('username') + if (not request.user.is_authenticated): + return Response("user not authenticated", status=status.HTTP_401_UNAUTHORIZED) + username = request.user.username user = User.objects.get(username=username) serializer = self.serializer_class(user) return Response(serializer.data, status=status.HTTP_200_OK) @@ -104,20 +106,21 @@ class UserDeleteView(APIView): serializer_class= UserDeleteSerializer def post(self, request, *args, **kwargs): - try: - username = kwargs.get('username') - user = User.objects.get(username=username) - serializer = self.serializer_class(data = request.data) - - serializer.is_valid(raise_exception=True) - - if user.check_password(serializer.validated_data.get('password')): - return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) - - user.delete() - return Response("user deleted successfully", status=status.HTTP_200_OK) - except User.DoesNotExist: - return Response("user not found", status=status.HTTP_404_NOT_FOUND) + if (not request.user.is_authenticated): + return Response("user not authenticated", status=status.HTTP_401_UNAUTHORIZED) + + username = request.user.username + user = User.objects.get(username=username) + serializer = self.serializer_class(data = request.data) + + serializer.is_valid(raise_exception=True) + + if user.check_password(serializer.validated_data.get('password')): + return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) + + user.delete() + return Response("user deleted successfully", status=status.HTTP_200_OK) + class UserUpdateView(APIView): @@ -126,7 +129,7 @@ class UserUpdateView(APIView): def patch(self, request, *args, **kwargs): try: - username = kwargs.get('username') + username = request.user.username user = User.objects.get(username=username) serializer = self.serializer_class(user, data = request.data, partial=True) diff --git a/home/urls.py b/home/urls.py index 604bc4e..eeaad3f 100644 --- a/home/urls.py +++ b/home/urls.py @@ -5,11 +5,11 @@ path('', HomeView.as_view()), path('suggestions/', SuggestionsView.as_view()), - path('time-slot/create/', TimeSlotCreateView.as_view()), - path('time-slots/', TimeSlotListView.as_view()), - path('time-slots//delete/', TimeSlotDeleteView.as_view()), + path('user-time-slots/create/', TimeSlotCreateView.as_view()), + path('user-time-slots/', TimeSlotListView.as_view()), + path('user-time-slots//delete/', TimeSlotDeleteView.as_view()), - path('group-time-slot/create/', GroupTimeSlotCreateView.as_view()), + path('group-time-slots/create/', GroupTimeSlotCreateView.as_view()), path('group-time-slots/', GroupTimeSlotListView.as_view()), path('group-time-slots//delete/', GroupTimeSlotDeleteView.as_view()), From d24f6c534c039395a31fb466cfe7f99b63550268 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 25 Nov 2024 23:12:06 +0330 Subject: [PATCH 26/60] Fix/general for sprint 2 * fix: user timeslot and group timeslot fully separated * fix: reset password modified to send frontend url --- FD/settings.py | 4 +- authentication/serializers.py | 9 ++--- authentication/views.py | 6 +-- home/models.py | 2 +- home/serializers.py | 9 ++++- home/services.py | 74 ++++++++++++++++++++++++++--------- home/urls.py | 12 +++--- home/views.py | 26 ++++++------ 8 files changed, 91 insertions(+), 51 deletions(-) diff --git a/FD/settings.py b/FD/settings.py index 1acb091..85b9f3f 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -127,8 +127,8 @@ } SIMPLE_JWT = { - "ACCESS_TOKEN_LIFETIME": timedelta(days=1), - "REFRESH_TOKEN_LIFETIME": timedelta(days=1), + "ACCESS_TOKEN_LIFETIME": timedelta(days=30), + "REFRESH_TOKEN_LIFETIME": timedelta(days=30), 'ROTATE_REFRESH_TOKENS': True, 'BLACKLIST_AFTER_ROTATION': True, "AUTH_HEADER_TYPES": ("Bearer",), diff --git a/authentication/serializers.py b/authentication/serializers.py index 0d1f659..f60fe5a 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -140,10 +140,9 @@ def validate(self, attrs): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) token = PasswordResetTokenGenerator().make_token(user) - request = self.context.get('request') - site_url = get_current_site(request).domain - relative_link = reverse('password_reset_confirm', kwargs={'uidb64': uidb64, 'token': token}) - absolute_url = f'http://{site_url}{relative_link}' + # relative_link = reverse('reset-password', kwargs={'uidb64': uidb64, 'token': token}) + # absolute_url = f'http://localhost:5173/{relative_link}' + absolute_url = f'https://localhost:5173/reset-password/{uidb64}/{token}' email_subject = 'Reset your password' email_body = f'Click the link below to reset your password\n{absolute_url}' @@ -154,7 +153,7 @@ def validate(self, attrs): return {'email': user.email} except User.DoesNotExist: - raise AuthenticationFailed('Invalid credentials, try again') + raise AuthenticationFailed('user not found') class PasswordResetConfirmSerializer(serializers.ModelSerializer): diff --git a/authentication/views.py b/authentication/views.py index a16aecd..7a95c12 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -3,7 +3,7 @@ from rest_framework.views import APIView from rest_framework.response import Response from rest_framework import status -from rest_framework.parsers import MultiPartParser, FormParser +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from authentication.utils import send_otp_email @@ -105,7 +105,7 @@ def get(self, request, *args, **kwargs): class UserDeleteView(APIView): serializer_class= UserDeleteSerializer - def post(self, request, *args, **kwargs): + def delete(self, request, *args, **kwargs): if (not request.user.is_authenticated): return Response("user not authenticated", status=status.HTTP_401_UNAUTHORIZED) @@ -125,7 +125,7 @@ def post(self, request, *args, **kwargs): class UserUpdateView(APIView): serializer_class= UserUpdateSerializer - parser_classes = [MultiPartParser, FormParser] + parser_classes = [MultiPartParser, FormParser, JSONParser] def patch(self, request, *args, **kwargs): try: diff --git a/home/models.py b/home/models.py index 35a8724..94d3f33 100644 --- a/home/models.py +++ b/home/models.py @@ -2,7 +2,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey from django.contrib.contenttypes.models import ContentType -class TimeSlot(models.Model): +class UserTimeSlot(models.Model): day_of_week = models.CharField(max_length=255) start_time = models.FloatField() end_time = models.FloatField() diff --git a/home/serializers.py b/home/serializers.py index 74df0c2..832e0e2 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -21,12 +21,17 @@ def get_pending_groups(self, obj): return pending_groups -class TimeSlotSerializer(serializers.ModelSerializer): +class UserTimeSlotSerializer(serializers.ModelSerializer): class Meta: - model = TimeSlot + model = UserTimeSlot fields = ['id', 'day_of_week', 'start_time', 'end_time'] read_only_fields = ['id'] +class GroupTimeSlotSerializer(serializers.ModelSerializer): + class Meta: + model = GroupTimeSlot + fields = ['id', 'day_of_week', 'start_time', 'end_time'] + class SuggestionSerializer(serializers.ModelSerializer): class Meta: model = Group diff --git a/home/services.py b/home/services.py index 5fb4634..34c9b32 100644 --- a/home/services.py +++ b/home/services.py @@ -5,7 +5,7 @@ from .models import * from groups.models import * -class TimeSlotService: +class UserTimeSlotService: @staticmethod def validate_time_range(start_time: float, end_time: float) -> None: if not (0 <= start_time <= 24 and 0 <= end_time <= 24): @@ -17,7 +17,7 @@ def validate_time_range(start_time: float, end_time: float) -> None: @staticmethod def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) -> None: - overlapping = TimeSlot.objects.filter( + overlapping = UserTimeSlot.objects.filter( user=user, day_of_week=day_of_week.lower(), start_time__lt=end_time, @@ -28,12 +28,12 @@ def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) raise ValidationError('This time slot overlaps with an existing slot') @classmethod - def create_time_slot(cls, user, day_of_week: str, start_time: float, end_time: float) -> TimeSlot: + def create_time_slot(cls, user, day_of_week: str, start_time: float, end_time: float) -> UserTimeSlot: cls.validate_time_range(start_time, end_time) cls.validate_overlap(user, day_of_week, start_time, end_time) - time_slot = TimeSlot.objects.create( + time_slot = UserTimeSlot.objects.create( user=user, day_of_week=day_of_week.lower(), start_time=start_time, @@ -42,8 +42,47 @@ def create_time_slot(cls, user, day_of_week: str, start_time: float, end_time: f return time_slot + def get_user_time_slots(user) -> QuerySet[UserTimeSlot]: + return UserTimeSlot.objects.filter(user=user) + + @staticmethod + def delete_time_slot(user, time_slot_id: int) -> None: + time_slot = UserTimeSlot.objects.filter( + id=time_slot_id, + user=user + ).first() + + if not time_slot: + raise ValidationError('Time slot not found') + + time_slot.delete() + + + +class GroupTimeSlotService: + @staticmethod + def validate_time_range(start_time: float, end_time: float) -> None: + if not (0 <= start_time <= 24 and 0 <= end_time <= 24): + raise ValidationError('Time must be between 0 and 24') + + if end_time <= start_time: + raise ValidationError('End time must be after start time') + + @staticmethod + def validate_overlap(group, day_of_week: str, start_time: float, end_time: float) -> None: + + overlapping = GroupTimeSlot.objects.filter( + group=group, + day_of_week=day_of_week.lower(), + start_time__lt=end_time, + end_time__gt=start_time + ) + + if overlapping.exists(): + raise ValidationError('This time slot overlaps with an existing slot') + @classmethod - def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> TimeSlot: + def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: cls.validate_time_range(start_time, end_time) cls.validate_overlap(group, day_of_week, start_time, end_time) @@ -56,16 +95,13 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ ) return time_slot - - def get_user_time_slots(user) -> QuerySet[TimeSlot]: - return TimeSlot.objects.filter(user=user) - - def get_group_time_slots(group) -> QuerySet[TimeSlot]: + + def get_group_time_slots(group) -> QuerySet[GroupTimeSlot]: return GroupTimeSlot.objects.filter(group=group) - + @staticmethod def delete_time_slot(user, time_slot_id: int) -> None: - time_slot = TimeSlot.objects.filter( + time_slot = GroupTimeSlot.objects.filter( id=time_slot_id, user=user ).first() @@ -74,16 +110,19 @@ def delete_time_slot(user, time_slot_id: int) -> None: raise ValidationError('Time slot not found') time_slot.delete() - + + + + class SuggestionService: def get_suggestions(user) -> list[Group]: - user_time_slots = TimeSlotService.get_user_time_slots(user) + user_time_slots = UserTimeSlotService.get_user_time_slots(user) groups = Group.objects.exclude(members=user) group_matches = [] for group in groups: - group_time_slots = TimeSlotService.get_group_time_slots(group) + group_time_slots = GroupTimeSlotService.get_group_time_slots(group) total_overlap = 0 for user_slot in user_time_slots: @@ -98,7 +137,4 @@ def get_suggestions(user) -> list[Group]: group_matches.sort(key=lambda x: x[1], reverse=True) - return group_matches - - - \ No newline at end of file + return group_matches \ No newline at end of file diff --git a/home/urls.py b/home/urls.py index eeaad3f..4712b77 100644 --- a/home/urls.py +++ b/home/urls.py @@ -5,13 +5,13 @@ path('', HomeView.as_view()), path('suggestions/', SuggestionsView.as_view()), - path('user-time-slots/create/', TimeSlotCreateView.as_view()), - path('user-time-slots/', TimeSlotListView.as_view()), - path('user-time-slots//delete/', TimeSlotDeleteView.as_view()), + path('user-time-slots/create/', UserTimeSlotCreateView.as_view()), + path('user-time-slots/', UserTimeSlotListView.as_view()), + path('user-time-slots//delete/', UserTimeSlotDeleteView.as_view()), - path('group-time-slots/create/', GroupTimeSlotCreateView.as_view()), - path('group-time-slots/', GroupTimeSlotListView.as_view()), - path('group-time-slots//delete/', GroupTimeSlotDeleteView.as_view()), + path('group-time-slots//create/', GroupTimeSlotCreateView.as_view()), + path('group-time-slots//', GroupTimeSlotListView.as_view()), + path('group-time-slots///delete/', GroupTimeSlotDeleteView.as_view()), path('neighborhoods/', get_table_data, name='table_data'), ] diff --git a/home/views.py b/home/views.py index c1a5048..1700b55 100644 --- a/home/views.py +++ b/home/views.py @@ -18,16 +18,16 @@ def get(self, request): else: return Response("landing page", status=status.HTTP_200_OK) -class TimeSlotCreateView(APIView): +class UserTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated] - serializer_class = TimeSlotSerializer + serializer_class = UserTimeSlotSerializer def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - time_slot = TimeSlotService.create_time_slot( + time_slot = UserTimeSlotService.create_time_slot( user=request.user, **serializer.validated_data ) @@ -35,22 +35,22 @@ def post(self, request): except ValidationError as e: return Response(str(e), status=status.HTTP_400_BAD_REQUEST) -class TimeSlotListView(APIView): +class UserTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = TimeSlotSerializer + serializer_class = UserTimeSlotSerializer def get(self, request): - time_slots = TimeSlotService.get_user_time_slots(request.user) + time_slots = UserTimeSlotService.get_user_time_slots(request.user) serializer = self.serializer_class(time_slots, many=True) return Response(serializer.data, status=status.HTTP_200_OK) -class TimeSlotDeleteView(APIView): +class UserTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated] def delete(self, request, *args, **kwargs): - TimeSlotService.delete_time_slot(request.user, kwargs.get('id')) + UserTimeSlotService.delete_time_slot(request.user, kwargs.get('id')) return Response("time slot deleted successfully", status=status.HTTP_200_OK) @@ -58,13 +58,13 @@ def delete(self, request, *args, **kwargs): class GroupTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] - serializer_class = TimeSlotSerializer + serializer_class = GroupTimeSlotSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - time_slot = TimeSlotService.create_group_time_slot( + time_slot = GroupTimeSlotService.create_group_time_slot( group=Group.objects.get(title=kwargs.get('title')), **serializer.validated_data ) @@ -76,10 +76,10 @@ def post(self, request, *args, **kwargs): class GroupTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = TimeSlotSerializer + serializer_class = GroupTimeSlotSerializer def get(self, request): - time_slots = TimeSlotService.get_group_time_slots(request.user) + time_slots = GroupTimeSlotService.get_group_time_slots(request.user) serializer = self.serializer_class(time_slots, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -88,7 +88,7 @@ class GroupTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] def delete(self, request, *args, **kwargs): - TimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) + GroupTimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) return Response("time slot deleted successfully", status=status.HTTP_200_OK) From 74501d4ab181af171af21acf2d1b4fcd3c2a96de Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 26 Nov 2024 01:44:56 +0330 Subject: [PATCH 27/60] feat: add group pic (#16) --- FD/settings.py | 2 +- authentication/migrations/0001_initial.py | 5 ++--- .../migrations/0002_tempuser_date_joined.py | 20 ------------------- .../migrations/0002_user_profile_image.py | 18 ----------------- authentication/serializers.py | 5 +---- authentication/urls.py | 1 - authentication/views.py | 14 ------------- groups/migrations/0001_initial.py | 3 ++- groups/models.py | 1 + groups/serializers.py | 5 +++-- groups/views.py | 4 +++- home/migrations/0001_initial.py | 4 ++-- 12 files changed, 15 insertions(+), 67 deletions(-) delete mode 100644 authentication/migrations/0002_tempuser_date_joined.py delete mode 100644 authentication/migrations/0002_user_profile_image.py diff --git a/FD/settings.py b/FD/settings.py index ed55eb9..2cbac32 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -23,7 +23,7 @@ 'rest_framework', 'drf_spectacular', - 'rest_framework_simplejwt.token_blacklist', + # 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'storages', diff --git a/authentication/migrations/0001_initial.py b/authentication/migrations/0001_initial.py index de95127..18788f8 100644 --- a/authentication/migrations/0001_initial.py +++ b/authentication/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-18 18:24 +# Generated by Django 5.1.2 on 2024-11-25 21:52 import django.db.models.deletion from django.db import migrations, models @@ -21,7 +21,6 @@ class Migration(migrations.Migration): ('email', models.EmailField(max_length=255)), ('password', models.CharField(max_length=255)), ('date_joined', models.DateTimeField(auto_now_add=True)), - ], ), migrations.CreateModel( @@ -30,6 +29,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('email', models.EmailField(max_length=255, unique=True, verbose_name='Email Address')), ('username', models.CharField(max_length=255, unique=True, verbose_name='Username')), + ('profile_image', models.ImageField(blank=True, null=True, upload_to='profile_images/')), ('password', models.CharField(max_length=255, verbose_name='Password')), ('is_staff', models.BooleanField(default=False)), ('is_superuser', models.BooleanField(default=False)), @@ -38,7 +38,6 @@ class Migration(migrations.Migration): ('level', models.CharField(default='A1', max_length=2)), ('city', models.CharField(blank=True, max_length=255, null=True)), ('neighborhood', models.CharField(blank=True, max_length=255, null=True)), - ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')), ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')), ], diff --git a/authentication/migrations/0002_tempuser_date_joined.py b/authentication/migrations/0002_tempuser_date_joined.py deleted file mode 100644 index c3f01f7..0000000 --- a/authentication/migrations/0002_tempuser_date_joined.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-10 08:23 - -import django.utils.timezone -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='tempuser', - name='date_joined', - field=models.DateTimeField(auto_now_add=True, default=django.utils.timezone.now), - preserve_default=False, - ), - ] diff --git a/authentication/migrations/0002_user_profile_image.py b/authentication/migrations/0002_user_profile_image.py deleted file mode 100644 index eff884f..0000000 --- a/authentication/migrations/0002_user_profile_image.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-18 22:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='profile_image', - field=models.ImageField(blank=True, null=True, upload_to='profile_images/'), - ), - ] diff --git a/authentication/serializers.py b/authentication/serializers.py index 52f7558..e31bca2 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -121,9 +121,6 @@ def validate(self, attrs): 'refresh_token': str(tokens['refresh']), } -class UserLogoutSerializer(serializers.Serializer): - refresh_token = serializers.CharField() - class PasswordResetRequestSerializer(serializers.ModelSerializer): email = serializers.EmailField() class Meta: @@ -201,7 +198,7 @@ def update(self, instance, validated_data): instance.level = validated_data.get('level', instance.level) instance.city = validated_data.get('city', instance.city) instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) - profile_image = validated_data.get('profile_image', instance.profile_image) + instance.profile_image = validated_data.get('profile_image', instance.profile_image) instance.save() return instance diff --git a/authentication/urls.py b/authentication/urls.py index 9dd842c..df242bd 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -10,7 +10,6 @@ path('verify-email/', VerifyEmailView.as_view()), path('login/', LoginUserView.as_view()), - path('logout/', LogoutUserView.as_view()), path('password-reset/', PasswordResetRequestView.as_view()), diff --git a/authentication/views.py b/authentication/views.py index 8aeb6cd..b7ebc48 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -75,20 +75,6 @@ def post(self, request, uidb64, token): return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) -class LogoutUserView(APIView): - serializer_class = UserLogoutSerializer - - def post(self, request): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) - - refresh_token = serializer.validated_data.get('refresh_token') - try: - token = RefreshToken(refresh_token) - token.blacklist() - return Response("logout successfully", status=status.HTTP_200_OK) - except: - return Response("invalid token", status=status.HTTP_400_BAD_REQUEST) class UserRetriveView(APIView): serializer_class= UserRetriveSerializer diff --git a/groups/migrations/0001_initial.py b/groups/migrations/0001_initial.py index 7b199b5..db63ff2 100644 --- a/groups/migrations/0001_initial.py +++ b/groups/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-18 18:24 +# Generated by Django 5.1.2 on 2024-11-25 21:53 import django.db.models.deletion from django.conf import settings @@ -20,6 +20,7 @@ class Migration(migrations.Migration): ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('title', models.CharField(max_length=255)), ('description', models.TextField(blank=True, null=True)), + ('image', models.ImageField(blank=True, null=True, upload_to='group_images/')), ('level', models.CharField(max_length=255)), ('city', models.CharField(blank=True, max_length=255, null=True)), ('neighborhood', models.CharField(blank=True, max_length=255, null=True)), diff --git a/groups/models.py b/groups/models.py index 7367048..e466e98 100644 --- a/groups/models.py +++ b/groups/models.py @@ -3,6 +3,7 @@ class Group(models.Model): title = models.CharField(max_length=255) description = models.TextField(blank=True, null=True) + image = models.ImageField(upload_to='group_images/', null=True, blank=True) level = models.CharField(max_length=255) city = models.CharField(max_length=255, blank=True, null=True) neighborhood = models.CharField(max_length=255, blank=True, null=True) diff --git a/groups/serializers.py b/groups/serializers.py index 1a4963a..1b31ce4 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -39,7 +39,7 @@ class GroupRetrieveSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', + fields = ['id', 'title', 'description', 'image', 'level', 'city', 'neighborhood', 'created_at', 'max_members', 'meeting_url', 'private', 'owner_username', 'member_usernames'] @@ -53,7 +53,7 @@ def get_member_usernames(self, obj): class GroupUpdateSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['title', 'description', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] + fields = ['title', 'description', 'image', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] def validate(self, data): if 'level' in data and data['level'] not in VALID_LEVELS: @@ -69,6 +69,7 @@ def validate(self, data): def update(self, instance, validated_data): instance.title = validated_data.get('title', instance.title) instance.description = validated_data.get('description', instance.description) + instance.image = validated_data.get('image', instance.image) instance.level = validated_data.get('level', instance.level) instance.city = validated_data.get('city', instance.city) instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) diff --git a/groups/views.py b/groups/views.py index c14f3a6..2140384 100644 --- a/groups/views.py +++ b/groups/views.py @@ -2,9 +2,10 @@ from rest_framework.response import Response from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from authentication.models import User +from authentication.models import User from .serializers import * from .models import * from .permissions import * @@ -38,6 +39,7 @@ def get(self, request, *args, **kwargs): class GroupUpdateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] + parser_classes = [MultiPartParser, FormParser, JSONParser] serializer_class = GroupUpdateSerializer diff --git a/home/migrations/0001_initial.py b/home/migrations/0001_initial.py index 33d5467..18a6540 100644 --- a/home/migrations/0001_initial.py +++ b/home/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 5.1.2 on 2024-11-18 18:25 +# Generated by Django 5.1.2 on 2024-11-25 21:53 import django.db.models.deletion from django.conf import settings @@ -26,7 +26,7 @@ class Migration(migrations.Migration): ], ), migrations.CreateModel( - name='TimeSlot', + name='UserTimeSlot', fields=[ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), ('day_of_week', models.CharField(max_length=255)), From 1efa1d8715d93fed308875d73cdcffa435b14543 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 26 Nov 2024 07:56:04 +0330 Subject: [PATCH 28/60] feat: add change password (#17) Co-authored-by: Mahziar --- authentication/serializers.py | 9 ++++++++- authentication/services.py | 14 ++++++++++++++ authentication/urls.py | 1 + authentication/views.py | 27 ++++++++++++++++++++++++++- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 authentication/services.py diff --git a/authentication/serializers.py b/authentication/serializers.py index e31bca2..3e117e5 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -201,4 +201,11 @@ def update(self, instance, validated_data): instance.profile_image = validated_data.get('profile_image', instance.profile_image) instance.save() return instance - + + +class ChangePasswordSerializer(serializers.Serializer): + old_password = serializers.CharField(min_length=8, max_length=64, write_only=True) + new_password = serializers.CharField(min_length=8, max_length=64, write_only=True) + class Meta: + model = User + fields = ['old_password', 'new_password'] diff --git a/authentication/services.py b/authentication/services.py new file mode 100644 index 0000000..b0b97e3 --- /dev/null +++ b/authentication/services.py @@ -0,0 +1,14 @@ +from rest_framework.exceptions import ValidationError +from rest_framework import status + +class UserService: + def change_password(user, old_password, new_password): + if not user.check_password(old_password): + raise ValidationError({'detail': 'Invalid password', 'status': status.HTTP_400_BAD_REQUEST}) + + if old_password == new_password: + raise ValidationError({'detail': 'New password cannot be the same as the old password', 'status': status.HTTP_400_BAD_REQUEST}) + + user.set_password(new_password) + user.save() + return user \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index df242bd..dba9a97 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -14,6 +14,7 @@ path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('change-password/', ChangePasswordView.as_view()), path('users/retrieve/', UserRetriveView.as_view()), path('users/delete/', UserDeleteView.as_view()), diff --git a/authentication/views.py b/authentication/views.py index b7ebc48..e393d47 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -8,7 +8,7 @@ from authentication.utils import send_otp_email from .serializers import * from .models import * - +from .services import * class RegisterUserView(APIView): serializer_class = UserCreateSerializer @@ -127,3 +127,28 @@ def patch(self, request, *args, **kwargs): except User.DoesNotExist: return Response("user not found", status=status.HTTP_404_NOT_FOUND) + + +class ChangePasswordView(APIView): + serializer_class= ChangePasswordSerializer + + def patch(self, request): + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + username = request.user.username + user = User.objects.get(username=username) + + old_password = serializer.validated_data.get('old_password') + new_password = serializer.validated_data.get('new_password') + + UserService.change_password(user, old_password, new_password) + + except User.DoesNotExist: + return Response("user not found", status=status.HTTP_404_NOT_FOUND) + + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + + return Response("password changed successfully", status=status.HTTP_200_OK) \ No newline at end of file From cdff37932c3a7ba13dceb90d5f0cdf41e19a9199 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 26 Nov 2024 18:22:36 +0330 Subject: [PATCH 29/60] Refactor/service layer (#18) * refactor: group * refactor: user * refactor: done --- ...002_tempuser_otp_delete_onetimepassword.py | 21 +++ .../migrations/0003_alter_tempuser_otp.py | 18 ++ authentication/models.py | 7 +- authentication/serializers.py | 153 +--------------- authentication/services.py | 144 +++++++++++++++- authentication/utils.py | 8 +- authentication/views.py | 98 +++++------ .../0002_remove_group_max_members.py | 17 ++ groups/models.py | 8 +- groups/serializers.py | 77 +-------- groups/services.py | 122 +++++++++++++ groups/views.py | 163 ++++++++---------- home/serializers.py | 2 +- home/views.py | 9 +- 14 files changed, 463 insertions(+), 384 deletions(-) create mode 100644 authentication/migrations/0002_tempuser_otp_delete_onetimepassword.py create mode 100644 authentication/migrations/0003_alter_tempuser_otp.py create mode 100644 groups/migrations/0002_remove_group_max_members.py create mode 100644 groups/services.py diff --git a/authentication/migrations/0002_tempuser_otp_delete_onetimepassword.py b/authentication/migrations/0002_tempuser_otp_delete_onetimepassword.py new file mode 100644 index 0000000..d10f0af --- /dev/null +++ b/authentication/migrations/0002_tempuser_otp_delete_onetimepassword.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2024-11-26 13:22 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0001_initial'), + ] + + operations = [ + migrations.AddField( + model_name='tempuser', + name='otp', + field=models.IntegerField(blank=True, null=True, unique=True), + ), + migrations.DeleteModel( + name='OneTimePassword', + ), + ] diff --git a/authentication/migrations/0003_alter_tempuser_otp.py b/authentication/migrations/0003_alter_tempuser_otp.py new file mode 100644 index 0000000..10936bd --- /dev/null +++ b/authentication/migrations/0003_alter_tempuser_otp.py @@ -0,0 +1,18 @@ +# Generated by Django 5.1.2 on 2024-11-26 13:33 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_tempuser_otp_delete_onetimepassword'), + ] + + operations = [ + migrations.AlterField( + model_name='tempuser', + name='otp', + field=models.CharField(blank=True, max_length=6, null=True, unique=True), + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 390e813..16c3d89 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -40,12 +40,7 @@ class TempUser(models.Model): email = models.EmailField(max_length=255) password = models.CharField(max_length=255) date_joined = models.DateTimeField(auto_now_add=True) + otp = models.CharField(max_length=6, unique=True, null=True, blank=True) def __str__(self): return self.username - - -class OneTimePassword(models.Model): - temp_user = models.ForeignKey(TempUser, on_delete=models.CASCADE) - otp = models.CharField(max_length=6, unique=True) - diff --git a/authentication/serializers.py b/authentication/serializers.py index 3e117e5..efcbe5b 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -1,125 +1,31 @@ -from django.contrib.auth.tokens import PasswordResetTokenGenerator -from django.utils.encoding import smart_str, smart_bytes -from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode -from django.contrib.sites.shortcuts import get_current_site -from django.urls import reverse -from django.core.mail import EmailMessage -from django.core.validators import MaxLengthValidator -from django.utils import timezone - - - -from rest_framework.exceptions import AuthenticationFailed from rest_framework import serializers -import datetime - -from FD import settings from .models import * class UserCreateSerializer(serializers.ModelSerializer): password = serializers.CharField(min_length=8, max_length=64, write_only=True) - class Meta: model = TempUser fields = ['username', 'email', 'password', 'date_joined'] - - def validate(self, attrs): - temp_user_ttl = timezone.now() - datetime.timedelta(minutes=1) - - username = attrs.get('username') - email = attrs.get('email') - password = attrs.get('password') - - user_username = User.objects.filter(username=username).first() - user_email = User.objects.filter(email=email).first() - - temp_user_username = TempUser.objects.filter(username=username).first() - print(temp_user_username) - temp_user_email = TempUser.objects.filter(email=email).first() - - if temp_user_username: - print(timezone.now() -temp_user_username.date_joined) - - if user_username: - raise serializers.ValidationError('username already exists') - - if user_email: - raise serializers.ValidationError('email already exists') - - if temp_user_username and temp_user_username.date_joined > temp_user_ttl: - raise serializers.ValidationError('username already exists') - - if temp_user_email and temp_user_email.date_joined > temp_user_ttl: - raise serializers.ValidationError('email already exists') - - if temp_user_username and temp_user_username.date_joined < temp_user_ttl: - temp_user_username.delete() - - if temp_user_email and temp_user_email.date_joined < temp_user_ttl: - temp_user_email.delete() - - return attrs - def create(self, validated_data): - temp_user = TempUser.objects.create(**validated_data) - return temp_user - class VerifyEmailSerializer(serializers.Serializer): otp = serializers.CharField(max_length=6) - - def validate(self, attrs): - otp = attrs.get('otp') - - try: - otp_obj = OneTimePassword.objects.get(otp=otp) - temp_user = otp_obj.temp_user - user = User.objects.create_user(username=temp_user.username, email=temp_user.email, password=temp_user.password) - temp_user.delete() - return user - - except OneTimePassword.DoesNotExist: - raise serializers.ValidationError('invalid one time password') + class Meta: + model = TempUser + fields = ['otp'] class UserLoginSerializer(serializers.ModelSerializer): username = serializers.CharField(max_length=255) password = serializers.CharField(max_length=255, write_only=True) - access_token = serializers.CharField(max_length=255, read_only=True) - refresh_token = serializers.CharField(max_length=255, read_only=True) - class Meta: model = User - fields = ['username', 'password', 'access_token', 'refresh_token'] + fields = ['username', 'password'] - def validate(self, attrs): - username = attrs.get('username') - password = attrs.get('password') - request = self.context.get('request') - try: - user = User.objects.get(username=username) - except User.DoesNotExist: - temp_user = TempUser.objects.filter(username=username).exists() - if temp_user: - raise AuthenticationFailed('please verify your email') - else: - raise AuthenticationFailed('wrong password') - - if not user.check_password(password): - raise AuthenticationFailed('Invalid password, try again') - - tokens = user.tokens() - - return { - 'email': user.email, - 'username': user.username, - 'access_token': str(tokens['access']), - 'refresh_token': str(tokens['refresh']), - } class PasswordResetRequestSerializer(serializers.ModelSerializer): email = serializers.EmailField() @@ -127,53 +33,12 @@ class Meta: model = User fields = ['email'] - def validate(self, attrs): - email = attrs.get('email') - - try: - user = User.objects.get(email=email) - - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) - # relative_link = reverse('reset-password', kwargs={'uidb64': uidb64, 'token': token}) - # absolute_url = f'http://localhost:5173/{relative_link}' - absolute_url = f'https://localhost:5173/reset-password/{uidb64}/{token}' - - email_subject = 'Reset your password' - email_body = f'Click the link below to reset your password\n{absolute_url}' - - email = EmailMessage(subject=email_subject, body=email_body, from_email=settings.EMAIL_HOST_USER, to=[email]) - email.send() - - return {'email': user.email} - - except User.DoesNotExist: - raise AuthenticationFailed('user not found') - - class PasswordResetConfirmSerializer(serializers.ModelSerializer): - uidb64 = serializers.CharField(max_length=255, validators=[MaxLengthValidator(255)], read_only=True) password = serializers.CharField(min_length=8, max_length=64, write_only=True) class Meta: model = User - fields = ['password', 'uidb64'] - - def validate(self, attrs): - password = attrs.get('password') - token = self.context.get('token') - uidb64 = self.context.get('uidb64') - - id = smart_str(urlsafe_base64_decode(uidb64)) - user = User.objects.get(id=id) - - if not PasswordResetTokenGenerator().check_token(user, token): - raise AuthenticationFailed('The reset link is invalid', 401) - - user.set_password(password) - user.save() - - return user + fields = ['password'] class UserRetriveSerializer(serializers.ModelSerializer): @@ -194,14 +59,6 @@ class Meta: model = User fields = ['level', 'city', 'neighborhood', 'profile_image'] - def update(self, instance, validated_data): - instance.level = validated_data.get('level', instance.level) - instance.city = validated_data.get('city', instance.city) - instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) - instance.profile_image = validated_data.get('profile_image', instance.profile_image) - instance.save() - return instance - class ChangePasswordSerializer(serializers.Serializer): old_password = serializers.CharField(min_length=8, max_length=64, write_only=True) diff --git a/authentication/services.py b/authentication/services.py index b0b97e3..1b9ac18 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -1,7 +1,90 @@ +from datetime import timedelta +from django.utils import timezone +from django.utils.encoding import smart_str, smart_bytes +from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode +from django.contrib.auth.tokens import PasswordResetTokenGenerator +from django.core.mail import EmailMessage + + from rest_framework.exceptions import ValidationError from rest_framework import status +from .models import * +from .utils import send_otp_email +from FD import settings + + class UserService: + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'] + + + @staticmethod + def check_username(username): + if User.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'Username already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + @staticmethod + def check_email(email): + if User.objects.filter(email=email).exists(): + raise ValidationError({'detail': 'Email already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + @classmethod + def check_temp_user(cls, username, email): + temp_user_by_username = TempUser.objects.filter(username=username).first() + temp_user_by_email = TempUser.objects.filter(email=email).first() + + if temp_user_by_username and (timezone.now() - temp_user_by_username.date_joined) > timedelta(minutes=0): + temp_user_by_username.delete() + return + + if temp_user_by_email and (timezone.now() - temp_user_by_email.date_joined) > timedelta(minutes=0): + temp_user_by_email.delete() + return + + if temp_user_by_username : + raise ValidationError({'detail': 'Username already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + if temp_user_by_email: + raise ValidationError({'detail': 'Email already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + + @classmethod + def create_temp_user(cls, data): + cls.check_username(data['username']) + cls.check_email(data['email']) + cls.check_temp_user(data['username'], data['email']) + + TempUser.objects.create(username=data['username'], email=data['email'], password=data['password']) + send_otp_email(data['email']) + + @classmethod + def verify_email(cls, otp): + if not TempUser.objects.filter(otp=otp).exists(): + raise ValidationError({'detail': 'invalid one time password', 'status': status.HTTP_400_BAD_REQUEST}) + + temp_user = TempUser.objects.get(otp=otp) + user = User.objects.create(username=temp_user.username, email=temp_user.email, password=temp_user.password) + user.set_password(temp_user.password) + user.save() + print(user.password) + temp_user.delete() + + @classmethod + def check_level(cls, level): + if level not in cls.VALID_LEVELS: + raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(cls.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) + + @classmethod + def update_user(cls, user, data): + user.level = data.get('level', user.level) + cls.check_level(user.level) + user.city = data.get('city', user.city) + user.neighborhood = data.get('neighborhood', user.neighborhood) + user.profile_image = data.get('profile_image', user.profile_image) + user.save() + return user + + @staticmethod def change_password(user, old_password, new_password): if not user.check_password(old_password): raise ValidationError({'detail': 'Invalid password', 'status': status.HTTP_400_BAD_REQUEST}) @@ -11,4 +94,63 @@ def change_password(user, old_password, new_password): user.set_password(new_password) user.save() - return user \ No newline at end of file + return user + + @staticmethod + def login(username, password): + print(password) + print(User.objects.get(username=username).password) + if not User.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(username=username) + if not user.check_password(password): + raise ValidationError({'detail': 'Invalid password', 'status': status.HTTP_400_BAD_REQUEST}) + + if TempUser.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'Please verify your email', 'status': status.HTTP_400_BAD_REQUEST}) + + tokens = user.tokens() + return { + 'email': user.email, + 'username': user.username, + 'access_token': str(tokens['access']), + 'refresh_token': str(tokens['refresh']), + } + + @staticmethod + def request_reset_password(email): + if not User.objects.filter(email=email).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(email=email) + + uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) + token = PasswordResetTokenGenerator().make_token(user) + + absolute_url = f'https://localhost:5173/reset-password/{uidb64}/{token}' + + email_subject = 'Reset your password' + email_body = f'Click the link below to reset your password\n{absolute_url}' + + email = EmailMessage(subject=email_subject, body=email_body, from_email=settings.EMAIL_HOST_USER, to=[email]) + email.send() + + @staticmethod + def confirm_reset_password(uidb64, token, new_password): + id = smart_str(urlsafe_base64_decode(uidb64)) + + if not User.objects.filter(id=id).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(id=id) + + if not PasswordResetTokenGenerator().check_token(user, token): + raise ValidationError({'detail': 'The reset link is invalid', 'status': status.HTTP_400_BAD_REQUEST}) + + user.set_password(new_password) + user.save() + + return user + + \ No newline at end of file diff --git a/authentication/utils.py b/authentication/utils.py index fb66402..c659cd1 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -11,11 +11,13 @@ def send_otp_email(email): password = settings.EMAIL_HOST_PASSWORD otp = random.randint(100000, 999999) - + while TempUser.objects.filter(otp=otp).exists(): + otp = random.randint(100000, 999999) temp_user = TempUser.objects.get(email=email) - OneTimePassword.objects.create(temp_user=temp_user, otp=otp) - + temp_user.otp = otp + temp_user.save() + subject = 'One Time Password' body = f'Thanks for registering on freediscussion.ir.\nYour One Time Password is {otp}' diff --git a/authentication/views.py b/authentication/views.py index e393d47..050b7fd 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -4,8 +4,8 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.permissions import IsAuthenticated -from authentication.utils import send_otp_email from .serializers import * from .models import * from .services import * @@ -15,112 +15,112 @@ class RegisterUserView(APIView): def post(self, request): serializer = self.serializer_class(data=request.data) - if serializer.is_valid(raise_exception=True): - serializer.save() - temp_user = serializer.data - send_otp_email(temp_user['email']) - return Response({ - 'data': temp_user, - 'message': 'User created successfully\n Please check your email for One Time Password', - }, status=status.HTTP_201_CREATED) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + serializer.is_valid(raise_exception=True) + try: + UserService.create_temp_user(serializer.validated_data) + return Response("user created successfully, please verify your email", status=status.HTTP_201_CREATED) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer def post(self, request): serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) - if serializer.is_valid(raise_exception=True): - return Response({ - 'message': 'account verified successfully', - }, status=status.HTTP_200_OK) + try: + UserService.verify_email(serializer.validated_data['otp']) + return Response("email verified successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class LoginUserView(APIView): serializer_class = UserLoginSerializer def post(self, request): - serializer = self.serializer_class(data=request.data, context={'request': request}) - - if serializer.is_valid(raise_exception=True): - return Response(serializer.data, status=status.HTTP_200_OK) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + tokens = UserService.login(serializer.validated_data['username'], serializer.validated_data['password']) + return Response(tokens, status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class PasswordResetRequestView(APIView): serializer_class = PasswordResetRequestSerializer + def post(self, request): - serializer = PasswordResetRequestSerializer(data=request.data, context={'request': request}) - - if serializer.is_valid(raise_exception=True): - return Response("password reset link has been sent to your email", status=status.HTTP_200_OK) + serializer = PasswordResetRequestSerializer(data=request.data) + serializer.is_valid(raise_exception=True) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + try: + UserService.request_reset_password(serializer.validated_data['email']) + return Response("password reset link sent successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer def post(self, request, uidb64, token): - serializer = PasswordResetConfirmSerializer(data=request.data, context={'request': request, 'uidb64': uidb64, 'token': token}) + serializer = PasswordResetConfirmSerializer(data=request.data) + serializer.is_valid(raise_exception=True) - if serializer.is_valid(raise_exception=True): + try: + UserService.confirm_reset_password(uidb64, token, serializer.validated_data['password']) return Response("password reset successfully", status=status.HTTP_200_OK) - - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class UserRetriveView(APIView): + permission_classes = [IsAuthenticated] serializer_class= UserRetriveSerializer - def get(self, request, *args, **kwargs): - if (not request.user.is_authenticated): - return Response("user not authenticated", status=status.HTTP_401_UNAUTHORIZED) - username = request.user.username - user = User.objects.get(username=username) + def get(self, request): + user = request.user + serializer = self.serializer_class(user) + return Response(serializer.data, status=status.HTTP_200_OK) class UserDeleteView(APIView): + permission_classes = [IsAuthenticated] serializer_class= UserDeleteSerializer - def delete(self, request, *args, **kwargs): - if (not request.user.is_authenticated): - return Response("user not authenticated", status=status.HTTP_401_UNAUTHORIZED) + def delete(self, request): - username = request.user.username - user = User.objects.get(username=username) + user = request.user serializer = self.serializer_class(data = request.data) serializer.is_valid(raise_exception=True) - if user.check_password(serializer.validated_data.get('password')): + if not user.check_password(serializer.validated_data.get('password')): return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) user.delete() return Response("user deleted successfully", status=status.HTTP_200_OK) - - + class UserUpdateView(APIView): + permission_classes = [IsAuthenticated] serializer_class= UserUpdateSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] - def patch(self, request, *args, **kwargs): + def patch(self, request): try: - username = request.user.username - user = User.objects.get(username=username) + user = request.user serializer = self.serializer_class(user, data = request.data, partial=True) if serializer.is_valid(): - serializer.update(user, serializer.validated_data) + UserService.update_user(user, serializer.validated_data) return Response(serializer.data, status=status.HTTP_200_OK) else: return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) diff --git a/groups/migrations/0002_remove_group_max_members.py b/groups/migrations/0002_remove_group_max_members.py new file mode 100644 index 0000000..30f4894 --- /dev/null +++ b/groups/migrations/0002_remove_group_max_members.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2024-11-26 08:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('groups', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='group', + name='max_members', + ), + ] diff --git a/groups/models.py b/groups/models.py index e466e98..602f54a 100644 --- a/groups/models.py +++ b/groups/models.py @@ -8,10 +8,8 @@ class Group(models.Model): city = models.CharField(max_length=255, blank=True, null=True) neighborhood = models.CharField(max_length=255, blank=True, null=True) created_at = models.DateTimeField(auto_now_add=True) - max_members = models.IntegerField() meeting_url = models.URLField(blank=True, null=True) private = models.BooleanField(default=False) - owner = models.ForeignKey('authentication.User', on_delete=models.CASCADE, related_name='owned_groups') members = models.ManyToManyField('authentication.User', related_name='joined_groups') pending_members = models.ManyToManyField('authentication.User', related_name='pending_groups') @@ -20,13 +18,13 @@ class Group(models.Model): def add_pending_member(self, user): self.pending_members.add(user) - def remove_pending_member(self, user): - self.pending_members.remove(user) - def accept_pending_member(self, user): self.members.add(user) self.pending_members.remove(user) + def decline_pending_member(self, user): + self.pending_members.remove(user) + def add_member(self, user): self.members.add(user) diff --git a/groups/serializers.py b/groups/serializers.py index 1b31ce4..2812686 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -7,40 +7,16 @@ class GroupCreateSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['title', 'description', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] - - def validate(self, data): - if Group.objects.filter(title=data.get('title')).exists(): - raise serializers.ValidationError('group title already exists') - return data - - def create(self, validated_data): - user = self.context['request'].user - Group.objects.create( - title=validated_data.get('title'), - description=validated_data.get('description'), - level=validated_data.get('level'), - city=validated_data.get('city'), - neighborhood=validated_data.get('neighborhood'), - max_members=validated_data.get('max_members'), - meeting_url=validated_data.get('meeting_url'), - private=validated_data.get('private'), - owner=user - ) - group = Group.objects.get(title=validated_data.get('title')) - group.add_member(user) - # user.joined_groups.add(group) - return group + fields = ['title', 'description', 'level', 'city', 'neighborhood', 'meeting_url', 'private'] class GroupRetrieveSerializer(serializers.ModelSerializer): owner_username = serializers.SerializerMethodField(read_only=True) member_usernames = serializers.SerializerMethodField(read_only=True) - class Meta: model = Group fields = ['id', 'title', 'description', 'image', 'level', 'city', 'neighborhood', - 'created_at', 'max_members', 'meeting_url', 'private', + 'created_at', 'meeting_url', 'private', 'owner_username', 'member_usernames'] def get_owner_username(self, obj): @@ -53,32 +29,9 @@ def get_member_usernames(self, obj): class GroupUpdateSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['title', 'description', 'image', 'level', 'city', 'neighborhood', 'max_members', 'meeting_url', 'private'] - - def validate(self, data): - if 'level' in data and data['level'] not in VALID_LEVELS: - raise serializers.ValidationError( - f'Invalid level. Please select one of the following: {", ".join(VALID_LEVELS)}' - ) - - if 'title' in data and Group.objects.filter(title=data['title']).exists(): - raise serializers.ValidationError('group title already exists') - - return data - - def update(self, instance, validated_data): - instance.title = validated_data.get('title', instance.title) - instance.description = validated_data.get('description', instance.description) - instance.image = validated_data.get('image', instance.image) - instance.level = validated_data.get('level', instance.level) - instance.city = validated_data.get('city', instance.city) - instance.neighborhood = validated_data.get('neighborhood', instance.neighborhood) - instance.max_members = validated_data.get('max_members', instance.max_members) - instance.meeting_url = validated_data.get('meeting_url', instance.meeting_url) - instance.private = validated_data.get('private', instance.private) - instance.save() - return instance - + fields = ['title', 'description', 'image', 'level', 'city', 'neighborhood', 'meeting_url', 'private'] + read_only_fields = ['title'] + class GroupPendingRequestSerializer(serializers.ModelSerializer): pending_members = serializers.SerializerMethodField(read_only=True) @@ -89,34 +42,16 @@ class Meta: def get_pending_members(self, obj): return [member.username for member in obj.pending_members.all()] + class GroupAcceptRequestSerializer(serializers.Serializer): username = serializers.CharField() - def validate(self, data): - pending_usernames = [member.username for member in self.instance.pending_members.all()] - if data['username'] not in pending_usernames: - print(data['username']) - print(pending_usernames) - raise serializers.ValidationError('user not in pending members') - return data - class GroupDeclineRequestSerializer(serializers.Serializer): username = serializers.CharField() - def validate(self, data): - pending_usernames = [member.username for member in self.instance.pending_members.all()] - if data['username'] not in pending_usernames: - raise serializers.ValidationError('user not in pending members') - return data - class GroupKickSerializer(serializers.Serializer): username = serializers.CharField() - def validate(self, data): - usernames = [member.username for member in self.instance.members.all()] - if data['username'] not in usernames: - raise serializers.ValidationError('user not in members') - return data \ No newline at end of file diff --git a/groups/services.py b/groups/services.py new file mode 100644 index 0000000..068b006 --- /dev/null +++ b/groups/services.py @@ -0,0 +1,122 @@ +from rest_framework.exceptions import ValidationError +from rest_framework import status + +from groups.models import Group +from authentication.models import User + + +class GroupService: + + def check_title(title): + if Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group title already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + @classmethod + def create_group(cls, user, data): + cls.check_title(data.get('title')) + group = Group.objects.create(owner=user, **data) + group.add_member(user) + return group + + @staticmethod + def retrieve_group(title): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + return group + + @staticmethod + def update_group(title, data): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + group.description = data.get('description', group.description) + group.image = data.get('image', group.image) + group.level = data.get('level', group.level) + group.city = data.get('city', group.city) + group.neighborhood = data.get('neighborhood', group.neighborhood) + group.meeting_url = data.get('meeting_url', group.meeting_url) + group.private = data.get('private', group.private) + group.save() + return group + + @staticmethod + def delete_group(title): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + group = Group.objects.get(title=title) + group.delete() + + @staticmethod + def join_request(title, user): + group = Group.objects.get(title=title) + if user.level != group.level: + raise ValidationError({'detail': 'Your level does not match the group level', 'status': status.HTTP_400_BAD_REQUEST}) + + if group.private: + group.add_pending_member(user) + else: + group.add_member(user) + + @staticmethod + def pending_requests(title): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + return group + + @staticmethod + def accept_request(title, username): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if not User.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(username=username) + group = Group.objects.get(title=title) + group.accept_pending_member(user) + + @staticmethod + def decline_request(title, username): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if not User.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(username=username) + group = Group.objects.get(title=title) + group.decline_pending_member(user) + + @staticmethod + def leave_group(title, user): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + group.remove_member(user) + + if group.owner == user: + group.delete() + group.save() + + @staticmethod + def kik_member(title, username): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if not User.objects.filter(username=username).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + user = User.objects.get(username=username) + + group = Group.objects.get(title=title) + if not group.members.filter(username=username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + group.remove_member(user) + group.save() + diff --git a/groups/views.py b/groups/views.py index 2140384..0ccd48a 100644 --- a/groups/views.py +++ b/groups/views.py @@ -9,69 +9,59 @@ from .serializers import * from .models import * from .permissions import * +from .services import * class GroupCreateView(APIView): permission_classes = [IsAuthenticated] - serializer_class = GroupCreateSerializer def post(self, request): - serializer = self.serializer_class(data=request.data, context={'request': request}) - if serializer.is_valid(raise_exception=True): - serializer.save(owner=request.user) - return Response(serializer.data, status=status.HTTP_201_CREATED) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + GroupService.create_group(request.user, serializer.validated_data) + return Response("group created successfully", status=status.HTTP_201_CREATED) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) class GroupRetrieveView(APIView): serializer_class = GroupRetrieveSerializer def get(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) + try: + group = GroupService.retrieve_group(kwargs.get('title')) serializer = self.serializer_class(group) return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupUpdateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] parser_classes = [MultiPartParser, FormParser, JSONParser] - serializer_class = GroupUpdateSerializer def patch(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - serializer = self.serializer_class( - group, - data=request.data, - partial=True - ) - if serializer.is_valid(raise_exception=True): - serializer.update(group, serializer.validated_data) - return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) - + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + GroupService.update_group(kwargs.get('title'), serializer.validated_data) + return Response("group updated successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] def delete(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - if request.user.owned_groups.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - group.delete() - return Response("group deleted successfully", status=status.HTTP_200_OK) - else: - return Response("you are not the owner of this group", status=status.HTTP_400_BAD_REQUEST) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + try: + GroupService.delete_group(kwargs.get('title')) + return Response("group deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -79,92 +69,75 @@ class GroupJoinRequestView(APIView): permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - if group.max_members < group.members.count(): - return Response("group is full", status=status.HTTP_400_BAD_REQUEST) - if group.level != request.user.level: - return Response(f"your level is not compatible with this group your level is {request.user.level} and group level is {group.level}", status=status.HTTP_400_BAD_REQUEST) - - if group.private: - group.add_pending_member(request.user) - return Response("join request sent successfully", status=status.HTTP_200_OK) - else: - group.add_member(request.user) - return Response("you are now a member of this group", status=status.HTTP_200_OK) - + try: + GroupService.join_request(kwargs.get('title'), request.user) + return Response("request sent successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupPendingRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] + serializer_class = GroupPendingRequestSerializer def get(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - serializer = GroupPendingRequestSerializer(group) + try: + group = GroupService.pending_requests(kwargs.get('title')) + serializer = self.serializer_class(group) return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupAcceptRequestView(APIView): - serializer_class = GroupAcceptRequestSerializer permission_classes = [IsAuthenticated, IsGroupOwner] + serializer_class = GroupAcceptRequestSerializer def post(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - serializer = self.serializer_class(group, data=request.data) - if serializer.is_valid(raise_exception=True): - user = User.objects.get(username=serializer.validated_data['username']) - group.accept_pending_member(user) - return Response("request accepted successfully", status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + GroupService.accept_request(kwargs.get('title'), serializer.validated_data['username']) + return Response("request accepted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + class GroupDeclineRequestView(APIView): - serializer_class = GroupDeclineRequestSerializer permission_classes = [IsAuthenticated, IsGroupOwner] + serializer_class = GroupDeclineRequestSerializer def post(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - serializer = self.serializer_class(group, data=request.data) - if serializer.is_valid(raise_exception=True): - user = User.objects.get(username=serializer.validated_data['username']) - group.decline_pending_member(user) - return Response("request declined successfully", status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username']) + return Response("request accepted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupLeaveView(APIView): permission_classes = [IsAuthenticated, IsGroupMember] + def post(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - group.remove_member(request.user) - return Response("you left the group successfully", status=status.HTTP_200_OK) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) + try: + GroupService.leave_group(kwargs.get('title'), request.user) + return Response("group left successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupKickView(APIView): serializer_class = GroupKickSerializer permission_classes = [IsAuthenticated, IsGroupOwner] + def post(self, request, *args, **kwargs): - if Group.objects.filter(title=kwargs.get('title')).exists(): - group = Group.objects.get(title=kwargs.get('title')) - serializer = self.serializer_class(group, data=request.data) - if serializer.is_valid(raise_exception=True): - user = User.objects.get(username=serializer.validated_data['username']) - group.remove_member(user) - return Response("user kicked successfully", status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - else: - return Response("group not found", status=status.HTTP_404_NOT_FOUND) \ No newline at end of file + serializer = self.serializer_class(data=request.data) + serializer.is_valid(raise_exception=True) + try: + GroupService.kick_user(kwargs.get('title'), serializer.validated_data['username']) + return Response("user kicked successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + \ No newline at end of file diff --git a/home/serializers.py b/home/serializers.py index 832e0e2..2b42cf6 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -35,7 +35,7 @@ class Meta: class SuggestionSerializer(serializers.ModelSerializer): class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'max_members', 'meeting_url', 'private'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] \ No newline at end of file diff --git a/home/views.py b/home/views.py index 1700b55..f2171fd 100644 --- a/home/views.py +++ b/home/views.py @@ -9,14 +9,13 @@ from .permissions import * class HomeView(APIView): + permission_classes = [IsAuthenticated] serializer_class = HomeSerializer def get(self, request): - if request.user.is_authenticated: - serializer = self.serializer_class(request.user) - return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response("landing page", status=status.HTTP_200_OK) + serializer = self.serializer_class(request.user) + return Response(serializer.data, status=status.HTTP_200_OK) + class UserTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated] From 7a675009b42158c05cae68f4afc4e79c8452d820 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 26 Nov 2024 21:14:48 +0330 Subject: [PATCH 30/60] Feat/pagination (#19) * feat: add all groups * feat: add scheduling pagination * feat: add group members --- FD/settings.py | 6 ++-- groups/serializers.py | 7 +++++ groups/services.py | 9 ++++++ groups/urls.py | 3 ++ groups/views.py | 22 ++++++++++++++ home/serializers.py | 23 ++++++-------- home/services.py | 27 +++++++++++------ home/urls.py | 1 + home/views.py | 70 +++++++++++++++++++++++++++++-------------- 9 files changed, 121 insertions(+), 47 deletions(-) diff --git a/FD/settings.py b/FD/settings.py index 2cbac32..a06ee46 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -120,7 +120,9 @@ 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_AUTHENTICATION_CLASSES': ( 'rest_framework_simplejwt.authentication.JWTAuthentication', - ) + ), + 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', + 'PAGE_SIZE': 1 } SPECTACULAR_SETTINGS = { @@ -153,4 +155,4 @@ "staticfiles": { "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, -} \ No newline at end of file +} diff --git a/groups/serializers.py b/groups/serializers.py index 2812686..d5a612f 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -1,4 +1,5 @@ from rest_framework import serializers +from authentication.models import User from groups.models import * VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] @@ -54,4 +55,10 @@ class GroupDeclineRequestSerializer(serializers.Serializer): class GroupKickSerializer(serializers.Serializer): username = serializers.CharField() +class GroupMemberListSerializer(serializers.ModelSerializer): + class Meta: + model = User + fields = ['username'] + + \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index 068b006..5b1711e 100644 --- a/groups/services.py +++ b/groups/services.py @@ -119,4 +119,13 @@ def kik_member(title, username): raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) group.save() + + + @staticmethod + def member_list(title): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + return group.members.all() diff --git a/groups/urls.py b/groups/urls.py index fee9d07..35df3fb 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -7,10 +7,13 @@ path('/delete/', GroupDeleteView.as_view()), path('/update/', GroupUpdateView.as_view()), + path('/members/', GroupMemberListView.as_view()), + path('/join/', GroupJoinRequestView.as_view()), path('/leave/', GroupLeaveView.as_view()), path('/pending/', GroupPendingRequestView.as_view()), path('/accept/', GroupAcceptRequestView.as_view()), path('/decline/', GroupDeclineRequestView.as_view()), + path('/kik/', GroupKickView.as_view()), ] \ No newline at end of file diff --git a/groups/views.py b/groups/views.py index 0ccd48a..c6c17d8 100644 --- a/groups/views.py +++ b/groups/views.py @@ -3,6 +3,7 @@ from rest_framework.views import APIView from rest_framework.permissions import IsAuthenticated from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.pagination import PageNumberPagination from authentication.models import User @@ -140,4 +141,25 @@ def post(self, request, *args, **kwargs): return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + + +class GroupMemberListView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = GroupMemberListSerializer + pagination_class = PageNumberPagination + + + def get(self, request, *args, **kwargs): + try: + members = GroupService.member_list(kwargs.get('title')) + + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(members, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + + + \ No newline at end of file diff --git a/home/serializers.py b/home/serializers.py index 2b42cf6..d62e037 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -4,22 +4,12 @@ from authentication.models import User from groups.models import Group -class HomeSerializer(serializers.Serializer): - joined_groups = serializers.SerializerMethodField() - pending_groups = serializers.SerializerMethodField() +class HomeSerializer(serializers.ModelSerializer): - class meta: - model = User - fields = ['joined_groups', 'pending_groups'] + class Meta: + model = Group + fields = '__all__' - def get_joined_groups(self, obj): - joined_groups = [group.title for group in obj.joined_groups.all()] - return joined_groups - - def get_pending_groups(self, obj): - pending_groups = [group.title for group in obj.pending_groups.all()] - return pending_groups - class UserTimeSlotSerializer(serializers.ModelSerializer): class Meta: @@ -37,5 +27,10 @@ class Meta: model = Group fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] +class GroupSerializer(serializers.ModelSerializer): + class Meta: + model = Group + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] + \ No newline at end of file diff --git a/home/services.py b/home/services.py index 34c9b32..3f1dbea 100644 --- a/home/services.py +++ b/home/services.py @@ -1,6 +1,7 @@ from django.db.models import QuerySet from rest_framework.exceptions import ValidationError +from rest_framework import status from .models import * from groups.models import * @@ -9,10 +10,10 @@ class UserTimeSlotService: @staticmethod def validate_time_range(start_time: float, end_time: float) -> None: if not (0 <= start_time <= 24 and 0 <= end_time <= 24): - raise ValidationError('Time must be between 0 and 24') + raise ValidationError({'detail': 'Time must be between 0 and 24', 'status': status.HTTP_400_BAD_REQUEST}) if end_time <= start_time: - raise ValidationError('End time must be after start time') + raise ValidationError({'detail': 'End time must be after start time', 'status': status.HTTP_400_BAD_REQUEST}) @staticmethod def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) -> None: @@ -25,7 +26,7 @@ def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) ) if overlapping.exists(): - raise ValidationError('This time slot overlaps with an existing slot') + raise ValidationError({'detail': 'This time slot overlaps with an existing slot', 'status': status.HTTP_400_BAD_REQUEST}) @classmethod def create_time_slot(cls, user, day_of_week: str, start_time: float, end_time: float) -> UserTimeSlot: @@ -53,7 +54,7 @@ def delete_time_slot(user, time_slot_id: int) -> None: ).first() if not time_slot: - raise ValidationError('Time slot not found') + raise ValidationError({'detail': 'Time slot not found', 'status': status.HTTP_404_NOT_FOUND}) time_slot.delete() @@ -63,10 +64,10 @@ class GroupTimeSlotService: @staticmethod def validate_time_range(start_time: float, end_time: float) -> None: if not (0 <= start_time <= 24 and 0 <= end_time <= 24): - raise ValidationError('Time must be between 0 and 24') + raise ValidationError({'detail': 'Time must be between 0 and 24', 'status': status.HTTP_400_BAD_REQUEST}) if end_time <= start_time: - raise ValidationError('End time must be after start time') + raise ValidationError({'detail': 'End time must be after start time', 'status': status.HTTP_400_BAD_REQUEST}) @staticmethod def validate_overlap(group, day_of_week: str, start_time: float, end_time: float) -> None: @@ -79,7 +80,7 @@ def validate_overlap(group, day_of_week: str, start_time: float, end_time: float ) if overlapping.exists(): - raise ValidationError('This time slot overlaps with an existing slot') + raise ValidationError({'detail': 'This time slot overlaps with an existing slot', 'status': status.HTTP_400_BAD_REQUEST}) @classmethod def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: @@ -107,12 +108,15 @@ def delete_time_slot(user, time_slot_id: int) -> None: ).first() if not time_slot: - raise ValidationError('Time slot not found') + raise ValidationError({'detail': 'Time slot not found', 'status': status.HTTP_404_NOT_FOUND}) time_slot.delete() +class HomeService: + def get_joined_groups(user) -> list[Group]: + return Group.objects.filter(members=user) class SuggestionService: @@ -137,4 +141,9 @@ def get_suggestions(user) -> list[Group]: group_matches.sort(key=lambda x: x[1], reverse=True) - return group_matches \ No newline at end of file + return group_matches + + +class AllGroupsService: + def get_all_groups() -> list[Group]: + return Group.objects.all() \ No newline at end of file diff --git a/home/urls.py b/home/urls.py index 4712b77..4d56e42 100644 --- a/home/urls.py +++ b/home/urls.py @@ -4,6 +4,7 @@ urlpatterns = [ path('', HomeView.as_view()), path('suggestions/', SuggestionsView.as_view()), + path('all-groups/', AllGroupsView.as_view()), path('user-time-slots/create/', UserTimeSlotCreateView.as_view()), path('user-time-slots/', UserTimeSlotListView.as_view()), diff --git a/home/views.py b/home/views.py index f2171fd..edeb184 100644 --- a/home/views.py +++ b/home/views.py @@ -2,6 +2,7 @@ from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response +from rest_framework.pagination import PageNumberPagination from .serializers import * from .services import * @@ -11,10 +12,42 @@ class HomeView(APIView): permission_classes = [IsAuthenticated] serializer_class = HomeSerializer + pagination_class = PageNumberPagination def get(self, request): - serializer = self.serializer_class(request.user) - return Response(serializer.data, status=status.HTTP_200_OK) + joined_groups = HomeService.get_joined_groups(request.user) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(joined_groups, request) + serializer = self.serializer_class(paginated_data, many=True) + + return paginator.get_paginated_response(serializer.data) + + +class SuggestionsView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = SuggestionSerializer + pagination_class = PageNumberPagination + + def get(self, request): + suggestions = SuggestionService.get_suggestions(request.user) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(suggestions, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + + +class AllGroupsView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = GroupSerializer + pagination_class = PageNumberPagination + + def get(self, request): + groups = AllGroupsService.get_all_groups() + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(groups, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + class UserTimeSlotCreateView(APIView): @@ -32,7 +65,7 @@ def post(self, request): ) return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: - return Response(str(e), status=status.HTTP_400_BAD_REQUEST) + return Response(e.detail.get('detail'), status=e.detail.get('status')) class UserTimeSlotListView(APIView): permission_classes = [IsAuthenticated] @@ -49,8 +82,11 @@ class UserTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated] def delete(self, request, *args, **kwargs): - UserTimeSlotService.delete_time_slot(request.user, kwargs.get('id')) - return Response("time slot deleted successfully", status=status.HTTP_200_OK) + try: + UserTimeSlotService.delete_time_slot(request.user, kwargs.get('id')) + return Response("time slot deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -69,7 +105,7 @@ def post(self, request, *args, **kwargs): ) return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: - return Response(str(e), status=status.HTTP_400_BAD_REQUEST) + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupTimeSlotListView(APIView): @@ -87,22 +123,12 @@ class GroupTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] def delete(self, request, *args, **kwargs): - GroupTimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) - return Response("time slot deleted successfully", status=status.HTTP_200_OK) - - -class SuggestionsView(APIView): - permission_classes = [IsAuthenticated] - - serializer_class = SuggestionSerializer - - def get(self, requst): - suggestions = SuggestionService.get_suggestions(requst.user) - serializer = self.serializer_class(suggestions, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - - - + try: + GroupTimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) + return Response("time slot deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + from django.db import connection from django.http import JsonResponse From 5e2e40f222e5b07b60ab5ac3598a01f05a64929b Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Wed, 27 Nov 2024 18:52:15 +0330 Subject: [PATCH 31/60] Feat/filter-sort-search (#20) * feat: add level and neighborhood filter * feat: add home and all groups search * feat: add sorting by level and member count --------- Co-authored-by: Mahziar --- FD/settings.py | 1 + groups/serializers.py | 6 +++++- groups/services.py | 3 ++- groups/views.py | 8 ++++++-- home/filters.py | 11 +++++++++++ home/serializers.py | 6 +++++- home/services.py | 17 +++++++++++++---- home/views.py | 33 ++++++++++++++++++++++++++++++--- requirements.txt | 1 + 9 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 home/filters.py diff --git a/FD/settings.py b/FD/settings.py index a06ee46..f10e95e 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -27,6 +27,7 @@ 'corsheaders', 'storages', + 'django_filters', 'authentication', 'groups', diff --git a/groups/serializers.py b/groups/serializers.py index d5a612f..91f9adc 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -14,17 +14,21 @@ class Meta: class GroupRetrieveSerializer(serializers.ModelSerializer): owner_username = serializers.SerializerMethodField(read_only=True) member_usernames = serializers.SerializerMethodField(read_only=True) + number_of_members = serializers.SerializerMethodField(read_only=True) class Meta: model = Group fields = ['id', 'title', 'description', 'image', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', - 'owner_username', 'member_usernames'] + 'owner_username', 'member_usernames', 'number_of_members'] def get_owner_username(self, obj): return obj.owner.username if obj.owner else None def get_member_usernames(self, obj): return [member.username for member in obj.members.all()] + + def get_number_of_members(self, obj): + return obj.members.count() class GroupUpdateSerializer(serializers.ModelSerializer): diff --git a/groups/services.py b/groups/services.py index 5b1711e..746f3eb 100644 --- a/groups/services.py +++ b/groups/services.py @@ -57,8 +57,10 @@ def join_request(title, user): if group.private: group.add_pending_member(user) + return "private" else: group.add_member(user) + return "public" @staticmethod def pending_requests(title): @@ -120,7 +122,6 @@ def kik_member(title, username): group.remove_member(user) group.save() - @staticmethod def member_list(title): if not Group.objects.filter(title=title).exists(): diff --git a/groups/views.py b/groups/views.py index c6c17d8..11cab84 100644 --- a/groups/views.py +++ b/groups/views.py @@ -71,8 +71,12 @@ class GroupJoinRequestView(APIView): def post(self, request, *args, **kwargs): try: - GroupService.join_request(kwargs.get('title'), request.user) - return Response("request sent successfully", status=status.HTTP_200_OK) + type = GroupService.join_request(kwargs.get('title'), request.user) + if type == "private": + return Response("request sent successfully", status=status.HTTP_200_OK) + elif type == "public": + return Response("you are now a member of this group", status=status.HTTP_200_OK) + except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) diff --git a/home/filters.py b/home/filters.py new file mode 100644 index 0000000..24ca97d --- /dev/null +++ b/home/filters.py @@ -0,0 +1,11 @@ +import django_filters +from groups.models import Group + +class GroupFilter(django_filters.FilterSet): + class Meta: + model = Group + fields = { + 'level': ['exact'], + 'neighborhood': ['exact'], + + } \ No newline at end of file diff --git a/home/serializers.py b/home/serializers.py index d62e037..f02637f 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -28,9 +28,13 @@ class Meta: fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] class GroupSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count'] + + def get_member_count(self, obj): + return obj.members.count() \ No newline at end of file diff --git a/home/services.py b/home/services.py index 3f1dbea..9161585 100644 --- a/home/services.py +++ b/home/services.py @@ -1,4 +1,4 @@ -from django.db.models import QuerySet +from django.db.models import QuerySet, Q from rest_framework.exceptions import ValidationError from rest_framework import status @@ -6,6 +6,8 @@ from .models import * from groups.models import * +from .filters import GroupFilter + class UserTimeSlotService: @staticmethod def validate_time_range(start_time: float, end_time: float) -> None: @@ -115,8 +117,14 @@ def delete_time_slot(user, time_slot_id: int) -> None: class HomeService: - def get_joined_groups(user) -> list[Group]: - return Group.objects.filter(members=user) + def get_joined_groups(user, request) -> list[Group]: + queryset = Group.objects.filter(members=user) + + search_term = request.GET.get('search') + if search_term: + queryset = queryset.filter(title__icontains=search_term) + + return queryset class SuggestionService: @@ -145,5 +153,6 @@ def get_suggestions(user) -> list[Group]: class AllGroupsService: - def get_all_groups() -> list[Group]: + @staticmethod + def get_all_groups() -> QuerySet[Group]: return Group.objects.all() \ No newline at end of file diff --git a/home/views.py b/home/views.py index edeb184..1f03542 100644 --- a/home/views.py +++ b/home/views.py @@ -1,26 +1,42 @@ +from django.db.models import Count + from rest_framework.views import APIView from rest_framework import status from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from rest_framework.pagination import PageNumberPagination +from rest_framework.filters import SearchFilter, OrderingFilter + + +from django_filters.rest_framework import DjangoFilterBackend + from .serializers import * from .services import * from groups.models import* from .permissions import * +from .filters import * class HomeView(APIView): permission_classes = [IsAuthenticated] serializer_class = HomeSerializer pagination_class = PageNumberPagination + filter_backends = [SearchFilter] + search_fields = ['title'] def get(self, request): - joined_groups = HomeService.get_joined_groups(request.user) + joined_groups = HomeService.get_joined_groups(request.user, request) + filtered_groups = self.filter_queryset(joined_groups) paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(joined_groups, request) + paginated_data = paginator.paginate_queryset(filtered_groups, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset class SuggestionsView(APIView): @@ -40,13 +56,24 @@ class AllGroupsView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupSerializer pagination_class = PageNumberPagination + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = GroupFilter + search_fields = ['title'] + ordering_fields = ['level', 'member_count'] def get(self, request): groups = AllGroupsService.get_all_groups() + groups = groups.annotate(member_count=Count('members')) + filtered_groups = self.filter_queryset(groups) paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(groups, request) + paginated_data = paginator.paginate_queryset(filtered_groups, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset diff --git a/requirements.txt b/requirements.txt index da4a3a7..830cf05 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,6 +4,7 @@ boto3==1.35.64 botocore==1.35.64 Django==5.1.2 django-cors-headers==4.6.0 +django-filter==24.3 django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 From a09b93057cd217bde4006768b3fa29b1feac391c Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Wed, 27 Nov 2024 19:26:29 +0330 Subject: [PATCH 32/60] Feat/chat (#21) * feat: add chat application and model * feat: add sending and retrieving messages --- FD/settings.py | 1 + FD/urls.py | 1 + authentication/services.py | 3 --- chat/__init__.py | 0 chat/admin.py | 5 +++++ chat/apps.py | 6 ++++++ chat/migrations/0001_initial.py | 38 +++++++++++++++++++++++++++++++++ chat/migrations/__init__.py | 0 chat/models.py | 22 +++++++++++++++++++ chat/serializers.py | 16 ++++++++++++++ chat/services.py | 35 ++++++++++++++++++++++++++++++ chat/tests.py | 3 +++ chat/urls.py | 8 +++++++ chat/views.py | 26 ++++++++++++++++++++++ groups/services.py | 2 ++ 15 files changed, 163 insertions(+), 3 deletions(-) create mode 100644 chat/__init__.py create mode 100644 chat/admin.py create mode 100644 chat/apps.py create mode 100644 chat/migrations/0001_initial.py create mode 100644 chat/migrations/__init__.py create mode 100644 chat/models.py create mode 100644 chat/serializers.py create mode 100644 chat/services.py create mode 100644 chat/tests.py create mode 100644 chat/urls.py create mode 100644 chat/views.py diff --git a/FD/settings.py b/FD/settings.py index f10e95e..64049d8 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -32,6 +32,7 @@ 'authentication', 'groups', 'home', + 'chat', ] diff --git a/FD/urls.py b/FD/urls.py index cc7cb86..44e5363 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -8,6 +8,7 @@ path('authentication/', include('authentication.urls')), path('groups/', include('groups.urls')), path('scheduling/', include('home.urls')), + path('chat/', include('chat.urls')), path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/authentication/services.py b/authentication/services.py index 1b9ac18..42d36db 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -66,7 +66,6 @@ def verify_email(cls, otp): user = User.objects.create(username=temp_user.username, email=temp_user.email, password=temp_user.password) user.set_password(temp_user.password) user.save() - print(user.password) temp_user.delete() @classmethod @@ -98,8 +97,6 @@ def change_password(user, old_password, new_password): @staticmethod def login(username, password): - print(password) - print(User.objects.get(username=username).password) if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) diff --git a/chat/__init__.py b/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/admin.py b/chat/admin.py new file mode 100644 index 0000000..9f63758 --- /dev/null +++ b/chat/admin.py @@ -0,0 +1,5 @@ +from django.contrib import admin +from .models import * + +admin.site.register(Chat) +admin.site.register(Message) \ No newline at end of file diff --git a/chat/apps.py b/chat/apps.py new file mode 100644 index 0000000..2fe899a --- /dev/null +++ b/chat/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'chat' diff --git a/chat/migrations/0001_initial.py b/chat/migrations/0001_initial.py new file mode 100644 index 0000000..fffb13b --- /dev/null +++ b/chat/migrations/0001_initial.py @@ -0,0 +1,38 @@ +# Generated by Django 5.1.2 on 2024-11-27 12:29 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('groups', '0002_remove_group_max_members'), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Chat', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('group', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='chat', to='groups.group')), + ], + ), + migrations.CreateModel( + name='Message', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('content', models.TextField()), + ('timestamp', models.DateTimeField(auto_now_add=True)), + ('chat', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.chat')), + ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-timestamp'], + }, + ), + ] diff --git a/chat/migrations/__init__.py b/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/chat/models.py b/chat/models.py new file mode 100644 index 0000000..04d5faa --- /dev/null +++ b/chat/models.py @@ -0,0 +1,22 @@ +from django.db import models + + + +class Chat(models.Model): + group = models.OneToOneField('groups.Group', on_delete=models.CASCADE, related_name='chat') + + + def __str__(self) -> str: + return self.group.title + + +class Message(models.Model): + chat = models.ForeignKey('chat.Chat', on_delete=models.CASCADE, related_name='messages') + sender = models.ForeignKey('authentication.User', on_delete=models.CASCADE, related_name='sent_messages') + + content = models.TextField() + timestamp = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ['-timestamp'] + \ No newline at end of file diff --git a/chat/serializers.py b/chat/serializers.py new file mode 100644 index 0000000..dcebe1e --- /dev/null +++ b/chat/serializers.py @@ -0,0 +1,16 @@ +from rest_framework import serializers + +from .models import * + +class ChatSerializer(serializers.ModelSerializer): + chat = serializers.SerializerMethodField() + sender = serializers.SerializerMethodField() + class Meta: + model = Message + fields = ['id', 'sender', 'chat', 'content', 'timestamp'] + + def get_chat(self, obj): + return obj.chat.group.title + + def get_sender(self, obj): + return obj.sender.username \ No newline at end of file diff --git a/chat/services.py b/chat/services.py new file mode 100644 index 0000000..f9c4733 --- /dev/null +++ b/chat/services.py @@ -0,0 +1,35 @@ +from rest_framework.exceptions import ValidationError +from rest_framework import status + +from groups.models import Group +from .models import * + +class ChatService: + + @staticmethod + def check_user(user, title): + group = Group.objects.get(title=title) + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + + @staticmethod + def get_messages(title): + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + group = Group.objects.get(title=title) + chat = group.chat + + messages = chat.messages.all() + return messages + + @classmethod + def send_message(cls, user, title, content): + cls.check_user(user, title) + + group = Group.objects.get(title=title) + chat = group.chat + message = Message.objects.create(chat=chat, sender=user, content=content) + return message + + \ No newline at end of file diff --git a/chat/tests.py b/chat/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/chat/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/chat/urls.py b/chat/urls.py new file mode 100644 index 0000000..6c68940 --- /dev/null +++ b/chat/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from .views import * + +urlpatterns = [ + path('/send/', ChatView.as_view()), + path('/retrieve/', ChatView.as_view()), +] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py new file mode 100644 index 0000000..1214cf2 --- /dev/null +++ b/chat/views.py @@ -0,0 +1,26 @@ +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import status +from rest_framework.permissions import IsAuthenticated + +from .serializers import * +from .services import * + +class ChatView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = ChatSerializer + + def get(self, request, *args, **kwargs): + try: + messages = ChatService.get_messages(kwargs.get('title')) + serializer = self.serializer_class(messages, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + + def post(self, request, *args, **kwargs): + try: + message = ChatService.send_message(request.user, kwargs.get('title'), request.data['content']) + return Response(self.serializer_class(message).data, status=status.HTTP_201_CREATED) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) diff --git a/groups/services.py b/groups/services.py index 746f3eb..6cbe9c2 100644 --- a/groups/services.py +++ b/groups/services.py @@ -3,6 +3,7 @@ from groups.models import Group from authentication.models import User +from chat.models import Chat class GroupService: @@ -15,6 +16,7 @@ def check_title(title): def create_group(cls, user, data): cls.check_title(data.get('title')) group = Group.objects.create(owner=user, **data) + Chat.objects.create(group=group) group.add_member(user) return group From be3508c5e98797f4869f4a490868a794261cb3cf Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Fri, 29 Nov 2024 23:38:19 +0330 Subject: [PATCH 33/60] Feat/Live Chat (#22) * feat: add real time chat base. pagination not implemented * feat: add chat history * feat: deploy live chat --- FD/asgi.py | 19 ++++++- FD/settings.py | 24 ++++++++- chat/consumers.py | 99 +++++++++++++++++++++++++++++++++++ chat/routing.py | 7 +++ chat/templates/chat/room.html | 51 ++++++++++++++++++ chat/urls.py | 7 ++- chat/views.py | 56 ++++++++++++-------- requirements.txt | 23 ++++++++ 8 files changed, 259 insertions(+), 27 deletions(-) create mode 100644 chat/consumers.py create mode 100644 chat/routing.py create mode 100644 chat/templates/chat/room.html diff --git a/FD/asgi.py b/FD/asgi.py index a5b38df..d18f737 100644 --- a/FD/asgi.py +++ b/FD/asgi.py @@ -1,7 +1,22 @@ import os +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter +from channels.security.websocket import AllowedHostsOriginValidator from django.core.asgi import get_asgi_application -os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'FD.settings') +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "FD.settings") +# Initialize Django ASGI application early to ensure the AppRegistry +# is populated before importing code that may import ORM models. +django_asgi_app = get_asgi_application() -application = get_asgi_application() +from chat.routing import websocket_urlpatterns + +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": AllowedHostsOriginValidator( + AuthMiddlewareStack(URLRouter(websocket_urlpatterns)) + ), + } +) \ No newline at end of file diff --git a/FD/settings.py b/FD/settings.py index 64049d8..74f59ef 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -14,6 +14,8 @@ ALLOWED_HOSTS = ['*'] INSTALLED_APPS = [ + 'daphne', + 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', @@ -28,6 +30,7 @@ 'corsheaders', 'storages', 'django_filters', + 'channels', 'authentication', 'groups', @@ -69,7 +72,9 @@ }, ] -WSGI_APPLICATION = 'FD.wsgi.application' +# WSGI_APPLICATION = 'FD.wsgi.application' +ASGI_APPLICATION = "FD.asgi.application" + DATABASES = { 'default': { @@ -158,3 +163,20 @@ "BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage", }, } + + + +import urllib.parse +import redis + +redis_url = os.environ.get('REDIS_URL') +parsed_url = urllib.parse.urlparse(redis_url) + +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [f"rediss://{parsed_url.netloc}"], + }, + }, +} \ No newline at end of file diff --git a/chat/consumers.py b/chat/consumers.py new file mode 100644 index 0000000..1fca73d --- /dev/null +++ b/chat/consumers.py @@ -0,0 +1,99 @@ +import json +from channels.generic.websocket import WebsocketConsumer +from asgiref.sync import async_to_sync + +from django.core.serializers.json import DjangoJSONEncoder + +from rest_framework_simplejwt.tokens import AccessToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError + +from .models import Chat, Message +from authentication.models import User + +class ChatConsumer(WebsocketConsumer): + def connect(self): + self.room_name = self.scope["url_route"]["kwargs"]["room_name"] + self.room_group_name = f"chat_{self.room_name}" + + try: + self.chat = Chat.objects.get(group__title=self.room_name) + except Chat.DoesNotExist: + self.close() + + headers = dict(self.scope['headers']) + auth_header = headers.get(b'authorization', b'').decode() + + if auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + user = self.get_user_from_token(token) + if user: + self.scope['user'] = user + else: + self.close() + return + else: + self.close() + return + + # Join room group + async_to_sync(self.channel_layer.group_add)( + self.room_group_name, self.channel_name + ) + + self.accept() + + messages = self.get_chat_messages() + self.send(text_data=json.dumps({ + 'type': 'chat_history', + 'messages': messages + }, cls=DjangoJSONEncoder)) + + + def get_chat_messages(self): + return list(self.chat.messages.all().order_by('timestamp').values( + 'content', 'sender__username', 'timestamp' + )) + + def get_user_from_token(self, token): + try: + access_token = AccessToken(token) + user_id = access_token['user_id'] + return User.objects.get(id=user_id) + except (InvalidToken, TokenError, User.DoesNotExist): + return None + + def disconnect(self, close_code): + # Leave room group + async_to_sync(self.channel_layer.group_discard)( + self.room_group_name, self.channel_name + ) + + def receive(self, text_data): + text_data_json = json.loads(text_data) + message = text_data_json["message"] + user = self.scope["user"] + + db_message = Message.objects.create( + chat=self.chat, + content=message, + sender=user + ) + + async_to_sync(self.channel_layer.group_send)( + self.room_group_name, + { + "type": "chat_message", + "message": db_message.content, + "username": db_message.sender.username, + "timestamp": db_message.timestamp.isoformat(), + } + ) + + # Receive message from room group + def chat_message(self, event): + # Send message to WebSocket + self.send(text_data=json.dumps({ + 'message': event['message'], + 'username': event['username'], + 'timestamp': event['timestamp'] + })) \ No newline at end of file diff --git a/chat/routing.py b/chat/routing.py new file mode 100644 index 0000000..b76b063 --- /dev/null +++ b/chat/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from . import consumers + +websocket_urlpatterns = [ + re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), +] \ No newline at end of file diff --git a/chat/templates/chat/room.html b/chat/templates/chat/room.html new file mode 100644 index 0000000..7ad233a --- /dev/null +++ b/chat/templates/chat/room.html @@ -0,0 +1,51 @@ + +{% load static %} + + + + + Chat Room + + +
+
+ + {{ room_name_json|json_script:"room-name" }} + + + \ No newline at end of file diff --git a/chat/urls.py b/chat/urls.py index 6c68940..93f9aa5 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -3,6 +3,9 @@ from .views import * urlpatterns = [ - path('/send/', ChatView.as_view()), - path('/retrieve/', ChatView.as_view()), + # path('/send/', ChatView.as_view()), + # path('/retrieve/', ChatView.as_view()), + + path('/', chat_room, name='chat_room'), + ] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py index 1214cf2..1af1eb0 100644 --- a/chat/views.py +++ b/chat/views.py @@ -1,26 +1,38 @@ -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import status -from rest_framework.permissions import IsAuthenticated +# from rest_framework.views import APIView +# from rest_framework.response import Response +# from rest_framework import status +# from rest_framework.permissions import IsAuthenticated -from .serializers import * -from .services import * +# from .serializers import * +# from .services import * -class ChatView(APIView): - permission_classes = [IsAuthenticated] - serializer_class = ChatSerializer +# class ChatView(APIView): +# permission_classes = [IsAuthenticated] +# serializer_class = ChatSerializer - def get(self, request, *args, **kwargs): - try: - messages = ChatService.get_messages(kwargs.get('title')) - serializer = self.serializer_class(messages, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) - except ValidationError as e: - return Response(e.detail.get('detail'), status=e.detail.get('status')) +# def get(self, request, *args, **kwargs): +# try: +# messages = ChatService.get_messages(kwargs.get('title')) +# serializer = self.serializer_class(messages, many=True) +# return Response(serializer.data, status=status.HTTP_200_OK) +# except ValidationError as e: +# return Response(e.detail.get('detail'), status=e.detail.get('status')) - def post(self, request, *args, **kwargs): - try: - message = ChatService.send_message(request.user, kwargs.get('title'), request.data['content']) - return Response(self.serializer_class(message).data, status=status.HTTP_201_CREATED) - except ValidationError as e: - return Response(e.detail.get('detail'), status=e.detail.get('status')) +# def post(self, request, *args, **kwargs): +# try: +# message = ChatService.send_message(request.user, kwargs.get('title'), request.data['content']) +# return Response(self.serializer_class(message).data, status=status.HTTP_201_CREATED) +# except ValidationError as e: +# return Response(e.detail.get('detail'), status=e.detail.get('status')) + + +from django.shortcuts import render +from django.contrib.auth.decorators import login_required +from django.utils.safestring import mark_safe +import json + +@login_required +def chat_room(request, room_name): + return render(request, 'chat/room.html', { + 'room_name_json': mark_safe(json.dumps(room_name)) + }) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 830cf05..804991b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,15 @@ asgiref==3.8.1 attrs==24.2.0 +autobahn==24.4.2 +Automat==24.8.1 boto3==1.35.64 botocore==1.35.64 +cffi==1.17.1 +channels==4.2.0 +channels_redis==4.2.1 +constantly==23.10.4 +cryptography==43.0.3 +daphne==4.1.2 Django==5.1.2 django-cors-headers==4.6.0 django-filter==24.3 @@ -9,22 +17,37 @@ django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 +hyperlink==21.0.0 +idna==3.10 +incremental==24.7.2 inflection==0.5.1 jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +msgpack==1.1.0 mysql-connector-python==9.1.0 mysqlclient==2.2.5 pillow==11.0.0 +pyasn1==0.6.1 +pyasn1_modules==0.4.1 +pycparser==2.22 PyJWT==2.9.0 +pyOpenSSL==24.2.1 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 PyYAML==6.0.2 +redis==5.2.0 referencing==0.35.1 rpds-py==0.20.1 s3transfer==0.10.3 +service-identity==24.2.0 +setuptools==75.6.0 six==1.16.0 sqlparse==0.5.1 +Twisted==24.10.0 +txaio==23.1.1 +typing_extensions==4.12.2 tzdata==2024.2 uritemplate==4.1.1 urllib3==2.2.3 +zope.interface==7.1.1 From 48b860d90788a1369ea795ccdc112079d8f67b43 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Sun, 1 Dec 2024 20:17:33 +0330 Subject: [PATCH 34/60] feat: add filter by timeslot (#23) --- home/filters.py | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/home/filters.py b/home/filters.py index 24ca97d..90a306d 100644 --- a/home/filters.py +++ b/home/filters.py @@ -2,10 +2,24 @@ from groups.models import Group class GroupFilter(django_filters.FilterSet): + time_slots = django_filters.CharFilter(method='filter_time_slots') + class Meta: model = Group fields = { 'level': ['exact'], 'neighborhood': ['exact'], - - } \ No newline at end of file + 'time_slots': ['exact'] + } + + def filter_time_slots(self, queryset, name, value): + time_slots = value.split(',') + for time_slot in time_slots: + day, start, end = time_slot.split(':') + queryset = queryset.filter( + day_of_week=day, + start_time=start, + end_time=end + ) + + return queryset \ No newline at end of file From ebdeb3df12fb0abbff437ddb758a1f9a651bfb22 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Wed, 4 Dec 2024 02:06:52 +0330 Subject: [PATCH 35/60] Feat/notification (#24) * feat: add notification app and model * feat: add join notification * feat: add accept request and group update notification * feat: add chat notification * fix: change settings to deployment config --- FD/asgi.py | 7 +- FD/settings.py | 3 +- authentication/services.py | 5 +- authentication/views.py | 6 +- chat/consumers.py | 38 ++++++++++- chat/routing.py | 2 +- chat/templates/chat/room.html | 51 -------------- chat/urls.py | 2 +- chat/views.py | 18 ++--- groups/serializers.py | 7 +- groups/services.py | 47 ++++++++++--- groups/urls.py | 1 + groups/views.py | 28 +++++--- notifications/__init__.py | 0 notifications/admin.py | 3 + notifications/apps.py | 6 ++ notifications/consumers.py | 67 +++++++++++++++++++ notifications/migrations/0001_initial.py | 27 ++++++++ .../0002_remove_notification_is_read.py | 17 +++++ notifications/migrations/__init__.py | 0 notifications/models.py | 10 +++ notifications/routing.py | 7 ++ notifications/serializers.py | 12 ++++ notifications/tests.py | 3 + notifications/urls.py | 0 notifications/views.py | 3 + 26 files changed, 279 insertions(+), 91 deletions(-) delete mode 100644 chat/templates/chat/room.html create mode 100644 notifications/__init__.py create mode 100644 notifications/admin.py create mode 100644 notifications/apps.py create mode 100644 notifications/consumers.py create mode 100644 notifications/migrations/0001_initial.py create mode 100644 notifications/migrations/0002_remove_notification_is_read.py create mode 100644 notifications/migrations/__init__.py create mode 100644 notifications/models.py create mode 100644 notifications/routing.py create mode 100644 notifications/serializers.py create mode 100644 notifications/tests.py create mode 100644 notifications/urls.py create mode 100644 notifications/views.py diff --git a/FD/asgi.py b/FD/asgi.py index d18f737..5b0fa1d 100644 --- a/FD/asgi.py +++ b/FD/asgi.py @@ -10,7 +10,12 @@ # is populated before importing code that may import ORM models. django_asgi_app = get_asgi_application() -from chat.routing import websocket_urlpatterns +from chat.routing import chat_websocket_urlpatterns +from notifications.routing import notifications_websocket_urlpatterns + +websocket_urlpatterns = chat_websocket_urlpatterns + notifications_websocket_urlpatterns + + application = ProtocolTypeRouter( { diff --git a/FD/settings.py b/FD/settings.py index 74f59ef..6741181 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -25,7 +25,6 @@ 'rest_framework', 'drf_spectacular', - # 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'storages', @@ -36,6 +35,7 @@ 'groups', 'home', 'chat', + 'notifications', ] @@ -177,6 +177,7 @@ "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { "hosts": [f"rediss://{parsed_url.netloc}"], + # "hosts": [("127.0.0.1", 6379)], }, }, } \ No newline at end of file diff --git a/authentication/services.py b/authentication/services.py index 42d36db..447af79 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -142,12 +142,13 @@ def confirm_reset_password(uidb64, token, new_password): user = User.objects.get(id=id) - if not PasswordResetTokenGenerator().check_token(user, token): + is_valid = PasswordResetTokenGenerator().check_token(user, token) + if not is_valid: raise ValidationError({'detail': 'The reset link is invalid', 'status': status.HTTP_400_BAD_REQUEST}) user.set_password(new_password) user.save() return user - + \ No newline at end of file diff --git a/authentication/views.py b/authentication/views.py index 050b7fd..4a06f07 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -67,10 +67,12 @@ def post(self, request): class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer - def post(self, request, uidb64, token): + def post(self, request, *args, **kwargs): serializer = PasswordResetConfirmSerializer(data=request.data) serializer.is_valid(raise_exception=True) - + uidb64 = kwargs.get('uidb64') + token = kwargs.get('token') + print(uidb64, token) try: UserService.confirm_reset_password(uidb64, token, serializer.validated_data['password']) return Response("password reset successfully", status=status.HTTP_200_OK) diff --git a/chat/consumers.py b/chat/consumers.py index 1fca73d..3f691c8 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -7,10 +7,19 @@ from rest_framework_simplejwt.tokens import AccessToken from rest_framework_simplejwt.exceptions import InvalidToken, TokenError +from groups.models import Group +from notifications.consumers import NotificationConsumer + from .models import Chat, Message from authentication.models import User +from rest_framework.exceptions import PermissionDenied +from rest_framework import status +from rest_framework.response import Response + class ChatConsumer(WebsocketConsumer): + connected_users = set() + def connect(self): self.room_name = self.scope["url_route"]["kwargs"]["room_name"] self.room_group_name = f"chat_{self.room_name}" @@ -28,14 +37,32 @@ def connect(self): user = self.get_user_from_token(token) if user: self.scope['user'] = user + self.connected_users.add(user.id) else: + self.accept() + self.send(text_data=json.dumps({ + 'type': 'error', + 'message': 'You are not authenticated' + })) self.close() return else: + self.accept() + self.send(text_data=json.dumps({ + 'type': 'error', + 'message': 'You are not authenticated' + })) self.close() return - # Join room group + if user not in self.chat.group.members.all(): + self.accept() + self.send(text_data=json.dumps({ + 'type': 'error', + 'message': 'You are not a member of this group' + })) + self.close() + async_to_sync(self.channel_layer.group_add)( self.room_group_name, self.channel_name ) @@ -50,7 +77,7 @@ def connect(self): def get_chat_messages(self): - return list(self.chat.messages.all().order_by('timestamp').values( + return list(self.chat.messages.all().order_by('-timestamp').values( 'content', 'sender__username', 'timestamp' )) @@ -67,6 +94,7 @@ def disconnect(self, close_code): async_to_sync(self.channel_layer.group_discard)( self.room_group_name, self.channel_name ) + self.connected_users.remove(self.scope["user"].id) def receive(self, text_data): text_data_json = json.loads(text_data) @@ -88,6 +116,12 @@ def receive(self, text_data): "timestamp": db_message.timestamp.isoformat(), } ) + + group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) + print(self.connected_users) + for member in group_members: + print(member.username) + NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") # Receive message from room group def chat_message(self, event): diff --git a/chat/routing.py b/chat/routing.py index b76b063..35a1b39 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -2,6 +2,6 @@ from . import consumers -websocket_urlpatterns = [ +chat_websocket_urlpatterns = [ re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), ] \ No newline at end of file diff --git a/chat/templates/chat/room.html b/chat/templates/chat/room.html deleted file mode 100644 index 7ad233a..0000000 --- a/chat/templates/chat/room.html +++ /dev/null @@ -1,51 +0,0 @@ - -{% load static %} - - - - - Chat Room - - -
-
- - {{ room_name_json|json_script:"room-name" }} - - - \ No newline at end of file diff --git a/chat/urls.py b/chat/urls.py index 93f9aa5..adea2de 100644 --- a/chat/urls.py +++ b/chat/urls.py @@ -6,6 +6,6 @@ # path('/send/', ChatView.as_view()), # path('/retrieve/', ChatView.as_view()), - path('/', chat_room, name='chat_room'), + # path('/', chat_room, name='chat_room'), ] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py index 1af1eb0..db3a0e5 100644 --- a/chat/views.py +++ b/chat/views.py @@ -26,13 +26,13 @@ # return Response(e.detail.get('detail'), status=e.detail.get('status')) -from django.shortcuts import render -from django.contrib.auth.decorators import login_required -from django.utils.safestring import mark_safe -import json +# from django.shortcuts import render +# from django.contrib.auth.decorators import login_required +# from django.utils.safestring import mark_safe +# import json -@login_required -def chat_room(request, room_name): - return render(request, 'chat/room.html', { - 'room_name_json': mark_safe(json.dumps(room_name)) - }) \ No newline at end of file +# @login_required +# def chat_room(request, room_name): +# return render(request, 'chat/room.html', { +# 'room_name_json': mark_safe(json.dumps(room_name)) +# }) \ No newline at end of file diff --git a/groups/serializers.py b/groups/serializers.py index 91f9adc..cb252cf 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -39,10 +39,9 @@ class Meta: class GroupPendingRequestSerializer(serializers.ModelSerializer): - pending_members = serializers.SerializerMethodField(read_only=True) class Meta: - model = Group - fields = ['pending_members'] + model = User + fields = ['username', 'profile_image', 'level'] def get_pending_members(self, obj): return [member.username for member in obj.pending_members.all()] @@ -62,7 +61,7 @@ class GroupKickSerializer(serializers.Serializer): class GroupMemberListSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['username'] + fields = ['username', 'profile_image', 'level'] \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index 6cbe9c2..6d47069 100644 --- a/groups/services.py +++ b/groups/services.py @@ -4,7 +4,11 @@ from groups.models import Group from authentication.models import User from chat.models import Chat +from notifications.consumers import NotificationConsumer +from notifications.models import Notification +from channels.layers import get_channel_layer +from asgiref.sync import async_to_sync class GroupService: @@ -33,7 +37,12 @@ def update_group(title, data): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) - group = Group.objects.get(title=title) + group = Group.objects.get(title=title) + + if data.get('description') != "" and data.get('description') != group.description: + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{member.username} updated the description of {group.title}") + group.description = data.get('description', group.description) group.image = data.get('image', group.image) group.level = data.get('level', group.level) @@ -54,15 +63,34 @@ def delete_group(title): @staticmethod def join_request(title, user): group = Group.objects.get(title=title) - if user.level != group.level: - raise ValidationError({'detail': 'Your level does not match the group level', 'status': status.HTTP_400_BAD_REQUEST}) + message = "" + group_status = "" if group.private: - group.add_pending_member(user) - return "private" + if user in group.pending_members.all(): + raise ValidationError({'detail': 'You have already sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + group.add_pending_member(user) + group_status = "private" + message = f"{user.username} wants to join {group.title}" else: + if user in group.members.all(): + raise ValidationError({'detail': 'You are already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.add_member(user) - return "public" + + group_status = "public" + message = f"{user.username} joined {group.title}" + + NotificationConsumer.send_notification(group.owner, message) + + return group_status + + def cancel_join_request(title, user): + group = Group.objects.get(title=title) + if not group.pending_members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You have not sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + group.pending_members.remove(user) @staticmethod def pending_requests(title): @@ -70,7 +98,7 @@ def pending_requests(title): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) - return group + return group.pending_members.all() @staticmethod def accept_request(title, username): @@ -82,6 +110,7 @@ def accept_request(title, username): user = User.objects.get(username=username) group = Group.objects.get(title=title) group.accept_pending_member(user) + NotificationConsumer.send_notification(user, f"your request to join {group.title} has been accepted") @staticmethod def decline_request(title, username): @@ -119,8 +148,8 @@ def kik_member(title, username): user = User.objects.get(username=username) group = Group.objects.get(title=title) - if not group.members.filter(username=username).exists(): - raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'this user is not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) group.save() diff --git a/groups/urls.py b/groups/urls.py index 35df3fb..b26a6bb 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -10,6 +10,7 @@ path('/members/', GroupMemberListView.as_view()), path('/join/', GroupJoinRequestView.as_view()), + path('/cancel/', GroupCancelRequestView.as_view()), path('/leave/', GroupLeaveView.as_view()), path('/pending/', GroupPendingRequestView.as_view()), diff --git a/groups/views.py b/groups/views.py index 11cab84..a0a1457 100644 --- a/groups/views.py +++ b/groups/views.py @@ -28,7 +28,6 @@ def post(self, request): return Response(e.detail.get('detail'), status=e.detail.get('status')) - class GroupRetrieveView(APIView): serializer_class = GroupRetrieveSerializer @@ -55,6 +54,7 @@ def patch(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + class GroupDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] def delete(self, request, *args, **kwargs): @@ -65,7 +65,6 @@ def delete(self, request, *args, **kwargs): return Response(e.detail.get('detail'), status=e.detail.get('status')) - class GroupJoinRequestView(APIView): permission_classes = [IsAuthenticated] @@ -80,15 +79,29 @@ def post(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + +class GroupCancelRequestView(APIView): + permission_classes = [IsAuthenticated] + def post(self, request, *args, **kwargs): + try: + GroupService.cancel_join_request(kwargs.get('title'), request.user) + return Response("request cancelled successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + + class GroupPendingRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupPendingRequestSerializer + pagination_class = PageNumberPagination def get(self, request, *args, **kwargs): try: - group = GroupService.pending_requests(kwargs.get('title')) - serializer = self.serializer_class(group) - return Response(serializer.data, status=status.HTTP_200_OK) + group_requests = GroupService.pending_requests(kwargs.get('title')) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(group_requests, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -106,8 +119,7 @@ def post(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - - + class GroupDeclineRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupDeclineRequestSerializer @@ -141,7 +153,7 @@ def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.kick_user(kwargs.get('title'), serializer.validated_data['username']) + GroupService.kik_member(kwargs.get('title'), serializer.validated_data['username']) return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) diff --git a/notifications/__init__.py b/notifications/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/admin.py b/notifications/admin.py new file mode 100644 index 0000000..8c38f3f --- /dev/null +++ b/notifications/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/notifications/apps.py b/notifications/apps.py new file mode 100644 index 0000000..001b4f9 --- /dev/null +++ b/notifications/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = 'django.db.models.BigAutoField' + name = 'notifications' diff --git a/notifications/consumers.py b/notifications/consumers.py new file mode 100644 index 0000000..3e678b4 --- /dev/null +++ b/notifications/consumers.py @@ -0,0 +1,67 @@ +import json +from channels.generic.websocket import WebsocketConsumer +from channels.layers import get_channel_layer + +from asgiref.sync import async_to_sync + +from django.core.serializers.json import DjangoJSONEncoder + +from rest_framework_simplejwt.tokens import AccessToken +from rest_framework_simplejwt.exceptions import InvalidToken, TokenError + +from authentication.models import User + +class NotificationConsumer(WebsocketConsumer): + def connect(self): + headers = dict(self.scope['headers']) + auth_header = headers.get(b'authorization', b'').decode() + + if auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + user = self.get_user_from_token(token) + if user: + self.scope['user'] = user + else: + self.close() + return + else: + self.close() + return + + async_to_sync(self.channel_layer.group_add)( + f"user_{user.id}", + self.channel_name + ) + + self.accept() + + def get_user_from_token(self, token): + try: + access_token = AccessToken(token) + user_id = access_token['user_id'] + return User.objects.get(id=user_id) + except (InvalidToken, TokenError, User.DoesNotExist): + return None + + def disconnect(self, close_code): + async_to_sync(self.channel_layer.group_discard)( + f"user_{self.scope['user'].id}", + self.channel_name + ) + + def send_notification(user, message): + notification = { + 'message': message, + } + channel_layer = get_channel_layer() + async_to_sync(channel_layer.group_send)( + f"user_{user.id}", + { + 'type': 'notify', + 'notification': notification, + } + ) + + def notify(self, event): + notification = event['notification'] + self.send(text_data=json.dumps(notification, cls=DjangoJSONEncoder)) \ No newline at end of file diff --git a/notifications/migrations/0001_initial.py b/notifications/migrations/0001_initial.py new file mode 100644 index 0000000..af0e8d5 --- /dev/null +++ b/notifications/migrations/0001_initial.py @@ -0,0 +1,27 @@ +# Generated by Django 5.1.2 on 2024-12-02 19:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Notification', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('message', models.CharField(max_length=255)), + ('is_read', models.BooleanField(default=False)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('recipient', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notifications', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/notifications/migrations/0002_remove_notification_is_read.py b/notifications/migrations/0002_remove_notification_is_read.py new file mode 100644 index 0000000..e555939 --- /dev/null +++ b/notifications/migrations/0002_remove_notification_is_read.py @@ -0,0 +1,17 @@ +# Generated by Django 5.1.2 on 2024-12-03 07:08 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('notifications', '0001_initial'), + ] + + operations = [ + migrations.RemoveField( + model_name='notification', + name='is_read', + ), + ] diff --git a/notifications/migrations/__init__.py b/notifications/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/models.py b/notifications/models.py new file mode 100644 index 0000000..ead25c1 --- /dev/null +++ b/notifications/models.py @@ -0,0 +1,10 @@ +from django.db import models + +class Notification(models.Model): + recipient = models.ForeignKey('authentication.User', on_delete=models.CASCADE, related_name='notifications') + message = models.CharField(max_length=255) + created_at = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return f"Notification for {self.recipient.username}: {self.message}" + diff --git a/notifications/routing.py b/notifications/routing.py new file mode 100644 index 0000000..c40e637 --- /dev/null +++ b/notifications/routing.py @@ -0,0 +1,7 @@ +from django.urls import re_path + +from .consumers import NotificationConsumer + +notifications_websocket_urlpatterns = [ + re_path(r"ws/notifications/", NotificationConsumer.as_asgi()), +] \ No newline at end of file diff --git a/notifications/serializers.py b/notifications/serializers.py new file mode 100644 index 0000000..85a7d97 --- /dev/null +++ b/notifications/serializers.py @@ -0,0 +1,12 @@ +from rest_framework import serializers + +from .models import Notification + +class NotificationSerializer(serializers.ModelSerializer): + + class Meta: + model = Notification + fields = '__all__' + read_only_fields = '__all__' + + diff --git a/notifications/tests.py b/notifications/tests.py new file mode 100644 index 0000000..7ce503c --- /dev/null +++ b/notifications/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/notifications/views.py b/notifications/views.py new file mode 100644 index 0000000..91ea44a --- /dev/null +++ b/notifications/views.py @@ -0,0 +1,3 @@ +from django.shortcuts import render + +# Create your views here. From a759c991145fb2b603fb823245dab284097556ad Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 9 Dec 2024 09:53:29 +0330 Subject: [PATCH 36/60] Fix/sprint3 (#25) * feat: change group title * fix: group update * fix: user update * fix: pagination and forget password * fix: join request * chore: changed spelling * chore: remove old comment --- .gitignore | 4 +++- authentication/serializers.py | 6 ----- authentication/services.py | 16 ++++++++++--- authentication/views.py | 12 ++-------- groups/serializers.py | 7 ------ groups/services.py | 42 ++++++++++++++++++++++++++--------- groups/urls.py | 2 +- groups/views.py | 7 ++---- home/services.py | 6 ++--- 9 files changed, 56 insertions(+), 46 deletions(-) diff --git a/.gitignore b/.gitignore index 56087e2..02fa3dc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,6 @@ Makefile venv/ .env -**/__pycache__/ \ No newline at end of file +**/__pycache__/ +Dockerfile +docker-compose.yml \ No newline at end of file diff --git a/authentication/serializers.py b/authentication/serializers.py index efcbe5b..f2f4383 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -54,12 +54,6 @@ class Meta: fields = ['password'] -class UserUpdateSerializer(serializers.ModelSerializer): - class Meta: - model = User - fields = ['level', 'city', 'neighborhood', 'profile_image'] - - class ChangePasswordSerializer(serializers.Serializer): old_password = serializers.CharField(min_length=8, max_length=64, write_only=True) new_password = serializers.CharField(min_length=8, max_length=64, write_only=True) diff --git a/authentication/services.py b/authentication/services.py index 447af79..41ed5c1 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -75,8 +75,18 @@ def check_level(cls, level): @classmethod def update_user(cls, user, data): - user.level = data.get('level', user.level) - cls.check_level(user.level) + + if 'username' in data and user.username != data.get('username'): + UserService.check_username(data.get('username')) + user.username = data.get('username') + + if 'level' in data and user.level != data.get('level'): + cls.check_level(data.get('level')) + user.level = data.get('level') + + + + user.city = data.get('city', user.city) user.neighborhood = data.get('neighborhood', user.neighborhood) user.profile_image = data.get('profile_image', user.profile_image) @@ -125,7 +135,7 @@ def request_reset_password(email): uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) token = PasswordResetTokenGenerator().make_token(user) - absolute_url = f'https://localhost:5173/reset-password/{uidb64}/{token}' + absolute_url = f'http://localhost:5173/reset-password/{uidb64}/{token}' email_subject = 'Reset your password' email_body = f'Click the link below to reset your password\n{absolute_url}' diff --git a/authentication/views.py b/authentication/views.py index 4a06f07..152146f 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -72,7 +72,6 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) uidb64 = kwargs.get('uidb64') token = kwargs.get('token') - print(uidb64, token) try: UserService.confirm_reset_password(uidb64, token, serializer.validated_data['password']) return Response("password reset successfully", status=status.HTTP_200_OK) @@ -113,20 +112,13 @@ def delete(self, request): class UserUpdateView(APIView): permission_classes = [IsAuthenticated] - serializer_class= UserUpdateSerializer parser_classes = [MultiPartParser, FormParser, JSONParser] def patch(self, request): try: user = request.user - serializer = self.serializer_class(user, data = request.data, partial=True) - - if serializer.is_valid(): - UserService.update_user(user, serializer.validated_data) - return Response(serializer.data, status=status.HTTP_200_OK) - else: - return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) - + UserService.update_user(user, request.data) + return Response("user updated successfully", status=status.HTTP_200_OK) except User.DoesNotExist: return Response("user not found", status=status.HTTP_404_NOT_FOUND) diff --git a/groups/serializers.py b/groups/serializers.py index cb252cf..a7a5acb 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -31,13 +31,6 @@ def get_number_of_members(self, obj): return obj.members.count() -class GroupUpdateSerializer(serializers.ModelSerializer): - class Meta: - model = Group - fields = ['title', 'description', 'image', 'level', 'city', 'neighborhood', 'meeting_url', 'private'] - read_only_fields = ['title'] - - class GroupPendingRequestSerializer(serializers.ModelSerializer): class Meta: model = User diff --git a/groups/services.py b/groups/services.py index 6d47069..ac406d7 100644 --- a/groups/services.py +++ b/groups/services.py @@ -12,9 +12,15 @@ class GroupService: + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + def check_title(title): if Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group title already exists', 'status': status.HTTP_400_BAD_REQUEST}) + + def check_level(level): + if level not in GroupService.VALID_LEVELS: + raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(Group.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) @classmethod def create_group(cls, user, data): @@ -39,19 +45,30 @@ def update_group(title, data): group = Group.objects.get(title=title) - if data.get('description') != "" and data.get('description') != group.description: + if 'title' in data and data.get('title') != group.title: + GroupService.check_title(data.get('title')) + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} has been renamed to {data.get('title')}") + group.title = data.get('title') + + if 'description' in data and data.get('description') != group.description: for member in group.members.all(): NotificationConsumer.send_notification(member, f"{member.username} updated the description of {group.title}") + group.description = data.get('description') + + if 'level' in data and data.get('level') != group.level: + GroupService.check_level(data.get('level')) + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} level has been updated to {data.get('level')}") + group.level = data.get('level') + + group.image = data.get("image", group.image) + group.private = data.get("private", group.private) + group.meeting_url = data.get("meeting_url", group.meeting_url) + group.neighborhood = data.get("neighborhood", group.neighborhood) + group.city = data.get("city", group.city) - group.description = data.get('description', group.description) - group.image = data.get('image', group.image) - group.level = data.get('level', group.level) - group.city = data.get('city', group.city) - group.neighborhood = data.get('neighborhood', group.neighborhood) - group.meeting_url = data.get('meeting_url', group.meeting_url) - group.private = data.get('private', group.private) group.save() - return group @staticmethod def delete_group(title): @@ -69,6 +86,8 @@ def join_request(title, user): if group.private: if user in group.pending_members.all(): raise ValidationError({'detail': 'You have already sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + if user in group.members.all(): + raise ValidationError({'detail': 'You are already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.add_pending_member(user) group_status = "private" @@ -138,13 +157,16 @@ def leave_group(title, user): group.save() @staticmethod - def kik_member(title, username): + def kick_member(title, username): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + if username == Group.objects.get(title=title).owner.username: + raise ValidationError({'detail': 'You cannot kik the owner of the group', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.get(username=username) group = Group.objects.get(title=title) diff --git a/groups/urls.py b/groups/urls.py index b26a6bb..d99226b 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -16,5 +16,5 @@ path('/pending/', GroupPendingRequestView.as_view()), path('/accept/', GroupAcceptRequestView.as_view()), path('/decline/', GroupDeclineRequestView.as_view()), - path('/kik/', GroupKickView.as_view()), + path('/kick/', GroupKickView.as_view()), ] \ No newline at end of file diff --git a/groups/views.py b/groups/views.py index a0a1457..ae5dc59 100644 --- a/groups/views.py +++ b/groups/views.py @@ -43,13 +43,10 @@ def get(self, request, *args, **kwargs): class GroupUpdateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] parser_classes = [MultiPartParser, FormParser, JSONParser] - serializer_class = GroupUpdateSerializer def patch(self, request, *args, **kwargs): - serializer = self.serializer_class(data=request.data) - serializer.is_valid(raise_exception=True) try: - GroupService.update_group(kwargs.get('title'), serializer.validated_data) + GroupService.update_group(kwargs.get('title'), request.data) return Response("group updated successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -153,7 +150,7 @@ def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.kik_member(kwargs.get('title'), serializer.validated_data['username']) + GroupService.kick_member(kwargs.get('title'), serializer.validated_data['username']) return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) diff --git a/home/services.py b/home/services.py index 9161585..466444e 100644 --- a/home/services.py +++ b/home/services.py @@ -144,11 +144,11 @@ def get_suggestions(user) -> list[Group]: if overlap > 0: total_overlap += overlap - if total_overlap > 0: - group_matches.append((group, total_overlap)) + # if total_overlap > 0: + group_matches.append((group, total_overlap)) group_matches.sort(key=lambda x: x[1], reverse=True) - + group_matches = [group[0] for group in group_matches] return group_matches From 8d5ba8549e71a64c01730ae0eb1fbabdff85b55a Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sun, 15 Dec 2024 22:14:49 +0330 Subject: [PATCH 37/60] Fix/websocket header (#27) * fix: notification * fix: chat --- .gitignore | 4 +++- chat/consumers.py | 32 +++++++------------------------- chat/routing.py | 2 +- notifications/consumers.py | 19 ++++++------------- notifications/routing.py | 3 ++- notifications/tests.py | 3 --- notifications/urls.py | 0 notifications/views.py | 3 --- 8 files changed, 19 insertions(+), 47 deletions(-) delete mode 100644 notifications/tests.py delete mode 100644 notifications/urls.py delete mode 100644 notifications/views.py diff --git a/.gitignore b/.gitignore index 02fa3dc..af8808d 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,6 @@ venv/ .env **/__pycache__/ Dockerfile -docker-compose.yml \ No newline at end of file +docker-compose.yml +.Dockerignore +.env.docker \ No newline at end of file diff --git a/chat/consumers.py b/chat/consumers.py index 3f691c8..9b17c19 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -29,31 +29,15 @@ def connect(self): except Chat.DoesNotExist: self.close() - headers = dict(self.scope['headers']) - auth_header = headers.get(b'authorization', b'').decode() - if auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - user = self.get_user_from_token(token) - if user: - self.scope['user'] = user - self.connected_users.add(user.id) - else: - self.accept() - self.send(text_data=json.dumps({ - 'type': 'error', - 'message': 'You are not authenticated' - })) - self.close() - return - else: - self.accept() - self.send(text_data=json.dumps({ - 'type': 'error', - 'message': 'You are not authenticated' - })) + token = self.scope['url_route']['kwargs']['token'] + user = self.get_user_from_token(token) + + if not user: self.close() - return + + self.scope['user'] = user + self.connected_users.add(user.id) if user not in self.chat.group.members.all(): self.accept() @@ -118,9 +102,7 @@ def receive(self, text_data): ) group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) - print(self.connected_users) for member in group_members: - print(member.username) NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") # Receive message from room group diff --git a/chat/routing.py b/chat/routing.py index 35a1b39..eca5f3b 100644 --- a/chat/routing.py +++ b/chat/routing.py @@ -3,5 +3,5 @@ from . import consumers chat_websocket_urlpatterns = [ - re_path(r"ws/chat/(?P\w+)/$", consumers.ChatConsumer.as_asgi()), + re_path(r"ws/chat/(?P\w+)/(?P[-\w.]+)/$", consumers.ChatConsumer.as_asgi()), ] \ No newline at end of file diff --git a/notifications/consumers.py b/notifications/consumers.py index 3e678b4..39ee081 100644 --- a/notifications/consumers.py +++ b/notifications/consumers.py @@ -13,20 +13,13 @@ class NotificationConsumer(WebsocketConsumer): def connect(self): - headers = dict(self.scope['headers']) - auth_header = headers.get(b'authorization', b'').decode() + token = self.scope['url_route']['kwargs']['token'] + user = self.get_user_from_token(token) - if auth_header.startswith('Bearer '): - token = auth_header.split(' ')[1] - user = self.get_user_from_token(token) - if user: - self.scope['user'] = user - else: - self.close() - return - else: - self.close() - return + if not user: + self.close(403) + + self.scope['user'] = user async_to_sync(self.channel_layer.group_add)( f"user_{user.id}", diff --git a/notifications/routing.py b/notifications/routing.py index c40e637..7bedf07 100644 --- a/notifications/routing.py +++ b/notifications/routing.py @@ -3,5 +3,6 @@ from .consumers import NotificationConsumer notifications_websocket_urlpatterns = [ - re_path(r"ws/notifications/", NotificationConsumer.as_asgi()), + re_path(r"ws/notifications/(?P[-\w.]+)/$", NotificationConsumer.as_asgi()), + ] \ No newline at end of file diff --git a/notifications/tests.py b/notifications/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/notifications/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/notifications/urls.py b/notifications/urls.py deleted file mode 100644 index e69de29..0000000 diff --git a/notifications/views.py b/notifications/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/notifications/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. From 16bcc83d7580dacdadb91ad4b3df40991025e9cd Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sun, 15 Dec 2024 22:21:11 +0330 Subject: [PATCH 38/60] Testing/configuration (#28) * test: setup * test: groups * test: home and timeslots * test: accounts * fix: testing yaml file removed --- .gitignore | 2 +- FD/urls.py | 1 - authentication/test.py | 57 +++++++++++ authentication/tests.py | 3 - authentication/urls.py | 10 +- chat/serializers.py | 16 --- chat/services.py | 35 ------- chat/tests.py | 3 - chat/urls.py | 11 -- chat/views.py | 38 ------- groups/test.py | 193 +++++++++++++++++++++++++++++++++++ groups/tests.py | 3 - groups/urls.py | 24 ++--- home/services.py | 8 +- home/test.py | 75 ++++++++++++++ home/tests.py | 3 - home/urls.py | 18 ++-- home/views.py | 6 +- notifications/routing.py | 2 +- notifications/serializers.py | 12 --- requirements.txt | 1 - 21 files changed, 361 insertions(+), 160 deletions(-) create mode 100644 authentication/test.py delete mode 100644 authentication/tests.py delete mode 100644 chat/serializers.py delete mode 100644 chat/services.py delete mode 100644 chat/tests.py delete mode 100644 chat/urls.py delete mode 100644 chat/views.py create mode 100644 groups/test.py delete mode 100644 groups/tests.py create mode 100644 home/test.py delete mode 100644 home/tests.py delete mode 100644 notifications/serializers.py diff --git a/.gitignore b/.gitignore index af8808d..b95b16f 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ venv/ Dockerfile docker-compose.yml .Dockerignore -.env.docker \ No newline at end of file +.env.docker diff --git a/FD/urls.py b/FD/urls.py index 44e5363..cc7cb86 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -8,7 +8,6 @@ path('authentication/', include('authentication.urls')), path('groups/', include('groups.urls')), path('scheduling/', include('home.urls')), - path('chat/', include('chat.urls')), path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), diff --git a/authentication/test.py b/authentication/test.py new file mode 100644 index 0000000..d66bdae --- /dev/null +++ b/authentication/test.py @@ -0,0 +1,57 @@ +from django.test import TestCase + +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from authentication.models import User + +class AuthenticationAPITests(TestCase): + + def setUp(self): + self.client = APIClient() + self.user = User.objects.create_user( + username='testuser', + email='testuser@example.com', + password='testpassword' + ) + self.client.force_authenticate(user=self.user) + + def test_login(self): + url = reverse('login') + data = {'username': 'testuser', 'password': 'testpassword'} + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('access_token', response.data) + self.assertIn('refresh_token', response.data) + + def test_change_password(self): + url = reverse('change-password') + data = {'old_password': 'testpassword', 'new_password': 'newpassword'} + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "password changed successfully") + + def test_retrieve_user(self): + url = reverse('user-retrieve') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['username'], 'testuser') + self.assertEqual(response.data['email'], 'testuser@example.com') + + def test_delete_user(self): + url = reverse('user-delete') + data = {'password': 'testpassword'} + response = self.client.delete(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "user deleted successfully") + self.assertFalse(User.objects.filter(username='testuser').exists()) + + def test_update_user(self): + url = reverse('user-update') + data = {'username': 'newuser'} + response = self.client.patch(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data, "user updated successfully") + self.user.refresh_from_db() + self.assertEqual(self.user.username, 'newuser') \ No newline at end of file diff --git a/authentication/tests.py b/authentication/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/authentication/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/authentication/urls.py b/authentication/urls.py index 4ed3806..30c9ac9 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -9,17 +9,17 @@ path('register/', RegisterUserView.as_view()), path('verify-email/', VerifyEmailView.as_view()), - path('login/', LoginUserView.as_view()), + path('login/', LoginUserView.as_view(), name='login'), path('password-reset/', PasswordResetRequestView.as_view()), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), - path('change-password/', ChangePasswordView.as_view()), + path('change-password/', ChangePasswordView.as_view(), name='change-password'), - path('users/retrieve/', UserRetriveView.as_view()), - path('users/delete/', UserDeleteView.as_view()), - path('users/update/', UserUpdateView.as_view()), + path('users/retrieve/', UserRetriveView.as_view(), name='user-retrieve'), + path('users/delete/', UserDeleteView.as_view(), name='user-delete'), + path('users/update/', UserUpdateView.as_view(), name='user-update'), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), path('token/refresh/', TokenRefreshView.as_view(), name='token_refresh'), diff --git a/chat/serializers.py b/chat/serializers.py deleted file mode 100644 index dcebe1e..0000000 --- a/chat/serializers.py +++ /dev/null @@ -1,16 +0,0 @@ -from rest_framework import serializers - -from .models import * - -class ChatSerializer(serializers.ModelSerializer): - chat = serializers.SerializerMethodField() - sender = serializers.SerializerMethodField() - class Meta: - model = Message - fields = ['id', 'sender', 'chat', 'content', 'timestamp'] - - def get_chat(self, obj): - return obj.chat.group.title - - def get_sender(self, obj): - return obj.sender.username \ No newline at end of file diff --git a/chat/services.py b/chat/services.py deleted file mode 100644 index f9c4733..0000000 --- a/chat/services.py +++ /dev/null @@ -1,35 +0,0 @@ -from rest_framework.exceptions import ValidationError -from rest_framework import status - -from groups.models import Group -from .models import * - -class ChatService: - - @staticmethod - def check_user(user, title): - group = Group.objects.get(title=title) - if not group.members.filter(username=user.username).exists(): - raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) - - @staticmethod - def get_messages(title): - if not Group.objects.filter(title=title).exists(): - raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) - - group = Group.objects.get(title=title) - chat = group.chat - - messages = chat.messages.all() - return messages - - @classmethod - def send_message(cls, user, title, content): - cls.check_user(user, title) - - group = Group.objects.get(title=title) - chat = group.chat - message = Message.objects.create(chat=chat, sender=user, content=content) - return message - - \ No newline at end of file diff --git a/chat/tests.py b/chat/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/chat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/chat/urls.py b/chat/urls.py deleted file mode 100644 index adea2de..0000000 --- a/chat/urls.py +++ /dev/null @@ -1,11 +0,0 @@ -from django.urls import path - -from .views import * - -urlpatterns = [ - # path('/send/', ChatView.as_view()), - # path('/retrieve/', ChatView.as_view()), - - # path('/', chat_room, name='chat_room'), - -] \ No newline at end of file diff --git a/chat/views.py b/chat/views.py deleted file mode 100644 index db3a0e5..0000000 --- a/chat/views.py +++ /dev/null @@ -1,38 +0,0 @@ -# from rest_framework.views import APIView -# from rest_framework.response import Response -# from rest_framework import status -# from rest_framework.permissions import IsAuthenticated - -# from .serializers import * -# from .services import * - -# class ChatView(APIView): -# permission_classes = [IsAuthenticated] -# serializer_class = ChatSerializer - -# def get(self, request, *args, **kwargs): -# try: -# messages = ChatService.get_messages(kwargs.get('title')) -# serializer = self.serializer_class(messages, many=True) -# return Response(serializer.data, status=status.HTTP_200_OK) -# except ValidationError as e: -# return Response(e.detail.get('detail'), status=e.detail.get('status')) - -# def post(self, request, *args, **kwargs): -# try: -# message = ChatService.send_message(request.user, kwargs.get('title'), request.data['content']) -# return Response(self.serializer_class(message).data, status=status.HTTP_201_CREATED) -# except ValidationError as e: -# return Response(e.detail.get('detail'), status=e.detail.get('status')) - - -# from django.shortcuts import render -# from django.contrib.auth.decorators import login_required -# from django.utils.safestring import mark_safe -# import json - -# @login_required -# def chat_room(request, room_name): -# return render(request, 'chat/room.html', { -# 'room_name_json': mark_safe(json.dumps(room_name)) -# }) \ No newline at end of file diff --git a/groups/test.py b/groups/test.py new file mode 100644 index 0000000..715f0a2 --- /dev/null +++ b/groups/test.py @@ -0,0 +1,193 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status + +from groups.models import Group +from groups.services import GroupService +from authentication.models import User + +class TestGroupAPI(TestCase): + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user( + username="testuser", + password="testpassword", + email="test@gmail.com" + ) + + cls.group_data = { + "title": "Test Group", + "description": "This is a test group", + "level": "A1", + "city": "Test City", + "neighborhood": "Test Neighborhood", + "meeting_url": "https://example.com", + "private": False + } + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_create_group_api(self): + url = reverse('create-group') + response = self.client.post(url, self.group_data, format='json') + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + self.assertEqual(Group.objects.count(), 1) + self.assertEqual(Group.objects.get().title, 'Test Group') + + def test_retrieve_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('retrieve-group', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['title'], group.title) + + def test_update_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('update-group', kwargs={'title': group.title}) + updated_data = { + "description": "Updated description", + "level": "B1" + } + response = self.client.patch(url, updated_data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + group.refresh_from_db() + self.assertEqual(group.description, "Updated description") + self.assertEqual(group.level, "B1") + + def test_delete_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('delete-group', kwargs={'title': group.title}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(Group.objects.count(), 0) + + def test_join_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + self.client.force_authenticate(user=other_user) + + url = reverse('join-group', kwargs={'title': group.title}) + + + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.members.filter(username="other_user").exists()) + group.members.remove(other_user) + + group.private = True + group.save() + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.pending_members.filter(username="other_user").exists()) + + def test_cancel_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + url = reverse('cancel-join-request', kwargs={'title': group.title}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + + def test_leave_group_api(self): + group = GroupService.create_group(self.user, self.group_data) + + url = reverse('leave-group', kwargs={'title': group.title}) + response = self.client.post(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.members.filter(username=self.user.username).exists()) + + def test_member_list_api(self): + group = GroupService.create_group(self.user, self.group_data) + url = reverse('retrieve-members', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], group.members.count()) + + def test_pending_member_list_api(self): + group = GroupService.create_group(self.user, self.group_data) + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + url = reverse('retrieve-pending-members', kwargs={'title': group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['count'], group.pending_members.count()) + + def test_accept_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + data = { + "username": other_user.username + } + + url = reverse('accept-request', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertTrue(group.members.filter(username="other_user").exists()) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + + def test_decline_request_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_pending_member(other_user) + self.client.force_authenticate(user=other_user) + + data = { + "username": other_user.username + } + url = reverse('decline-request', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.pending_members.filter(username="other_user").exists()) + + def test_kick_member_api(self): + group = GroupService.create_group(self.user, self.group_data) + + other_user = User.objects.create_user( + username="other_user", + password="testpassword", + email="other@gmail.com" + ) + group.add_member(other_user) + self.client.force_authenticate(user=self.user) + + data = { + "username": other_user.username + } + + url = reverse('kick-member', kwargs={'title': group.title}) + response = self.client.post(url, data, format='json') + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(group.members.filter(username="other_user").exists()) \ No newline at end of file diff --git a/groups/tests.py b/groups/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/groups/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/groups/urls.py b/groups/urls.py index c5d8c94..573437a 100644 --- a/groups/urls.py +++ b/groups/urls.py @@ -2,20 +2,20 @@ from .views import * urlpatterns = [ - path('create/', GroupCreateView.as_view()), - path('/', GroupRetrieveView.as_view()), #retrieve - path('/delete/', GroupDeleteView.as_view()), - path('/update/', GroupUpdateView.as_view()), + path('create/', GroupCreateView.as_view(), name='create-group'), + path('/', GroupRetrieveView.as_view(), name='retrieve-group'), + path('/update/', GroupUpdateView.as_view(), name='update-group'), + path('/delete/', GroupDeleteView.as_view(), name='delete-group'), - path('/members/', GroupMemberListView.as_view()), - path('/join/', GroupJoinRequestView.as_view()), - path('/cancel/', GroupCancelRequestView.as_view()), + path('/join/', GroupJoinRequestView.as_view(), name='join-group'), + path('/cancel/', GroupCancelRequestView.as_view(), name='cancel-join-request'), + path('/leave/', GroupLeaveView.as_view(), name='leave-group'), - path('/leave/', GroupLeaveView.as_view()), - path('/pending/', GroupPendingRequestView.as_view()), - path('/accept/', GroupAcceptRequestView.as_view()), - path('/decline/', GroupDeclineRequestView.as_view()), - path('/kick/', GroupKickView.as_view()), + path('/members/', GroupMemberListView.as_view(), name='retrieve-members'), + path('/pending/', GroupPendingRequestView.as_view(), name='retrieve-pending-members'), + path('/accept/', GroupAcceptRequestView.as_view(), name='accept-request'), + path('/decline/', GroupDeclineRequestView.as_view(), name='decline-request'), + path('/kick/', GroupKickView.as_view(), name='kick-member'), ] \ No newline at end of file diff --git a/home/services.py b/home/services.py index a35701b..c6fdcbe 100644 --- a/home/services.py +++ b/home/services.py @@ -104,14 +104,16 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ return time_slot - def get_group_time_slots(group) -> QuerySet[GroupTimeSlot]: + def get_group_time_slots(title) -> QuerySet[GroupTimeSlot]: + group = Group.objects.get(title=title) return GroupTimeSlot.objects.filter(group=group) @staticmethod - def delete_time_slot(user, time_slot_id: int) -> None: + def delete_time_slot(title, time_slot_id: int) -> None: + group = Group.objects.get(title=title) time_slot = GroupTimeSlot.objects.filter( id=time_slot_id, - user=user + group=group ).first() if not time_slot: diff --git a/home/test.py b/home/test.py new file mode 100644 index 0000000..9c7bba2 --- /dev/null +++ b/home/test.py @@ -0,0 +1,75 @@ +from django.test import TestCase +from django.urls import reverse +from rest_framework.test import APIClient +from rest_framework import status +from authentication.models import User +from groups.models import Group +from home.models import UserTimeSlot, GroupTimeSlot + +class TestHomeAPI(TestCase): + + @classmethod + def setUpTestData(cls): + cls.user = User.objects.create_user(username='testuser', email='user@test.com', password='testpassword') + cls.group = Group.objects.create(title='testgroup', owner=cls.user) + + def setUp(self): + self.client = APIClient() + self.client.force_authenticate(user=self.user) + + def test_home_view(self): + url = reverse('home') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_suggestions_view(self): + url = reverse('suggestions') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_all_groups_view(self): + url = reverse('all-groups') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_user_time_slot(self): + url = reverse('user-time-slot-create') + data = { + 'day_of_week': 'Monday', + 'start_time': 9.00, + 'end_time': 17.00 + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_list_user_time_slots(self): + url = reverse('user-time-slot-list') + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_user_time_slot(self): + time_slot = UserTimeSlot.objects.create(user=self.user, day_of_week='Monday', start_time=9.00, end_time=17.00) + url = reverse('user-time-slot-delete', kwargs={'id': time_slot.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_create_group_time_slot(self): + url = reverse('group-time-slot-create', kwargs={'title': self.group.title}) + data = { + 'day_of_week': 'Monday', + 'start_time': 9.00, + 'end_time': 17.00 + } + response = self.client.post(url, data) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) + + def test_list_group_time_slots(self): + url = reverse('group-time-slot-list', kwargs={'title': self.group.title}) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + def test_delete_group_time_slot(self): + time_slot = GroupTimeSlot.objects.create(group=self.group, day_of_week='Monday', start_time=9.00, end_time=17.00) + url = reverse('group-time-slot-delete', kwargs={'title': self.group.title, 'id': time_slot.id}) + response = self.client.delete(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) \ No newline at end of file diff --git a/home/tests.py b/home/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/home/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/home/urls.py b/home/urls.py index 31a5ff6..ad318a3 100644 --- a/home/urls.py +++ b/home/urls.py @@ -2,18 +2,18 @@ from .views import * urlpatterns = [ - path('', HomeView.as_view()), - path('suggestions/', SuggestionsView.as_view()), - path('all-groups/', AllGroupsView.as_view()), + path('', HomeView.as_view(), name='home'), + path('suggestions/', SuggestionsView.as_view(), name='suggestions'), + path('all-groups/', AllGroupsView.as_view(), name='all-groups'), - path('user-time-slots/create/', UserTimeSlotCreateView.as_view()), - path('user-time-slots/', UserTimeSlotListView.as_view()), - path('user-time-slots//delete/', UserTimeSlotDeleteView.as_view()), + path('user-time-slots/create/', UserTimeSlotCreateView.as_view(), name='user-time-slot-create'), + path('user-time-slots/', UserTimeSlotListView.as_view(), name='user-time-slot-list'), + path('user-time-slots//delete/', UserTimeSlotDeleteView.as_view(), name='user-time-slot-delete'), - path('group-time-slots//create/', GroupTimeSlotCreateView.as_view()), - path('group-time-slots//', GroupTimeSlotListView.as_view()), - path('group-time-slots///delete/', GroupTimeSlotDeleteView.as_view()), + path('group-time-slots//create/', GroupTimeSlotCreateView.as_view(), name='group-time-slot-create'), + path('group-time-slots//', GroupTimeSlotListView.as_view(), name='group-time-slot-list'), + path('group-time-slots///delete/', GroupTimeSlotDeleteView.as_view(), name='group-time-slot-delete'), path('neighborhoods/', get_table_data, name='table_data'), ] diff --git a/home/views.py b/home/views.py index 8c68b22..0fc5c31 100644 --- a/home/views.py +++ b/home/views.py @@ -143,8 +143,8 @@ class GroupTimeSlotListView(APIView): serializer_class = GroupTimeSlotSerializer - def get(self, request): - time_slots = GroupTimeSlotService.get_group_time_slots(request.user) + def get(self, request, *args, **kwargs): + time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) serializer = self.serializer_class(time_slots, many=True) return Response(serializer.data, status=status.HTTP_200_OK) @@ -154,7 +154,7 @@ class GroupTimeSlotDeleteView(APIView): def delete(self, request, *args, **kwargs): try: - GroupTimeSlotService.delete_group_time_slot(request.user, kwargs.get('id')) + GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id')) return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) diff --git a/notifications/routing.py b/notifications/routing.py index 7bedf07..6cdd8c8 100644 --- a/notifications/routing.py +++ b/notifications/routing.py @@ -5,4 +5,4 @@ notifications_websocket_urlpatterns = [ re_path(r"ws/notifications/(?P[-\w.]+)/$", NotificationConsumer.as_asgi()), -] \ No newline at end of file +] diff --git a/notifications/serializers.py b/notifications/serializers.py deleted file mode 100644 index 85a7d97..0000000 --- a/notifications/serializers.py +++ /dev/null @@ -1,12 +0,0 @@ -from rest_framework import serializers - -from .models import Notification - -class NotificationSerializer(serializers.ModelSerializer): - - class Meta: - model = Notification - fields = '__all__' - read_only_fields = '__all__' - - diff --git a/requirements.txt b/requirements.txt index c97b717..0558460 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,6 @@ jsonschema==4.23.0 jsonschema-specifications==2024.10.1 msgpack==1.1.0 mysql-connector-python==9.1.0 -mysqlclient==2.2.5 pillow==11.0.0 pyasn1==0.6.1 pyasn1_modules==0.4.1 From 8a10ff7db18403698ae90d525c982cf11ead99eb Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 16 Dec 2024 02:38:39 +0330 Subject: [PATCH 39/60] fix: group time slot not found error (#29) --- home/services.py | 4 ++++ home/views.py | 9 ++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/home/services.py b/home/services.py index c6fdcbe..0beb269 100644 --- a/home/services.py +++ b/home/services.py @@ -105,11 +105,15 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ return time_slot def get_group_time_slots(title) -> QuerySet[GroupTimeSlot]: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) return GroupTimeSlot.objects.filter(group=group) @staticmethod def delete_time_slot(title, time_slot_id: int) -> None: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) time_slot = GroupTimeSlot.objects.filter( id=time_slot_id, diff --git a/home/views.py b/home/views.py index 0fc5c31..ebca9b3 100644 --- a/home/views.py +++ b/home/views.py @@ -144,9 +144,12 @@ class GroupTimeSlotListView(APIView): serializer_class = GroupTimeSlotSerializer def get(self, request, *args, **kwargs): - time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) - serializer = self.serializer_class(time_slots, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) class GroupTimeSlotDeleteView(APIView): From b6f31c1d9062b130b2658050756aceb8a57d9d7c Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 16 Dec 2024 02:40:43 +0330 Subject: [PATCH 40/60] Feat/monitoring (#30) * feat: setup monitoring configuration * feat: first issue --- FD/settings.py | 17 ++++++++++++++++- FD/urls.py | 4 ++++ requirements.txt | 18 +++++++++++++++++- 3 files changed, 37 insertions(+), 2 deletions(-) diff --git a/FD/settings.py b/FD/settings.py index 23525eb..8c1fc8d 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -183,4 +183,19 @@ # "hosts": [("127.0.0.1", 6379)], }, }, -} \ No newline at end of file +} + +import sentry_sdk + +sentry_sdk.init( + dsn="https://ccb764743ed51a9f962c8417f8dfe02a@o4508450288631808.ingest.de.sentry.io/4508450290663504", + # Set traces_sample_rate to 1.0 to capture 100% + # of transactions for tracing. + traces_sample_rate=1.0, + _experiments={ + # Set continuous_profiling_auto_start to True + # to automatically start the profiler on when + # possible. + "continuous_profiling_auto_start": True, + }, +) \ No newline at end of file diff --git a/FD/urls.py b/FD/urls.py index cc7cb86..58e32a4 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -2,7 +2,11 @@ from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +def trigger_error(request): + division_by_zero = 1 / 0 + urlpatterns = [ + path('trigger_error/', trigger_error), path('admin/', admin.site.urls), path('authentication/', include('authentication.urls')), diff --git a/requirements.txt b/requirements.txt index 0558460..278c5d0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,10 +4,13 @@ autobahn==24.4.2 Automat==24.8.1 boto3==1.35.64 botocore==1.35.64 +certifi==2024.8.30 cffi==1.17.1 channels==4.2.0 channels_redis==4.2.1 +colorama==0.4.6 constantly==23.10.4 +coverage==7.6.9 cryptography==43.0.3 daphne==4.1.2 Django==5.1.2 @@ -17,21 +20,33 @@ django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 +execnet==2.1.1 +flake8==7.1.1 +hiredis==3.0.0 hyperlink==21.0.0 idna==3.10 incremental==24.7.2 inflection==0.5.1 +iniconfig==2.0.0 jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +mccabe==0.7.0 msgpack==1.1.0 mysql-connector-python==9.1.0 +packaging==24.2 pillow==11.0.0 +pluggy==1.5.0 pyasn1==0.6.1 pyasn1_modules==0.4.1 +pycodestyle==2.12.1 pycparser==2.22 +pyflakes==3.2.0 PyJWT==2.9.0 pyOpenSSL==24.2.1 +pytest==8.3.4 +pytest-django==4.9.0 +pytest-xdist==3.6.1 python-dateutil==2.9.0.post0 python-dotenv==1.0.1 PyYAML==6.0.2 @@ -39,6 +54,7 @@ redis==5.2.0 referencing==0.35.1 rpds-py==0.20.1 s3transfer==0.10.3 +sentry-sdk==2.19.2 service-identity==24.2.0 setuptools==75.6.0 six==1.16.0 @@ -49,4 +65,4 @@ typing_extensions==4.12.2 tzdata==2024.2 uritemplate==4.1.1 urllib3==2.2.3 -zope.interface==7.1.1 \ No newline at end of file +zope.interface==7.1.1 From 29d8b37d9521ea7a5fc978346d84a5265ea63a43 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 17 Dec 2024 00:55:30 +0330 Subject: [PATCH 41/60] Fix/image names (#31) * fix: user profile image * fix: group profile image --- authentication/services.py | 26 +++++++++++++++++++++++--- groups/services.py | 32 +++++++++++++++++++++++++++++++- requirements.txt | 6 ++++++ 3 files changed, 60 insertions(+), 4 deletions(-) diff --git a/authentication/services.py b/authentication/services.py index 41ed5c1..96ff984 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -5,10 +5,11 @@ from django.contrib.auth.tokens import PasswordResetTokenGenerator from django.core.mail import EmailMessage - from rest_framework.exceptions import ValidationError from rest_framework import status +import boto3 + from .models import * from .utils import send_otp_email from FD import settings @@ -16,6 +17,16 @@ class UserService: VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'] + + @staticmethod + def delete_s3_object(file_path): + s3 = boto3.client('s3', + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_path) + @staticmethod @@ -84,12 +95,21 @@ def update_user(cls, user, data): cls.check_level(data.get('level')) user.level = data.get('level') + if not user.profile_image and data.get('profile_image'): + path = data.get('profile_image').name + f'_{user.id}' + user.profile_image.save(path, data.get('profile_image')) + if user.profile_image and not data.get('profile_image'): + cls.delete_s3_object(user.profile_image.name) + user.profile_image = None - + if user.profile_image and data.get('profile_image') and user.profile_image != data.get('profile_image'): + cls.delete_s3_object(user.profile_image.name) + path = data.get('profile_image').name + f'_{user.id}' + user.profile_image.save(path, data.get('profile_image')) + user.city = data.get('city', user.city) user.neighborhood = data.get('neighborhood', user.neighborhood) - user.profile_image = data.get('profile_image', user.profile_image) user.save() return user diff --git a/groups/services.py b/groups/services.py index ac406d7..83284f3 100644 --- a/groups/services.py +++ b/groups/services.py @@ -7,6 +7,10 @@ from notifications.consumers import NotificationConsumer from notifications.models import Notification +import boto3 + +from FD import settings + from channels.layers import get_channel_layer from asgiref.sync import async_to_sync @@ -14,6 +18,16 @@ class GroupService: VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + @staticmethod + def delete_s3_object(file_path): + s3 = boto3.client('s3', + aws_access_key_id=settings.AWS_ACCESS_KEY_ID, + aws_secret_access_key=settings.AWS_SECRET_ACCESS_KEY, + endpoint_url=settings.AWS_S3_ENDPOINT_URL, + ) + s3.delete_object(Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_path) + + def check_title(title): if Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group title already exists', 'status': status.HTTP_400_BAD_REQUEST}) @@ -62,7 +76,23 @@ def update_group(title, data): NotificationConsumer.send_notification(member, f"{group.title} level has been updated to {data.get('level')}") group.level = data.get('level') - group.image = data.get("image", group.image) + print(data.get('image')) + if not group.image and data.get('image'): + print(1) + path = data.get('image').name + f'_{group.id}' + group.image.save(path, data.get('image')) + + if group.image and not data.get('image'): + print(2) + GroupService.delete_s3_object(group.image.name) + group.image = None + + if group.image and data.get('image') and group.image != data.get('image'): + print(3) + GroupService.delete_s3_object(group.image.name) + path = data.get('image').name + f'_{group.id}' + group.image.save(path, data.get('image')) + group.private = data.get("private", group.private) group.meeting_url = data.get("meeting_url", group.meeting_url) group.neighborhood = data.get("neighborhood", group.neighborhood) diff --git a/requirements.txt b/requirements.txt index 278c5d0..4ae52c0 100644 --- a/requirements.txt +++ b/requirements.txt @@ -2,6 +2,7 @@ asgiref==3.8.1 attrs==24.2.0 autobahn==24.4.2 Automat==24.8.1 +autopep8==2.3.1 boto3==1.35.64 botocore==1.35.64 certifi==2024.8.30 @@ -16,12 +17,15 @@ daphne==4.1.2 Django==5.1.2 django-cors-headers==4.6.0 django-filter==24.3 +django-prometheus==2.3.1 +django-silk==5.3.2 django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 execnet==2.1.1 flake8==7.1.1 +gprof2dot==2024.6.6 hiredis==3.0.0 hyperlink==21.0.0 idna==3.10 @@ -34,9 +38,11 @@ jsonschema-specifications==2024.10.1 mccabe==0.7.0 msgpack==1.1.0 mysql-connector-python==9.1.0 +mysqlclient==2.2.5 packaging==24.2 pillow==11.0.0 pluggy==1.5.0 +prometheus_client==0.21.1 pyasn1==0.6.1 pyasn1_modules==0.4.1 pycodestyle==2.12.1 From dd7c1652493cdc98f582499f45213ce8c12d3f13 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 17 Dec 2024 01:45:40 +0330 Subject: [PATCH 42/60] Feat/profiling logging (#32) * feat: prometheus setup * feat: silk profiling setup * feat: user apis profiling * feat: group apis profiling * feat: scheduling apis profiling --- FD/settings.py | 11 ++++++++- FD/urls.py | 4 ++++ ...r_profile_image_0003_alter_tempuser_otp.py | 14 +++++++++++ authentication/views.py | 13 +++++++++- groups/views.py | 24 +++++++++++++++---- home/views.py | 17 ++++++++----- 6 files changed, 71 insertions(+), 12 deletions(-) create mode 100644 authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py diff --git a/FD/settings.py b/FD/settings.py index 8c1fc8d..b9a1a1d 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -31,6 +31,8 @@ 'storages', 'django_filters', 'channels', + 'django_prometheus', + 'silk', 'authentication', 'groups', @@ -42,7 +44,9 @@ ] MIDDLEWARE = [ + 'django_prometheus.middleware.PrometheusBeforeMiddleware', 'corsheaders.middleware.CorsMiddleware', + 'django.middleware.security.SecurityMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', @@ -51,6 +55,9 @@ 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + + 'silk.middleware.SilkyMiddleware', + 'django_prometheus.middleware.PrometheusAfterMiddleware', ] CORS_ALLOW_ALL_ORIGINS = True @@ -198,4 +205,6 @@ # possible. "continuous_profiling_auto_start": True, }, -) \ No newline at end of file +) + +SILKY_PYTHON_PROFILER = True diff --git a/FD/urls.py b/FD/urls.py index 58e32a4..0e7994d 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -1,12 +1,16 @@ from django.contrib import admin from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView +from django_prometheus import exports def trigger_error(request): division_by_zero = 1 / 0 urlpatterns = [ path('trigger_error/', trigger_error), + path('prometheus/', include('django_prometheus.urls')), + path("silk/", include("silk.urls", namespace="silk")), + path('admin/', admin.site.urls), path('authentication/', include('authentication.urls')), diff --git a/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py b/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py new file mode 100644 index 0000000..ccf3a62 --- /dev/null +++ b/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py @@ -0,0 +1,14 @@ +# Generated by Django 5.1.2 on 2024-12-15 23:26 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0002_user_profile_image'), + ('authentication', '0003_alter_tempuser_otp'), + ] + + operations = [ + ] diff --git a/authentication/views.py b/authentication/views.py index ab6cd6d..9bf9e82 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -4,9 +4,10 @@ from rest_framework.response import Response from rest_framework import status from rest_framework.parsers import MultiPartParser, FormParser, JSONParser - from rest_framework.permissions import IsAuthenticated +from silk.profiling.profiler import silk_profile + from .serializers import * from .models import * from .services import * @@ -14,6 +15,7 @@ class RegisterUserView(APIView): serializer_class = UserCreateSerializer + @silk_profile(name='register') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -27,6 +29,7 @@ def post(self, request): class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer + @silk_profile(name='verify-email') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -42,6 +45,7 @@ def post(self, request): class LoginUserView(APIView): serializer_class = UserLoginSerializer + @silk_profile(name='login') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -55,6 +59,7 @@ def post(self, request): class PasswordResetRequestView(APIView): serializer_class = PasswordResetRequestSerializer + @silk_profile(name='forget-password') def post(self, request): serializer = PasswordResetRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -68,6 +73,8 @@ def post(self, request): class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer + + @silk_profile(name='forget-password-confirm') def post(self, request, *args, **kwargs): serializer = PasswordResetConfirmSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -85,6 +92,7 @@ class UserRetriveView(APIView): permission_classes = [IsAuthenticated] serializer_class= UserRetriveSerializer + @silk_profile(name='user-retrive') def get(self, request): user = request.user @@ -97,6 +105,7 @@ class UserDeleteView(APIView): permission_classes = [IsAuthenticated] serializer_class= UserDeleteSerializer + @silk_profile(name='user-delete') def delete(self, request): user = request.user @@ -115,6 +124,7 @@ class UserUpdateView(APIView): permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] + @silk_profile(name='user-update') def patch(self, request): try: user = request.user @@ -127,6 +137,7 @@ def patch(self, request): class ChangePasswordView(APIView): serializer_class= ChangePasswordSerializer + @silk_profile(name='change-password') def patch(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) diff --git a/groups/views.py b/groups/views.py index 0da1889..e33ea8a 100644 --- a/groups/views.py +++ b/groups/views.py @@ -5,6 +5,7 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.pagination import PageNumberPagination +from silk.profiling.profiler import silk_profile from authentication.models import User from .serializers import * @@ -18,6 +19,7 @@ class GroupCreateView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupCreateSerializer + @silk_profile(name='group-create') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -32,6 +34,7 @@ def post(self, request): class GroupRetrieveView(APIView): serializer_class = GroupRetrieveSerializer + @silk_profile(name='group-retrieve') def get(self, request, *args, **kwargs): try: group = GroupService.retrieve_group(kwargs.get('title')) @@ -46,6 +49,7 @@ class GroupUpdateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] parser_classes = [MultiPartParser, FormParser, JSONParser] + @silk_profile(name='group-update') def patch(self, request, *args, **kwargs): try: GroupService.update_group(kwargs.get('title'), request.data) @@ -57,16 +61,20 @@ def patch(self, request, *args, **kwargs): class GroupDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] + + @silk_profile(name='group-delete') def delete(self, request, *args, **kwargs): try: GroupService.delete_group(kwargs.get('title')) return Response("group deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + + class GroupJoinRequestView(APIView): permission_classes = [IsAuthenticated] + @silk_profile(name='group-join-request') def post(self, request, *args, **kwargs): try: type = GroupService.join_request(kwargs.get('title'), request.user) @@ -81,6 +89,8 @@ def post(self, request, *args, **kwargs): class GroupCancelRequestView(APIView): permission_classes = [IsAuthenticated] + + @silk_profile(name='group-join-request-cancel') def post(self, request, *args, **kwargs): try: GroupService.cancel_join_request(kwargs.get('title'), request.user) @@ -94,6 +104,7 @@ class GroupPendingRequestView(APIView): serializer_class = GroupPendingRequestSerializer pagination_class = PageNumberPagination + @silk_profile(name='group-pending-request') def get(self, request, *args, **kwargs): try: group_requests = GroupService.pending_requests(kwargs.get('title')) @@ -109,6 +120,7 @@ class GroupAcceptRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupAcceptRequestSerializer + @silk_profile(name='group-accept-request') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -117,12 +129,13 @@ def post(self, request, *args, **kwargs): return Response("request accepted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - - + + class GroupDeclineRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupDeclineRequestSerializer + @silk_profile(name='group-decline-request') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -132,11 +145,11 @@ def post(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - class GroupLeaveView(APIView): permission_classes = [IsAuthenticated, IsGroupMember] + @silk_profile(name='group-leave') def post(self, request, *args, **kwargs): try: GroupService.leave_group(kwargs.get('title'), request.user) @@ -150,6 +163,8 @@ class GroupKickView(APIView): serializer_class = GroupKickSerializer permission_classes = [IsAuthenticated, IsGroupOwner] + + @silk_profile(name='group-kick-member') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -166,6 +181,7 @@ class GroupMemberListView(APIView): pagination_class = PageNumberPagination + @silk_profile(name='group-member-list') def get(self, request, *args, **kwargs): try: members = GroupService.member_list(kwargs.get('title')) diff --git a/home/views.py b/home/views.py index ebca9b3..bec7edd 100644 --- a/home/views.py +++ b/home/views.py @@ -9,7 +9,7 @@ from django_filters.rest_framework import DjangoFilterBackend - +from silk.profiling.profiler import silk_profile from .serializers import * from .services import * @@ -24,6 +24,7 @@ class HomeView(APIView): filter_backends = [SearchFilter] search_fields = ['title'] + @silk_profile(name='my-groups') def get(self, request): joined_groups = HomeService.get_joined_groups(request.user, request) filtered_groups = self.filter_queryset(joined_groups) @@ -44,6 +45,7 @@ class SuggestionsView(APIView): serializer_class = SuggestionSerializer pagination_class = PageNumberPagination + @silk_profile(name='suggestions') def get(self, request): suggestions = SuggestionService.get_suggestions(request.user) paginator = self.pagination_class() @@ -61,6 +63,7 @@ class AllGroupsView(APIView): search_fields = ['title'] ordering_fields = ['level', 'member_count'] + @silk_profile(name='all-groups') def get(self, request): groups = AllGroupsService.get_all_groups() groups = groups.annotate(member_count=Count('members')) @@ -78,10 +81,10 @@ def filter_queryset(self, queryset): class UserTimeSlotCreateView(APIView): - permission_classes = [IsAuthenticated] - + permission_classes = [IsAuthenticated] serializer_class = UserTimeSlotSerializer + @silk_profile(name='create-user-time-slot') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -97,9 +100,9 @@ def post(self, request): class UserTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = UserTimeSlotSerializer + @silk_profile(name='user-time-slots-retrieve') def get(self, request): time_slots = UserTimeSlotService.get_user_time_slots(request.user) serializer = self.serializer_class(time_slots, many=True) @@ -109,6 +112,7 @@ def get(self, request): class UserTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated] + @silk_profile(name='user-time-slot-delete') def delete(self, request, *args, **kwargs): try: UserTimeSlotService.delete_time_slot(request.user, kwargs.get('id')) @@ -121,9 +125,9 @@ def delete(self, request, *args, **kwargs): class GroupTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] - serializer_class = GroupTimeSlotSerializer + @silk_profile(name='create-group-time-slot') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -140,9 +144,9 @@ def post(self, request, *args, **kwargs): class GroupTimeSlotListView(APIView): permission_classes = [IsAuthenticated] - serializer_class = GroupTimeSlotSerializer + @silk_profile(name='group-time-slots-retrieve') def get(self, request, *args, **kwargs): try: time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) @@ -155,6 +159,7 @@ def get(self, request, *args, **kwargs): class GroupTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] + @silk_profile(name='group-time-slot-delete') def delete(self, request, *args, **kwargs): try: GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id')) From c33c2342213b2bfee7a5c54a1ddb9b56a6a8270b Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 17 Dec 2024 06:08:46 +0330 Subject: [PATCH 43/60] Feat/user and group deletion (#33) * feat: user deletion * feat: user deletion test * feat: group deletion ant test --- .../migrations/0002_user_profile_image.py | 18 ---------- ...r_profile_image_0003_alter_tempuser_otp.py | 14 -------- authentication/services.py | 16 +++++++++ authentication/test.py | 35 ++++++++++++++----- authentication/urls.py | 2 +- authentication/views.py | 34 +++++++----------- groups/services.py | 9 +++-- groups/test.py | 22 +++++++++++- 8 files changed, 84 insertions(+), 66 deletions(-) delete mode 100644 authentication/migrations/0002_user_profile_image.py delete mode 100644 authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py diff --git a/authentication/migrations/0002_user_profile_image.py b/authentication/migrations/0002_user_profile_image.py deleted file mode 100644 index eff884f..0000000 --- a/authentication/migrations/0002_user_profile_image.py +++ /dev/null @@ -1,18 +0,0 @@ -# Generated by Django 5.1.2 on 2024-11-18 22:07 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0001_initial'), - ] - - operations = [ - migrations.AddField( - model_name='user', - name='profile_image', - field=models.ImageField(blank=True, null=True, upload_to='profile_images/'), - ), - ] diff --git a/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py b/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py deleted file mode 100644 index ccf3a62..0000000 --- a/authentication/migrations/0004_merge_0002_user_profile_image_0003_alter_tempuser_otp.py +++ /dev/null @@ -1,14 +0,0 @@ -# Generated by Django 5.1.2 on 2024-12-15 23:26 - -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('authentication', '0002_user_profile_image'), - ('authentication', '0003_alter_tempuser_otp'), - ] - - operations = [ - ] diff --git a/authentication/services.py b/authentication/services.py index 96ff984..2c46126 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -14,6 +14,8 @@ from .utils import send_otp_email from FD import settings +from notifications.consumers import NotificationConsumer + class UserService: VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2', 'a1', 'a2', 'b1', 'b2', 'c1', 'c2'] @@ -180,5 +182,19 @@ def confirm_reset_password(uidb64, token, new_password): user.save() return user + + @classmethod + def delete_account(cls, user): + for group in user.owned_groups.all(): + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + group.delete() + + if user.profile_image: + cls.delete_s3_object(user.profile_image.name) + + user.delete() + + \ No newline at end of file diff --git a/authentication/test.py b/authentication/test.py index d66bdae..ea5b46f 100644 --- a/authentication/test.py +++ b/authentication/test.py @@ -5,6 +5,7 @@ from rest_framework.test import APIClient from rest_framework import status from authentication.models import User +from groups.models import Group class AuthenticationAPITests(TestCase): @@ -39,14 +40,6 @@ def test_retrieve_user(self): self.assertEqual(response.data['username'], 'testuser') self.assertEqual(response.data['email'], 'testuser@example.com') - def test_delete_user(self): - url = reverse('user-delete') - data = {'password': 'testpassword'} - response = self.client.delete(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, "user deleted successfully") - self.assertFalse(User.objects.filter(username='testuser').exists()) - def test_update_user(self): url = reverse('user-update') data = {'username': 'newuser'} @@ -54,4 +47,28 @@ def test_update_user(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, "user updated successfully") self.user.refresh_from_db() - self.assertEqual(self.user.username, 'newuser') \ No newline at end of file + self.assertEqual(self.user.username, 'newuser') + + def test_delete_account(self): + test_group = Group.objects.create(title='testgroup', owner=self.user) + other_user = User.objects.create_user( + username='otheruser', + email='otheruser@example.com', + password='otherpassword' + ) + + test_group.add_member(other_user) + + url = reverse('delete-account') + response = self.client.delete(url) + + + self.assertEqual(response.data, "account deleted successfully") + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(other_user.groups.count(), 0) + test_group.delete() + other_user.delete() + + def cleanup(self): + self.user.delete() + \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 30c9ac9..fa76bf0 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -18,7 +18,7 @@ path('change-password/', ChangePasswordView.as_view(), name='change-password'), path('users/retrieve/', UserRetriveView.as_view(), name='user-retrieve'), - path('users/delete/', UserDeleteView.as_view(), name='user-delete'), + path('users/delete/', DeleteAccountView.as_view(), name='delete-account'), path('users/update/', UserUpdateView.as_view(), name='user-update'), path('token/', TokenObtainPairView.as_view(), name='token_obtain_pair'), diff --git a/authentication/views.py b/authentication/views.py index 9bf9e82..338e705 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -98,27 +98,7 @@ def get(self, request): serializer = self.serializer_class(user) - return Response(serializer.data, status=status.HTTP_200_OK) - - -class UserDeleteView(APIView): - permission_classes = [IsAuthenticated] - serializer_class= UserDeleteSerializer - - @silk_profile(name='user-delete') - def delete(self, request): - - user = request.user - serializer = self.serializer_class(data = request.data) - - serializer.is_valid(raise_exception=True) - - if not user.check_password(serializer.validated_data.get('password')): - return Response("invalid password", status=status.HTTP_400_BAD_REQUEST) - - user.delete() - return Response("user deleted successfully", status=status.HTTP_200_OK) - + return Response(serializer.data, status=status.HTTP_200_OK) class UserUpdateView(APIView): permission_classes = [IsAuthenticated] @@ -158,3 +138,15 @@ def patch(self, request): return Response(e.detail.get('detail'), status=e.detail.get('status')) return Response("password changed successfully", status=status.HTTP_200_OK) + + +class DeleteAccountView(APIView): + permission_classes = [IsAuthenticated] + + @silk_profile(name='delete-account') + def delete(self, request): + try: + UserService.delete_account(request.user) + return Response("account deleted successfully", status=status.HTTP_200_OK) + except: + return Response("account not deleted", status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/groups/services.py b/groups/services.py index 83284f3..cb45e2c 100644 --- a/groups/services.py +++ b/groups/services.py @@ -100,11 +100,16 @@ def update_group(title, data): group.save() - @staticmethod - def delete_group(title): + @classmethod + def delete_group(cls, title): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + for member in group.members.all(): + NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + if group.image: + cls.delete_s3_object(group.image.name) + group.delete() @staticmethod diff --git a/groups/test.py b/groups/test.py index 715f0a2..7db0e72 100644 --- a/groups/test.py +++ b/groups/test.py @@ -43,6 +43,7 @@ def test_retrieve_group_api(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['title'], group.title) + group.delete() def test_update_group_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -56,11 +57,13 @@ def test_update_group_api(self): group.refresh_from_db() self.assertEqual(group.description, "Updated description") self.assertEqual(group.level, "B1") + group.delete() def test_delete_group_api(self): group = GroupService.create_group(self.user, self.group_data) url = reverse('delete-group', kwargs={'title': group.title}) response = self.client.delete(url) + self.assertEqual(self.user.groups.count(), 0) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(Group.objects.count(), 0) @@ -87,6 +90,8 @@ def test_join_request_api(self): response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() def test_cancel_request_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -103,6 +108,8 @@ def test_cancel_request_api(self): response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() def test_leave_group_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -111,6 +118,7 @@ def test_leave_group_api(self): response = self.client.post(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(group.members.filter(username=self.user.username).exists()) + group.delete() def test_member_list_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -118,6 +126,7 @@ def test_member_list_api(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], group.members.count()) + group.delete() def test_pending_member_list_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -131,6 +140,8 @@ def test_pending_member_list_api(self): response = self.client.get(url) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['count'], group.pending_members.count()) + other_user.delete() + group.delete() def test_accept_request_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -152,6 +163,8 @@ def test_accept_request_api(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue(group.members.filter(username="other_user").exists()) self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() def test_decline_request_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -171,6 +184,8 @@ def test_decline_request_api(self): response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertFalse(group.pending_members.filter(username="other_user").exists()) + other_user.delete() + group.delete() def test_kick_member_api(self): group = GroupService.create_group(self.user, self.group_data) @@ -190,4 +205,9 @@ def test_kick_member_api(self): url = reverse('kick-member', kwargs={'title': group.title}) response = self.client.post(url, data, format='json') self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertFalse(group.members.filter(username="other_user").exists()) \ No newline at end of file + self.assertFalse(group.members.filter(username="other_user").exists()) + other_user.delete() + group.delete() + + def cleanup(self): + self.user.delete() \ No newline at end of file From 53eec4ff84c66f10d737040c5a8cc4e8b40b92db Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 17 Dec 2024 17:45:26 +0330 Subject: [PATCH 44/60] feat: redis setup on server (#34) --- FD/settings.py | 13 +------------ groups/services.py | 4 ---- 2 files changed, 1 insertion(+), 16 deletions(-) diff --git a/FD/settings.py b/FD/settings.py index b9a1a1d..510dc41 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -176,18 +176,12 @@ -import urllib.parse -import redis - -redis_url = os.environ.get('REDIS_URL') -parsed_url = urllib.parse.urlparse(redis_url) CHANNEL_LAYERS = { "default": { "BACKEND": "channels_redis.core.RedisChannelLayer", "CONFIG": { - "hosts": [f"rediss://{parsed_url.netloc}"], - # "hosts": [("127.0.0.1", 6379)], + "hosts": [(os.getenv("REDIS_URL"))], }, }, } @@ -196,13 +190,8 @@ sentry_sdk.init( dsn="https://ccb764743ed51a9f962c8417f8dfe02a@o4508450288631808.ingest.de.sentry.io/4508450290663504", - # Set traces_sample_rate to 1.0 to capture 100% - # of transactions for tracing. traces_sample_rate=1.0, _experiments={ - # Set continuous_profiling_auto_start to True - # to automatically start the profiler on when - # possible. "continuous_profiling_auto_start": True, }, ) diff --git a/groups/services.py b/groups/services.py index cb45e2c..51978f4 100644 --- a/groups/services.py +++ b/groups/services.py @@ -76,19 +76,15 @@ def update_group(title, data): NotificationConsumer.send_notification(member, f"{group.title} level has been updated to {data.get('level')}") group.level = data.get('level') - print(data.get('image')) if not group.image and data.get('image'): - print(1) path = data.get('image').name + f'_{group.id}' group.image.save(path, data.get('image')) if group.image and not data.get('image'): - print(2) GroupService.delete_s3_object(group.image.name) group.image = None if group.image and data.get('image') and group.image != data.get('image'): - print(3) GroupService.delete_s3_object(group.image.name) path = data.get('image').name + f'_{group.id}' group.image.save(path, data.get('image')) From af73c1fce9c742e7bdbd4a309e4454c3b54d71dd Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Wed, 18 Dec 2024 03:48:19 +0330 Subject: [PATCH 45/60] Feat/ci/cd (#35) * feat: testing setup * chore: move cicd yaml file location * test: update CI/CD yaml file * test: update testing database configuration * deploy: update CI/CD yaml file --- .github/workflows/CICD.yaml | 72 +++++++++++++++++++++++++++++++++++++ .gitignore | 2 +- Dockerfile | 26 ++++++++++++++ 3 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/CICD.yaml create mode 100644 Dockerfile diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CICD.yaml new file mode 100644 index 0000000..0220533 --- /dev/null +++ b/.github/workflows/CICD.yaml @@ -0,0 +1,72 @@ +name: CI/CD + +on: + pull_request: + branches: + - develop + +jobs: + test: + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Write .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + python manage.py test --keepdb + + deploy: + if: github.event.pull_request.merged == true + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Write .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + + - name: Log in to GHCR + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ secrets.USERNAME }} + password: ${{ secrets.TOKEN }} + + - name: Build and Push Docker Image + run: | + docker build -t ghcr.io/kianyari/web:latest . + docker push ghcr.io/kianyari/web:latest + + - name: Deploy to Server + uses: appleboy/ssh-action@v0.1.8 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + script: | + docker pull ghcr.io/kianyari/web:latest + docker stop web || true + docker rm web || true + docker run -d --name web -p 8000:8000 ghcr.io/kianyari/web:latest diff --git a/.gitignore b/.gitignore index b95b16f..b8cf42b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,7 @@ Makefile venv/ .env **/__pycache__/ -Dockerfile +# Dockerfile docker-compose.yml .Dockerignore .env.docker diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..e5a3954 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,26 @@ +# Use the official Python image from the Docker Hub +FROM python:3.12-alpine + +# Set environment variables +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Set the working directory +WORKDIR /app + +# Install system dependencies +RUN apk add --no-cache gcc musl-dev mariadb-connector-c-dev mariadb-dev pkgconfig + +# Install Python dependencies +COPY requirements.txt /app/ +RUN pip install --no-cache-dir -r requirements.txt +RUN pip install --upgrade 'sentry-sdk[django]' + +# Copy the project files +COPY . /app/ + +# Expose the port your app runs on +EXPOSE 8000 + +# Run the application +CMD ["daphne", "FD.asgi:application", "--bind", "0.0.0.0", "--port", "8000"] From 66a602fb78475b4693b7b1d5a90cdee4f25e6861 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Wed, 18 Dec 2024 04:06:25 +0330 Subject: [PATCH 46/60] Fix/separate CI and CD(#36) --- .github/workflows/{CICD.yaml => CD.yaml} | 32 ++------------------- .github/workflows/CI.yaml | 36 ++++++++++++++++++++++++ 2 files changed, 39 insertions(+), 29 deletions(-) rename .github/workflows/{CICD.yaml => CD.yaml} (62%) create mode 100644 .github/workflows/CI.yaml diff --git a/.github/workflows/CICD.yaml b/.github/workflows/CD.yaml similarity index 62% rename from .github/workflows/CICD.yaml rename to .github/workflows/CD.yaml index 0220533..f56349d 100644 --- a/.github/workflows/CICD.yaml +++ b/.github/workflows/CD.yaml @@ -1,40 +1,14 @@ -name: CI/CD +name: CD on: pull_request: branches: - develop + types: + - closed jobs: - test: - if: github.event_name == 'pull_request' - runs-on: ubuntu-latest - - steps: - - name: Checkout code - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Write .env file - run: echo "${{ secrets.ENV_FILE }}" > .env - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - - - name: Run Tests - env: - ENV_FILE: ${{ secrets.ENV_FILE }} - run: | - python manage.py test --keepdb - deploy: - if: github.event.pull_request.merged == true runs-on: ubuntu-latest steps: diff --git a/.github/workflows/CI.yaml b/.github/workflows/CI.yaml new file mode 100644 index 0000000..2ca0673 --- /dev/null +++ b/.github/workflows/CI.yaml @@ -0,0 +1,36 @@ +name: CI + +on: + pull_request: + branches: + - develop + types: + - opened + +jobs: + test: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up Python + uses: actions/setup-python@v4 + with: + python-version: '3.10' + + - name: Write .env file + run: echo "${{ secrets.ENV_FILE }}" > .env + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -r requirements.txt + + - name: Run Tests + env: + ENV_FILE: ${{ secrets.ENV_FILE }} + run: | + python manage.py test --keepdb + \ No newline at end of file From c8774bcbde767f10cca1466cf67bb5654775f7cd Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Thu, 19 Dec 2024 04:30:37 +0330 Subject: [PATCH 47/60] Feat/status 500 handling (#37) * feat: authentican * feat: groups * feat: scheduling --- authentication/views.py | 37 ++++++++++++++------- groups/views.py | 33 ++++++++++++++++--- home/views.py | 72 +++++++++++++++++++++++++---------------- 3 files changed, 97 insertions(+), 45 deletions(-) diff --git a/authentication/views.py b/authentication/views.py index 338e705..fff3b07 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -24,7 +24,8 @@ def post(self, request): return Response("user created successfully, please verify your email", status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer @@ -39,6 +40,8 @@ def post(self, request): return Response("email verified successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -54,6 +57,8 @@ def post(self, request): return Response(tokens, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class PasswordResetRequestView(APIView): @@ -69,6 +74,8 @@ def post(self, request): return Response("password reset link sent successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class PasswordResetConfirmView(APIView): @@ -85,6 +92,8 @@ def post(self, request, *args, **kwargs): return Response("password reset successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -94,11 +103,13 @@ class UserRetriveView(APIView): @silk_profile(name='user-retrive') def get(self, request): - user = request.user - - serializer = self.serializer_class(user) - - return Response(serializer.data, status=status.HTTP_200_OK) + try: + user = request.user + serializer = self.serializer_class(user) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class UserUpdateView(APIView): permission_classes = [IsAuthenticated] @@ -112,9 +123,12 @@ def patch(self, request): return Response("user updated successfully", status=status.HTTP_200_OK) except User.DoesNotExist: return Response("user not found", status=status.HTTP_404_NOT_FOUND) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ChangePasswordView(APIView): + permission_classes = [IsAuthenticated] serializer_class= ChangePasswordSerializer @silk_profile(name='change-password') @@ -130,14 +144,13 @@ def patch(self, request): new_password = serializer.validated_data.get('new_password') UserService.change_password(user, old_password, new_password) - - except User.DoesNotExist: - return Response("user not found", status=status.HTTP_404_NOT_FOUND) + return Response("password changed successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - return Response("password changed successfully", status=status.HTTP_200_OK) class DeleteAccountView(APIView): @@ -148,5 +161,5 @@ def delete(self, request): try: UserService.delete_account(request.user) return Response("account deleted successfully", status=status.HTTP_200_OK) - except: - return Response("account not deleted", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/groups/views.py b/groups/views.py index e33ea8a..a7ff5ce 100644 --- a/groups/views.py +++ b/groups/views.py @@ -29,6 +29,9 @@ def post(self, request): return Response("group created successfully", status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupRetrieveView(APIView): @@ -42,6 +45,8 @@ def get(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -56,6 +61,8 @@ def patch(self, request, *args, **kwargs): return Response("group updated successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -69,6 +76,8 @@ def delete(self, request, *args, **kwargs): return Response("group deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupJoinRequestView(APIView): @@ -85,6 +94,8 @@ def post(self, request, *args, **kwargs): except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupCancelRequestView(APIView): @@ -97,6 +108,8 @@ def post(self, request, *args, **kwargs): return Response("request cancelled successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupPendingRequestView(APIView): @@ -114,6 +127,8 @@ def get(self, request, *args, **kwargs): return paginator.get_paginated_response(serializer.data) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupAcceptRequestView(APIView): @@ -129,6 +144,8 @@ def post(self, request, *args, **kwargs): return Response("request accepted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupDeclineRequestView(APIView): @@ -141,10 +158,12 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) try: GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username']) - return Response("request accepted successfully", status=status.HTTP_200_OK) + return Response("request declined successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupLeaveView(APIView): permission_classes = [IsAuthenticated, IsGroupMember] @@ -156,6 +175,8 @@ def post(self, request, *args, **kwargs): return Response("group left successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) @@ -173,14 +194,14 @@ def post(self, request, *args, **kwargs): return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupMemberListView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupMemberListSerializer pagination_class = PageNumberPagination - @silk_profile(name='group-member-list') def get(self, request, *args, **kwargs): try: @@ -191,4 +212,6 @@ def get(self, request, *args, **kwargs): serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) except ValidationError as e: - return Response(e.detail.get('detail'), status=e.detail.get('status')) \ No newline at end of file + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) \ No newline at end of file diff --git a/home/views.py b/home/views.py index bec7edd..420b077 100644 --- a/home/views.py +++ b/home/views.py @@ -26,13 +26,15 @@ class HomeView(APIView): @silk_profile(name='my-groups') def get(self, request): - joined_groups = HomeService.get_joined_groups(request.user, request) - filtered_groups = self.filter_queryset(joined_groups) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(filtered_groups, request) - serializer = self.serializer_class(paginated_data, many=True) - - return paginator.get_paginated_response(serializer.data) + try: + joined_groups = HomeService.get_joined_groups(request.user, request) + filtered_groups = self.filter_queryset(joined_groups) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(filtered_groups, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) def filter_queryset(self, queryset): for backend in list(self.filter_backends): @@ -47,12 +49,14 @@ class SuggestionsView(APIView): @silk_profile(name='suggestions') def get(self, request): - suggestions = SuggestionService.get_suggestions(request.user) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(suggestions, request) - serializer = self.serializer_class(paginated_data, many=True) - return paginator.get_paginated_response(serializer.data) - + try: + suggestions = SuggestionService.get_suggestions(request.user) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(suggestions, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class AllGroupsView(APIView): permission_classes = [IsAuthenticated] @@ -65,13 +69,16 @@ class AllGroupsView(APIView): @silk_profile(name='all-groups') def get(self, request): - groups = AllGroupsService.get_all_groups() - groups = groups.annotate(member_count=Count('members')) - filtered_groups = self.filter_queryset(groups) - paginator = self.pagination_class() - paginated_data = paginator.paginate_queryset(filtered_groups, request) - serializer = self.serializer_class(paginated_data, many=True) - return paginator.get_paginated_response(serializer.data) + try: + groups = AllGroupsService.get_all_groups() + groups = groups.annotate(member_count=Count('members')) + filtered_groups = self.filter_queryset(groups) + paginator = self.pagination_class() + paginated_data = paginator.paginate_queryset(filtered_groups, request) + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + except Exception: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) def filter_queryset(self, queryset): for backend in list(self.filter_backends): @@ -96,6 +103,8 @@ def post(self, request): return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class UserTimeSlotListView(APIView): @@ -104,9 +113,12 @@ class UserTimeSlotListView(APIView): @silk_profile(name='user-time-slots-retrieve') def get(self, request): - time_slots = UserTimeSlotService.get_user_time_slots(request.user) - serializer = self.serializer_class(time_slots, many=True) - return Response(serializer.data, status=status.HTTP_200_OK) + try: + time_slots = UserTimeSlotService.get_user_time_slots(request.user) + serializer = self.serializer_class(time_slots, many=True) + return Response(serializer.data, status=status.HTTP_200_OK) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class UserTimeSlotDeleteView(APIView): @@ -119,8 +131,8 @@ def delete(self, request, *args, **kwargs): return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupTimeSlotCreateView(APIView): @@ -139,7 +151,8 @@ def post(self, request, *args, **kwargs): return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class GroupTimeSlotListView(APIView): @@ -154,7 +167,9 @@ def get(self, request, *args, **kwargs): return Response(serializer.data, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + class GroupTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] @@ -166,7 +181,8 @@ def delete(self, request, *args, **kwargs): return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) from django.db import connection From 8ce6c672a06f79e71a0eea1859db3d0e9d1e6ff8 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Fri, 20 Dec 2024 00:58:01 +0330 Subject: [PATCH 48/60] Feat/suggestion algorithm (#38) * feat: setup celery for background processing * feat: caching setup * feat: suggestion algorithm done * feat: update liara settings --- .gitignore | 3 +- Dockerfile | 8 -- FD/__init__.py | 3 + FD/celery.py | 23 +++++ FD/settings.py | 25 +++++- chat/consumers.py | 1 - home/migrations/0002_usersuggestions.py | 21 +++++ home/models.py | 6 +- home/services.py | 108 +++++++++++++++++------- home/tasks.py | 55 ++++++++++++ requirements.txt | 14 +++ supervisor.conf | 12 +++ 12 files changed, 234 insertions(+), 45 deletions(-) create mode 100644 FD/celery.py create mode 100644 home/migrations/0002_usersuggestions.py create mode 100644 home/tasks.py create mode 100644 supervisor.conf diff --git a/.gitignore b/.gitignore index b8cf42b..8804a15 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,6 @@ Makefile venv/ .env **/__pycache__/ -# Dockerfile docker-compose.yml .Dockerignore -.env.docker +.env.docker \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index e5a3954..9e488df 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,26 +1,18 @@ -# Use the official Python image from the Docker Hub FROM python:3.12-alpine -# Set environment variables ENV PYTHONDONTWRITEBYTECODE=1 ENV PYTHONUNBUFFERED=1 -# Set the working directory WORKDIR /app -# Install system dependencies RUN apk add --no-cache gcc musl-dev mariadb-connector-c-dev mariadb-dev pkgconfig -# Install Python dependencies COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt RUN pip install --upgrade 'sentry-sdk[django]' -# Copy the project files COPY . /app/ -# Expose the port your app runs on EXPOSE 8000 -# Run the application CMD ["daphne", "FD.asgi:application", "--bind", "0.0.0.0", "--port", "8000"] diff --git a/FD/__init__.py b/FD/__init__.py index e69de29..9e0d95f 100644 --- a/FD/__init__.py +++ b/FD/__init__.py @@ -0,0 +1,3 @@ +from .celery import app as celery_app + +__all__ = ('celery_app',) \ No newline at end of file diff --git a/FD/celery.py b/FD/celery.py new file mode 100644 index 0000000..0d10b31 --- /dev/null +++ b/FD/celery.py @@ -0,0 +1,23 @@ +import os +from celery import Celery +from celery.schedules import crontab + +os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'FD.settings') + +app = Celery('FD') + +app.config_from_object('django.conf:settings', namespace='CELERY') +app.conf.broker_connection_retry_on_startup = True + +app.autodiscover_tasks() + +app.conf.beat_schedule = { + 'cache-matching-groups': { + 'task': 'home.tasks.cache_matching_groups', + 'schedule': crontab(hour=2), + }, + 'test-celery': { + 'task': 'home.tasks.test_celery', + 'schedule': crontab(minute=1), + }, +} \ No newline at end of file diff --git a/FD/settings.py b/FD/settings.py index 510dc41..e1dcaac 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -1,6 +1,9 @@ from datetime import timedelta from pathlib import Path from dotenv import load_dotenv +import sentry_sdk +from sentry_sdk.integrations.django import DjangoIntegration + import os load_dotenv() @@ -81,7 +84,6 @@ }, ] -# WSGI_APPLICATION = 'FD.wsgi.application' ASGI_APPLICATION = "FD.asgi.application" @@ -186,10 +188,10 @@ }, } -import sentry_sdk sentry_sdk.init( dsn="https://ccb764743ed51a9f962c8417f8dfe02a@o4508450288631808.ingest.de.sentry.io/4508450290663504", + integrations=[DjangoIntegration()], traces_sample_rate=1.0, _experiments={ "continuous_profiling_auto_start": True, @@ -197,3 +199,22 @@ ) SILKY_PYTHON_PROFILER = True + +REDIS_URL = os.getenv("REDIS_URL") + +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": REDIS_URL, + "OPTIONS": { + "CLIENT_CLASS": "django_redis.client.DefaultClient", + } + } +} + +CELERY_BROKER_URL = os.getenv("REDIS_URL") +CELERY_RESULT_BACKEND = os.getenv("REDIS_URL") +CELERY_ACCEPT_CONTENT = ['application/json'] +CELERY_TASK_SERIALIZER = 'json' +CELERY_RESULT_SERIALIZER = 'json' +CELERY_TIMEZONE = 'Asia/Tehran' diff --git a/chat/consumers.py b/chat/consumers.py index 9b17c19..9555bbd 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -74,7 +74,6 @@ def get_user_from_token(self, token): return None def disconnect(self, close_code): - # Leave room group async_to_sync(self.channel_layer.group_discard)( self.room_group_name, self.channel_name ) diff --git a/home/migrations/0002_usersuggestions.py b/home/migrations/0002_usersuggestions.py new file mode 100644 index 0000000..abcc0d9 --- /dev/null +++ b/home/migrations/0002_usersuggestions.py @@ -0,0 +1,21 @@ +# Generated by Django 5.1.2 on 2024-12-19 21:15 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('home', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='UserSuggestions', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('user_id', models.IntegerField()), + ('group_ids', models.TextField()), + ], + ), + ] diff --git a/home/models.py b/home/models.py index 94d3f33..8a2d75b 100644 --- a/home/models.py +++ b/home/models.py @@ -20,4 +20,8 @@ class GroupTimeSlot(models.Model): group = models.ForeignKey('groups.Group', models.CASCADE, related_name='time_slots') def __str__(self): - return f"{self.day_of_week} {self.start_time} - {self.end_time}" \ No newline at end of file + return f"{self.day_of_week} {self.start_time} - {self.end_time}" + +class UserSuggestions(models.Model): + user_id = models.IntegerField() + group_ids = models.TextField() \ No newline at end of file diff --git a/home/services.py b/home/services.py index 0beb269..3cb95ef 100644 --- a/home/services.py +++ b/home/services.py @@ -1,14 +1,28 @@ +import redis +from dotenv import load_dotenv +import os +from groups.models import Group + +from django.db.models import Case, When, Value, FloatField, F, ExpressionWrapper, Sum +from django.db.models.functions import Coalesce +import random + +load_dotenv() + +redis_url = os.getenv('REDIS_URL') +redis_client = redis.StrictRedis.from_url(redis_url) + from django.db.models import QuerySet, Q from rest_framework.exceptions import ValidationError from rest_framework import status - from .models import * from groups.models import * from .filters import GroupFilter + class UserTimeSlotService: @staticmethod def validate_time_range(start_time: float, end_time: float) -> None: @@ -107,23 +121,18 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ def get_group_time_slots(title) -> QuerySet[GroupTimeSlot]: if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + group = Group.objects.get(title=title) - return GroupTimeSlot.objects.filter(group=group) + return group.time_slots.all() @staticmethod def delete_time_slot(title, time_slot_id: int) -> None: if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) - time_slot = GroupTimeSlot.objects.filter( - id=time_slot_id, - group=group - ).first() - - if not time_slot: + if not group.time_slots.filter(id=time_slot_id).exists(): raise ValidationError({'detail': 'Time slot not found', 'status': status.HTTP_404_NOT_FOUND}) - - + time_slot = group.time_slots.filter(id=time_slot_id).first() time_slot.delete() @@ -142,28 +151,65 @@ def get_joined_groups(user, request) -> list[Group]: class SuggestionService: def get_suggestions(user) -> list[Group]: + + if UserSuggestions.objects.filter(user_id=user.id).exists(): + group_ids = UserSuggestions.objects.get(user_id=user.id).group_ids + group_ids = group_ids.split(',') + group_ids = [int(group_id) for group_id in group_ids] + groups = Group.objects.filter(id__in=group_ids) + return groups + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + user_level = user.level.upper() + level_index = VALID_LEVELS.index(user_level) + levels_to_consider = [user_level] + if level_index > 0: + levels_to_consider.append(VALID_LEVELS[level_index - 1]) + if level_index < len(VALID_LEVELS) - 1: + levels_to_consider.append(VALID_LEVELS[level_index + 1]) + + groups = Group.objects.filter(level__in=levels_to_consider).order_by('?')[:100] + + groups = list(groups) + user_time_slots = UserTimeSlotService.get_user_time_slots(user) - groups = Group.objects.exclude(members=user) - group_matches = [] - - for group in groups: - group_time_slots = GroupTimeSlotService.get_group_time_slots(group) - total_overlap = 0 - - for user_slot in user_time_slots: - for group_slot in group_time_slots: - if user_slot.day_of_week == group_slot.day_of_week: - overlap = min(user_slot.end_time, group_slot.end_time) - max(user_slot.start_time, group_slot.start_time) - if overlap > 0: - total_overlap += overlap - - # if total_overlap > 0: - group_matches.append((group, total_overlap)) - - group_matches.sort(key=lambda x: x[1], reverse=True) - group_matches = [group[0] for group in group_matches] - return group_matches - + + groups = Group.objects.filter(id__in=[group.id for group in groups]).annotate( + total_overlap=Coalesce( + Sum( + ExpressionWrapper( + Case( + *[ + When( + time_slots__day_of_week=user_slot.day_of_week, + then=F('time_slots__end_time') - F('time_slots__start_time') + ) + for user_slot in user_time_slots + ], + output_field=FloatField() + ), + output_field=FloatField() + ) + ), + Value(0.0), + output_field=FloatField() + ) + ) + + if user.neighborhood: + groups = groups.annotate( + total_overlap=ExpressionWrapper( + F('total_overlap') * Case( + When(neighborhood=user.neighborhood, then=1.3), + default=1, + output_field=FloatField() + ), + output_field=FloatField() + ) + ) + + best_fit_groups = groups.order_by('-total_overlap')[:25] + UserSuggestions.objects.create(user_id=user.id, group_ids=','.join([str(group.id) for group in best_fit_groups])) + return best_fit_groups class AllGroupsService: @staticmethod diff --git a/home/tasks.py b/home/tasks.py new file mode 100644 index 0000000..9830d23 --- /dev/null +++ b/home/tasks.py @@ -0,0 +1,55 @@ +from django.db.models import F, ExpressionWrapper, FloatField, Case, When +from celery import shared_task +from django.contrib.auth import get_user_model +import redis +from dotenv import load_dotenv +import os + +from groups.models import Group +from home.services import UserTimeSlotService, GroupTimeSlotService +from home.models import GroupTimeSlot + +load_dotenv() + +redis_url = os.getenv('REDIS_URL') +redis_client = redis.StrictRedis.from_url(redis_url) + +@shared_task +def find_matching_groups(user): + loc = None + if user.neighborhood: + loc = user.neighborhood + user_time_slots = UserTimeSlotService.get_user_time_slots(user) + groups = Group.objects.all() + group_matches = [] + for group in groups: + group_time_slots = GroupTimeSlotService.get_group_time_slots(group.title) + total_overlap = 0 + + for user_slot in user_time_slots: + for group_slot in group_time_slots: + if user_slot.day_of_week == group_slot.day_of_week: + overlap = min(user_slot.end_time, group_slot.end_time) - max(user_slot.start_time, group_slot.start_time) + if overlap > 0: + total_overlap += overlap + if group.neighborhood == loc: + total_overlap *= 1.3 + + if user not in group.members.all(): + group_matches.append((group, total_overlap)) + + if len(group_matches) == 25: + break + + group_matches.sort(key=lambda x: x[1], reverse=True) + group_ids = [group[0].id for group in group_matches] + return group_ids + +@shared_task +def cache_matching_groups(): + User = get_user_model() + users = User.objects.all() + + for user in users: + matching_groups = find_matching_groups(user) + redis_client.set(f'user:{user.id}:matching_groups', matching_groups) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 4ae52c0..5825564 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,14 +1,21 @@ +amqp==5.3.1 asgiref==3.8.1 attrs==24.2.0 autobahn==24.4.2 Automat==24.8.1 autopep8==2.3.1 +billiard==4.2.1 boto3==1.35.64 botocore==1.35.64 +celery==5.4.0 certifi==2024.8.30 cffi==1.17.1 channels==4.2.0 channels_redis==4.2.1 +click==8.1.7 +click-didyoumean==0.3.1 +click-plugins==1.1.1 +click-repl==0.3.0 colorama==0.4.6 constantly==23.10.4 coverage==7.6.9 @@ -18,12 +25,15 @@ Django==5.1.2 django-cors-headers==4.6.0 django-filter==24.3 django-prometheus==2.3.1 +django-redis==5.4.0 django-silk==5.3.2 django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 execnet==2.1.1 +factory_boy==3.3.1 +Faker==33.1.0 flake8==7.1.1 gprof2dot==2024.6.6 hiredis==3.0.0 @@ -35,6 +45,7 @@ iniconfig==2.0.0 jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 +kombu==5.4.2 mccabe==0.7.0 msgpack==1.1.0 mysql-connector-python==9.1.0 @@ -43,6 +54,7 @@ packaging==24.2 pillow==11.0.0 pluggy==1.5.0 prometheus_client==0.21.1 +prompt_toolkit==3.0.48 pyasn1==0.6.1 pyasn1_modules==0.4.1 pycodestyle==2.12.1 @@ -71,4 +83,6 @@ typing_extensions==4.12.2 tzdata==2024.2 uritemplate==4.1.1 urllib3==2.2.3 +vine==5.1.0 +wcwidth==0.2.13 zope.interface==7.1.1 diff --git a/supervisor.conf b/supervisor.conf new file mode 100644 index 0000000..1ba3c70 --- /dev/null +++ b/supervisor.conf @@ -0,0 +1,12 @@ +[program:celery-worker] +process_name=%(program_name)s_%(process_num)02d +command=celery -A proj worker -l INFO +autostart=true +autorestart=true +stopasgroup=true +killasgroup=true +numprocs=1 +startsecs=10 +stopwaitsecs=600 +redirect_stderr=true +stdout_logfile=/tmp/worker.log \ No newline at end of file From 86294502b6cf518459e8a800e798fb715eb00e0f Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Fri, 20 Dec 2024 01:40:53 +0330 Subject: [PATCH 49/60] Feat/celery deployment (#39) * feat: celery deployment setup * feat: done --- .github/workflows/CD.yaml | 23 +++++++++-- home/services.py | 5 +-- home/tasks.py | 87 +++++++++++++++++++++++++-------------- 3 files changed, 78 insertions(+), 37 deletions(-) diff --git a/.github/workflows/CD.yaml b/.github/workflows/CD.yaml index f56349d..54acf9a 100644 --- a/.github/workflows/CD.yaml +++ b/.github/workflows/CD.yaml @@ -40,7 +40,24 @@ jobs: username: ${{ secrets.SERVER_USER }} password: ${{ secrets.SERVER_PASSWORD }} script: | + # Pull latest image docker pull ghcr.io/kianyari/web:latest - docker stop web || true - docker rm web || true - docker run -d --name web -p 8000:8000 ghcr.io/kianyari/web:latest + + # Stop and remove existing containers + docker stop web celery-worker celery-beat || true + docker rm web celery-worker celery-beat || true + + # Start web container + docker run -d --name web \ + -p 8000:8000 \ + ghcr.io/kianyari/web:latest + + # Start Celery worker container + docker run -d --name celery-worker \ + ghcr.io/kianyari/web:latest \ + celery -A FD worker --loglevel=info + + # Start Celery beat container + docker run -d --name celery-beat \ + ghcr.io/kianyari/web:latest \ + celery -A FD beat --loglevel=info diff --git a/home/services.py b/home/services.py index 3cb95ef..b596076 100644 --- a/home/services.py +++ b/home/services.py @@ -9,10 +9,8 @@ load_dotenv() -redis_url = os.getenv('REDIS_URL') -redis_client = redis.StrictRedis.from_url(redis_url) -from django.db.models import QuerySet, Q +from django.db.models import QuerySet from rest_framework.exceptions import ValidationError from rest_framework import status @@ -20,7 +18,6 @@ from .models import * from groups.models import * -from .filters import GroupFilter class UserTimeSlotService: diff --git a/home/tasks.py b/home/tasks.py index 9830d23..2df6b29 100644 --- a/home/tasks.py +++ b/home/tasks.py @@ -9,41 +9,67 @@ from home.services import UserTimeSlotService, GroupTimeSlotService from home.models import GroupTimeSlot +from django.db.models import Case, When, Value, FloatField, F, ExpressionWrapper, Sum +from django.db.models.functions import Coalesce + +from home.models import UserSuggestions + load_dotenv() -redis_url = os.getenv('REDIS_URL') -redis_client = redis.StrictRedis.from_url(redis_url) @shared_task def find_matching_groups(user): - loc = None - if user.neighborhood: - loc = user.neighborhood + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] + user_level = user.level.upper() + level_index = VALID_LEVELS.index(user_level) + levels_to_consider = [user_level] + if level_index > 0: + levels_to_consider.append(VALID_LEVELS[level_index - 1]) + if level_index < len(VALID_LEVELS) - 1: + levels_to_consider.append(VALID_LEVELS[level_index + 1]) + + groups = Group.objects.filter(level__in=levels_to_consider).order_by('?')[:100] + + groups = list(groups) + user_time_slots = UserTimeSlotService.get_user_time_slots(user) - groups = Group.objects.all() - group_matches = [] - for group in groups: - group_time_slots = GroupTimeSlotService.get_group_time_slots(group.title) - total_overlap = 0 - - for user_slot in user_time_slots: - for group_slot in group_time_slots: - if user_slot.day_of_week == group_slot.day_of_week: - overlap = min(user_slot.end_time, group_slot.end_time) - max(user_slot.start_time, group_slot.start_time) - if overlap > 0: - total_overlap += overlap - if group.neighborhood == loc: - total_overlap *= 1.3 - - if user not in group.members.all(): - group_matches.append((group, total_overlap)) - - if len(group_matches) == 25: - break - - group_matches.sort(key=lambda x: x[1], reverse=True) - group_ids = [group[0].id for group in group_matches] - return group_ids + + groups = Group.objects.filter(id__in=[group.id for group in groups]).annotate( + total_overlap=Coalesce( + Sum( + ExpressionWrapper( + Case( + *[ + When( + time_slots__day_of_week=user_slot.day_of_week, + then=F('time_slots__end_time') - F('time_slots__start_time') + ) + for user_slot in user_time_slots + ], + output_field=FloatField() + ), + output_field=FloatField() + ) + ), + Value(0.0), + output_field=FloatField() + ) + ) + + if user.neighborhood: + groups = groups.annotate( + total_overlap=ExpressionWrapper( + F('total_overlap') * Case( + When(neighborhood=user.neighborhood, then=1.3), + default=1, + output_field=FloatField() + ), + output_field=FloatField() + ) + ) + + best_fit_groups = groups.order_by('-total_overlap')[:25] + return best_fit_groups @shared_task def cache_matching_groups(): @@ -52,4 +78,5 @@ def cache_matching_groups(): for user in users: matching_groups = find_matching_groups(user) - redis_client.set(f'user:{user.id}:matching_groups', matching_groups) \ No newline at end of file + matching_group_ids = [group.id for group in matching_groups] + UserSuggestions.objects.update_or_create(user_id=user.id, defaults={'group_ids': ','.join(map(str, matching_group_ids))}) From d6189bd956af253cf086a303a606ea68737afa92 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sat, 21 Dec 2024 11:03:33 +0330 Subject: [PATCH 50/60] Feat/dashboarding (#40) * feat: prometheus server setup * refactor: remove silk and sentry * chore: white space removal --- .gitignore | 2 +- Dockerfile | 1 - FD/settings.py | 15 +-------------- FD/urls.py | 3 +-- authentication/views.py | 10 ---------- groups/views.py | 13 ------------- home/views.py | 10 ---------- requirements.txt | 4 +--- 8 files changed, 4 insertions(+), 54 deletions(-) diff --git a/.gitignore b/.gitignore index 8804a15..88e9346 100644 --- a/.gitignore +++ b/.gitignore @@ -4,4 +4,4 @@ venv/ **/__pycache__/ docker-compose.yml .Dockerignore -.env.docker \ No newline at end of file +.env.docker diff --git a/Dockerfile b/Dockerfile index 9e488df..bce96fd 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,7 +9,6 @@ RUN apk add --no-cache gcc musl-dev mariadb-connector-c-dev mariadb-dev pkgconfi COPY requirements.txt /app/ RUN pip install --no-cache-dir -r requirements.txt -RUN pip install --upgrade 'sentry-sdk[django]' COPY . /app/ diff --git a/FD/settings.py b/FD/settings.py index e1dcaac..a0f40b9 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -1,8 +1,7 @@ from datetime import timedelta from pathlib import Path from dotenv import load_dotenv -import sentry_sdk -from sentry_sdk.integrations.django import DjangoIntegration + import os @@ -35,7 +34,6 @@ 'django_filters', 'channels', 'django_prometheus', - 'silk', 'authentication', 'groups', @@ -59,7 +57,6 @@ 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', - 'silk.middleware.SilkyMiddleware', 'django_prometheus.middleware.PrometheusAfterMiddleware', ] @@ -189,16 +186,6 @@ } -sentry_sdk.init( - dsn="https://ccb764743ed51a9f962c8417f8dfe02a@o4508450288631808.ingest.de.sentry.io/4508450290663504", - integrations=[DjangoIntegration()], - traces_sample_rate=1.0, - _experiments={ - "continuous_profiling_auto_start": True, - }, -) - -SILKY_PYTHON_PROFILER = True REDIS_URL = os.getenv("REDIS_URL") diff --git a/FD/urls.py b/FD/urls.py index 0e7994d..a56aa62 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -8,8 +8,7 @@ def trigger_error(request): urlpatterns = [ path('trigger_error/', trigger_error), - path('prometheus/', include('django_prometheus.urls')), - path("silk/", include("silk.urls", namespace="silk")), + path('', include('django_prometheus.urls')), path('admin/', admin.site.urls), diff --git a/authentication/views.py b/authentication/views.py index fff3b07..4098caa 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -6,7 +6,6 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.permissions import IsAuthenticated -from silk.profiling.profiler import silk_profile from .serializers import * from .models import * @@ -15,7 +14,6 @@ class RegisterUserView(APIView): serializer_class = UserCreateSerializer - @silk_profile(name='register') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -30,7 +28,6 @@ def post(self, request): class VerifyEmailView(APIView): serializer_class = VerifyEmailSerializer - @silk_profile(name='verify-email') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -48,7 +45,6 @@ def post(self, request): class LoginUserView(APIView): serializer_class = UserLoginSerializer - @silk_profile(name='login') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -64,7 +60,6 @@ def post(self, request): class PasswordResetRequestView(APIView): serializer_class = PasswordResetRequestSerializer - @silk_profile(name='forget-password') def post(self, request): serializer = PasswordResetRequestSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -81,7 +76,6 @@ def post(self, request): class PasswordResetConfirmView(APIView): serializer_class = PasswordResetConfirmSerializer - @silk_profile(name='forget-password-confirm') def post(self, request, *args, **kwargs): serializer = PasswordResetConfirmSerializer(data=request.data) serializer.is_valid(raise_exception=True) @@ -101,7 +95,6 @@ class UserRetriveView(APIView): permission_classes = [IsAuthenticated] serializer_class= UserRetriveSerializer - @silk_profile(name='user-retrive') def get(self, request): try: user = request.user @@ -115,7 +108,6 @@ class UserUpdateView(APIView): permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] - @silk_profile(name='user-update') def patch(self, request): try: user = request.user @@ -131,7 +123,6 @@ class ChangePasswordView(APIView): permission_classes = [IsAuthenticated] serializer_class= ChangePasswordSerializer - @silk_profile(name='change-password') def patch(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -156,7 +147,6 @@ def patch(self, request): class DeleteAccountView(APIView): permission_classes = [IsAuthenticated] - @silk_profile(name='delete-account') def delete(self, request): try: UserService.delete_account(request.user) diff --git a/groups/views.py b/groups/views.py index a7ff5ce..7e30dc8 100644 --- a/groups/views.py +++ b/groups/views.py @@ -5,7 +5,6 @@ from rest_framework.parsers import MultiPartParser, FormParser, JSONParser from rest_framework.pagination import PageNumberPagination -from silk.profiling.profiler import silk_profile from authentication.models import User from .serializers import * @@ -19,7 +18,6 @@ class GroupCreateView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupCreateSerializer - @silk_profile(name='group-create') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -37,7 +35,6 @@ def post(self, request): class GroupRetrieveView(APIView): serializer_class = GroupRetrieveSerializer - @silk_profile(name='group-retrieve') def get(self, request, *args, **kwargs): try: group = GroupService.retrieve_group(kwargs.get('title')) @@ -54,7 +51,6 @@ class GroupUpdateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] parser_classes = [MultiPartParser, FormParser, JSONParser] - @silk_profile(name='group-update') def patch(self, request, *args, **kwargs): try: GroupService.update_group(kwargs.get('title'), request.data) @@ -69,7 +65,6 @@ def patch(self, request, *args, **kwargs): class GroupDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] - @silk_profile(name='group-delete') def delete(self, request, *args, **kwargs): try: GroupService.delete_group(kwargs.get('title')) @@ -83,7 +78,6 @@ def delete(self, request, *args, **kwargs): class GroupJoinRequestView(APIView): permission_classes = [IsAuthenticated] - @silk_profile(name='group-join-request') def post(self, request, *args, **kwargs): try: type = GroupService.join_request(kwargs.get('title'), request.user) @@ -101,7 +95,6 @@ def post(self, request, *args, **kwargs): class GroupCancelRequestView(APIView): permission_classes = [IsAuthenticated] - @silk_profile(name='group-join-request-cancel') def post(self, request, *args, **kwargs): try: GroupService.cancel_join_request(kwargs.get('title'), request.user) @@ -117,7 +110,6 @@ class GroupPendingRequestView(APIView): serializer_class = GroupPendingRequestSerializer pagination_class = PageNumberPagination - @silk_profile(name='group-pending-request') def get(self, request, *args, **kwargs): try: group_requests = GroupService.pending_requests(kwargs.get('title')) @@ -135,7 +127,6 @@ class GroupAcceptRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupAcceptRequestSerializer - @silk_profile(name='group-accept-request') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -152,7 +143,6 @@ class GroupDeclineRequestView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupDeclineRequestSerializer - @silk_profile(name='group-decline-request') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -168,7 +158,6 @@ def post(self, request, *args, **kwargs): class GroupLeaveView(APIView): permission_classes = [IsAuthenticated, IsGroupMember] - @silk_profile(name='group-leave') def post(self, request, *args, **kwargs): try: GroupService.leave_group(kwargs.get('title'), request.user) @@ -185,7 +174,6 @@ class GroupKickView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] - @silk_profile(name='group-kick-member') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -202,7 +190,6 @@ class GroupMemberListView(APIView): serializer_class = GroupMemberListSerializer pagination_class = PageNumberPagination - @silk_profile(name='group-member-list') def get(self, request, *args, **kwargs): try: members = GroupService.member_list(kwargs.get('title')) diff --git a/home/views.py b/home/views.py index 420b077..88a6164 100644 --- a/home/views.py +++ b/home/views.py @@ -9,7 +9,6 @@ from django_filters.rest_framework import DjangoFilterBackend -from silk.profiling.profiler import silk_profile from .serializers import * from .services import * @@ -24,7 +23,6 @@ class HomeView(APIView): filter_backends = [SearchFilter] search_fields = ['title'] - @silk_profile(name='my-groups') def get(self, request): try: joined_groups = HomeService.get_joined_groups(request.user, request) @@ -47,7 +45,6 @@ class SuggestionsView(APIView): serializer_class = SuggestionSerializer pagination_class = PageNumberPagination - @silk_profile(name='suggestions') def get(self, request): try: suggestions = SuggestionService.get_suggestions(request.user) @@ -67,7 +64,6 @@ class AllGroupsView(APIView): search_fields = ['title'] ordering_fields = ['level', 'member_count'] - @silk_profile(name='all-groups') def get(self, request): try: groups = AllGroupsService.get_all_groups() @@ -91,7 +87,6 @@ class UserTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated] serializer_class = UserTimeSlotSerializer - @silk_profile(name='create-user-time-slot') def post(self, request): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -111,7 +106,6 @@ class UserTimeSlotListView(APIView): permission_classes = [IsAuthenticated] serializer_class = UserTimeSlotSerializer - @silk_profile(name='user-time-slots-retrieve') def get(self, request): try: time_slots = UserTimeSlotService.get_user_time_slots(request.user) @@ -124,7 +118,6 @@ def get(self, request): class UserTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated] - @silk_profile(name='user-time-slot-delete') def delete(self, request, *args, **kwargs): try: UserTimeSlotService.delete_time_slot(request.user, kwargs.get('id')) @@ -139,7 +132,6 @@ class GroupTimeSlotCreateView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] serializer_class = GroupTimeSlotSerializer - @silk_profile(name='create-group-time-slot') def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) @@ -159,7 +151,6 @@ class GroupTimeSlotListView(APIView): permission_classes = [IsAuthenticated] serializer_class = GroupTimeSlotSerializer - @silk_profile(name='group-time-slots-retrieve') def get(self, request, *args, **kwargs): try: time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) @@ -174,7 +165,6 @@ def get(self, request, *args, **kwargs): class GroupTimeSlotDeleteView(APIView): permission_classes = [IsAuthenticated, IsGroupOwner] - @silk_profile(name='group-time-slot-delete') def delete(self, request, *args, **kwargs): try: GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id')) diff --git a/requirements.txt b/requirements.txt index 5825564..9b0b184 100644 --- a/requirements.txt +++ b/requirements.txt @@ -26,7 +26,6 @@ django-cors-headers==4.6.0 django-filter==24.3 django-prometheus==2.3.1 django-redis==5.4.0 -django-silk==5.3.2 django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 @@ -72,7 +71,6 @@ redis==5.2.0 referencing==0.35.1 rpds-py==0.20.1 s3transfer==0.10.3 -sentry-sdk==2.19.2 service-identity==24.2.0 setuptools==75.6.0 six==1.16.0 @@ -85,4 +83,4 @@ uritemplate==4.1.1 urllib3==2.2.3 vine==5.1.0 wcwidth==0.2.13 -zope.interface==7.1.1 +zope.interface==7.1.1 \ No newline at end of file From 7125887f0fba83b1a99160dacea33b1d3b931dcd Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Sat, 21 Dec 2024 11:07:56 +0330 Subject: [PATCH 51/60] Fix/sprint4 (#41) * fix: timeslot overlaps * fix: prometheus endpoint --- home/services.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/home/services.py b/home/services.py index b596076..80d7775 100644 --- a/home/services.py +++ b/home/services.py @@ -36,8 +36,8 @@ def validate_overlap(user, day_of_week: str, start_time: float, end_time: float) overlapping = UserTimeSlot.objects.filter( user=user, day_of_week=day_of_week.lower(), - start_time__lt=end_time, - end_time__gt=start_time + start_time__lte=end_time, + end_time__gte=start_time ) if overlapping.exists(): @@ -92,8 +92,8 @@ def validate_overlap(group, day_of_week: str, start_time: float, end_time: float overlapping = GroupTimeSlot.objects.filter( group=group, day_of_week=day_of_week.lower(), - start_time__lt=end_time, - end_time__gt=start_time + start_time__lte=end_time, + end_time__gte=start_time ) if overlapping.exists(): From f916ec93888bdf021c979b720ed3f82c0ed27961 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Thu, 26 Dec 2024 14:47:43 +0330 Subject: [PATCH 52/60] Fix/sprint issues (#42) * fix: all groups api * chore: change pagination number * chore: move code blocks * chore: change page size * refactor: sync database fields and cache fields in chat * feat: add requested flag to groups --- .github/workflows/CD.yaml | 53 ++++++++++++--------- .gitignore | 2 +- FD/settings.py | 2 +- alert_rules.yml | 29 ++++++++++++ authentication/views.py | 4 +- chat/consumers.py | 18 +++++--- docker-compose.yml | 97 +++++++++++++++++++++++++++++++++++++++ groups/services.py | 5 +- groups/views.py | 1 + home/serializers.py | 15 ++++++ home/services.py | 4 +- home/views.py | 9 ++-- prometheus.yml | 20 ++++++++ 13 files changed, 220 insertions(+), 39 deletions(-) create mode 100644 alert_rules.yml create mode 100644 docker-compose.yml create mode 100644 prometheus.yml diff --git a/.github/workflows/CD.yaml b/.github/workflows/CD.yaml index 54acf9a..285b1f1 100644 --- a/.github/workflows/CD.yaml +++ b/.github/workflows/CD.yaml @@ -1,6 +1,9 @@ name: CD on: + push: + branches: + - fix/sprint_issues pull_request: branches: - develop @@ -28,36 +31,44 @@ jobs: username: ${{ secrets.USERNAME }} password: ${{ secrets.TOKEN }} + - name: Install Docker Compose + run: | + sudo curl -L "https://github.com/docker/compose/releases/download/v2.23.3/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose + sudo chmod +x /usr/local/bin/docker-compose + docker-compose --version + - name: Build and Push Docker Image run: | - docker build -t ghcr.io/kianyari/web:latest . - docker push ghcr.io/kianyari/web:latest + docker-compose build + docker-compose push - - name: Deploy to Server + - name: Copy files to server via SSH + uses: appleboy/scp-action@v0.1.0 + with: + host: ${{ secrets.SERVER_HOST }} + username: ${{ secrets.SERVER_USER }} + password: ${{ secrets.SERVER_PASSWORD }} + source: "docker-compose.yml, prometheus.yml, alert_rules.yml" + target: "/home/fd/" + + - name: Copy files and deploy uses: appleboy/ssh-action@v0.1.8 with: host: ${{ secrets.SERVER_HOST }} username: ${{ secrets.SERVER_USER }} password: ${{ secrets.SERVER_PASSWORD }} script: | - # Pull latest image - docker pull ghcr.io/kianyari/web:latest - - # Stop and remove existing containers - docker stop web celery-worker celery-beat || true - docker rm web celery-worker celery-beat || true + + cd /home/fd - # Start web container - docker run -d --name web \ - -p 8000:8000 \ - ghcr.io/kianyari/web:latest + echo "${{ secrets.ENV_FILE }}" > .env + echo "${{ secrets.ALERT_MANAGER}}" > alertmanager.yml + + chmod 644 .env + + docker compose pull web - # Start Celery worker container - docker run -d --name celery-worker \ - ghcr.io/kianyari/web:latest \ - celery -A FD worker --loglevel=info + docker compose down - # Start Celery beat container - docker run -d --name celery-beat \ - ghcr.io/kianyari/web:latest \ - celery -A FD beat --loglevel=info + docker compose up -d + diff --git a/.gitignore b/.gitignore index 88e9346..7d312c3 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,6 @@ Makefile venv/ .env **/__pycache__/ -docker-compose.yml .Dockerignore .env.docker +alertmanager.yml \ No newline at end of file diff --git a/FD/settings.py b/FD/settings.py index a0f40b9..0495666 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -137,7 +137,7 @@ 'rest_framework_simplejwt.authentication.JWTAuthentication', ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', - 'PAGE_SIZE': 1 + 'PAGE_SIZE': 3 } SPECTACULAR_SETTINGS = { diff --git a/alert_rules.yml b/alert_rules.yml new file mode 100644 index 0000000..ee6d9d1 --- /dev/null +++ b/alert_rules.yml @@ -0,0 +1,29 @@ +groups: +- name: website + rules: + - alert: WebsiteDown + expr: up{job="website"} == 0 + for: 1m + labels: + severity: critical + annotations: + summary: "Website is down" + description: "The website has been down for more than 1 minute" + + - alert: HighResponseTime + expr: prometheus_http_request_duration_seconds{job="website"} > 2 + for: 5m + labels: + severity: warning + annotations: + summary: "High response time" + description: "Website response time is above 2 seconds for 5 minutes" + + - alert: HighErrorRate + expr: rate(django_http_responses_total_by_status_total{status=~"5.."}[5m]) > 0 + for: 0s + labels: + severity: warning + annotations: + summary: "High error rate" + description: "Website is returning too many 5xx errors" diff --git a/authentication/views.py b/authentication/views.py index 4098caa..64fe56a 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -69,8 +69,8 @@ def post(self, request): return Response("password reset link sent successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - except Exception as e: - return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # except Exception as e: + # return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class PasswordResetConfirmView(APIView): diff --git a/chat/consumers.py b/chat/consumers.py index 9555bbd..9977436 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -17,6 +17,8 @@ from rest_framework import status from rest_framework.response import Response +from django.db.models import F + class ChatConsumer(WebsocketConsumer): connected_users = set() @@ -63,7 +65,10 @@ def connect(self): def get_chat_messages(self): return list(self.chat.messages.all().order_by('-timestamp').values( 'content', 'sender__username', 'timestamp' - )) + ).annotate( + message=F('content'), + username=F('sender__username'), + ).values('message', 'username', 'timestamp')) def get_user_from_token(self, token): try: @@ -89,6 +94,10 @@ def receive(self, text_data): content=message, sender=user ) + group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) + for member in group_members: + if member.id not in self.connected_users: + NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") async_to_sync(self.channel_layer.group_send)( self.room_group_name, @@ -100,15 +109,10 @@ def receive(self, text_data): } ) - group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) - for member in group_members: - NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") - # Receive message from room group def chat_message(self, event): - # Send message to WebSocket self.send(text_data=json.dumps({ 'message': event['message'], 'username': event['username'], 'timestamp': event['timestamp'] - })) \ No newline at end of file + })) diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..859c377 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,97 @@ +services: + web: + image: ghcr.io/kianyari/web:latest + container_name: web + env_file: + - ./.env + ports: + - 8000:8000 + networks: + - fd + depends_on: + - redis + + prometheus: + image: prom/prometheus + container_name: prometheus + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + - ./alert_rules.yml:/etc/prometheus/alert_rules.yml + - prometheus_data:/prometheus + ports: + - 9090:9090 + networks: + - fd + + grafana: + image: grafana/grafana + container_name: grafana + volumes: + - grafana_data:/var/lib/grafana + ports: + - 3000:3000 + networks: + - fd + + redis: + image: redis:latest + container_name: redis + ports: + - 6379:6379 + networks: + - fd + + alertmanager: + image: prom/alertmanager + container_name: alertmanager + volumes: + - ./alertmanager.yml:/etc/alertmanager/alertmanager.yml + ports: + - 9093:9093 + networks: + - fd + + celery-worker: + image: ghcr.io/kianyari/web:latest + container_name: celery-worker + command: celery -A FD worker -l INFO + env_file: + - ./.env + networks: + - fd + depends_on: + - web + + celery-beat: + image: ghcr.io/kianyari/web:latest + container_name: celery-beat + command: celery -A FD beat -l INFO + env_file: + - ./.env + networks: + - fd + depends_on: + - web + + elasticsearch: + image: elasticsearch:7.14.1 + container_name: elasticsearch + environment: + - discovery.type=single-node + - xpack.security.enabled=false + ports: + - 9200:9200 + volumes: + - esdata01:/usr/share/elasticsearch/data + networks: + - fd + +volumes: + esdata01: + prometheus_data: + grafana_data: + + +networks: + fd: + driver: bridge \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index 51978f4..f7b0d7f 100644 --- a/groups/services.py +++ b/groups/services.py @@ -179,12 +179,13 @@ def leave_group(title, user): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + if group.owner == user: + raise ValidationError({'detail': 'You cannot leave your own group', 'status': status.HTTP_400_BAD_REQUEST}) + if not group.members.filter(username=user.username).exists(): raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) - if group.owner == user: - group.delete() group.save() @staticmethod diff --git a/groups/views.py b/groups/views.py index 7e30dc8..3363ecd 100644 --- a/groups/views.py +++ b/groups/views.py @@ -195,6 +195,7 @@ def get(self, request, *args, **kwargs): members = GroupService.member_list(kwargs.get('title')) paginator = self.pagination_class() + paginator.page_size = 4 paginated_data = paginator.paginate_queryset(members, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) diff --git a/home/serializers.py b/home/serializers.py index 93f9723..1d6c741 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -29,6 +29,7 @@ class Meta: class GroupSerializer(serializers.ModelSerializer): member_count = serializers.SerializerMethodField() + class Meta: model = Group fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count'] @@ -36,6 +37,20 @@ class Meta: def get_member_count(self, obj): return obj.members.count() + +class AllGroupsSerializer(serializers.ModelSerializer): + member_count = serializers.SerializerMethodField() + requested = serializers.SerializerMethodField() + + class Meta: + model = Group + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count', 'requested'] + def get_member_count(self, obj): + return obj.members.count() + + def get_requested(self, obj): + request = self.context['request'] + return request.user in obj.pending_members.all() \ No newline at end of file diff --git a/home/services.py b/home/services.py index 80d7775..15ee3f9 100644 --- a/home/services.py +++ b/home/services.py @@ -210,5 +210,5 @@ def get_suggestions(user) -> list[Group]: class AllGroupsService: @staticmethod - def get_all_groups() -> QuerySet[Group]: - return Group.objects.all() + def get_all_groups(user) -> QuerySet[Group]: + return Group.objects.exclude(members=user) diff --git a/home/views.py b/home/views.py index 88a6164..9da3553 100644 --- a/home/views.py +++ b/home/views.py @@ -28,6 +28,7 @@ def get(self, request): joined_groups = HomeService.get_joined_groups(request.user, request) filtered_groups = self.filter_queryset(joined_groups) paginator = self.pagination_class() + paginator.page_size = 4 paginated_data = paginator.paginate_queryset(filtered_groups, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) @@ -49,6 +50,7 @@ def get(self, request): try: suggestions = SuggestionService.get_suggestions(request.user) paginator = self.pagination_class() + paginator.page_size = 4 paginated_data = paginator.paginate_queryset(suggestions, request) serializer = self.serializer_class(paginated_data, many=True) return paginator.get_paginated_response(serializer.data) @@ -57,7 +59,7 @@ def get(self, request): class AllGroupsView(APIView): permission_classes = [IsAuthenticated] - serializer_class = GroupSerializer + serializer_class = AllGroupsSerializer pagination_class = PageNumberPagination filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = GroupFilter @@ -66,12 +68,13 @@ class AllGroupsView(APIView): def get(self, request): try: - groups = AllGroupsService.get_all_groups() + groups = AllGroupsService.get_all_groups(request.user) groups = groups.annotate(member_count=Count('members')) filtered_groups = self.filter_queryset(groups) paginator = self.pagination_class() + paginator.page_size = 4 paginated_data = paginator.paginate_queryset(filtered_groups, request) - serializer = self.serializer_class(paginated_data, many=True) + serializer = self.serializer_class(paginated_data, many=True, context={'request': request}) return paginator.get_paginated_response(serializer.data) except Exception: return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..c9dc6bc --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,20 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +alerting: + alertmanagers: + - static_configs: + - targets: + - '185.252.86.8:9093' + scheme: http + +rule_files: + - 'alert_rules.yml' + +scrape_configs: + - job_name: 'website' + metrics_path: '/metrics' + static_configs: + - targets: ['freediscussion.liara.run'] + scheme: https From 4b777ce0b645e191a15973e22c22c07815e1b8ab Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Mon, 30 Dec 2024 08:31:25 +0330 Subject: [PATCH 53/60] Testing/accounts (#44) * refactor: modify accounts tests * chore: update gitignore --- .gitignore | 2 +- FD/settings.py | 2 +- authentication/test.py | 74 ------------------ authentication/tests.py | 163 ++++++++++++++++++++++++++++++++++++++++ authentication/urls.py | 8 +- pytest.ini | 4 + requirements.txt | 16 +++- 7 files changed, 188 insertions(+), 81 deletions(-) delete mode 100644 authentication/test.py create mode 100644 authentication/tests.py create mode 100644 pytest.ini diff --git a/.gitignore b/.gitignore index 9a08464..c65f128 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,4 @@ venv/ .Dockerignore .env.docker alertmanager.yml - +.vscode/ diff --git a/FD/settings.py b/FD/settings.py index ed6270b..b36571f 100644 --- a/FD/settings.py +++ b/FD/settings.py @@ -140,7 +140,7 @@ ), 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination', 'PAGE_SIZE': 3 - +} SPECTACULAR_SETTINGS = { 'TITLE': 'FD API', diff --git a/authentication/test.py b/authentication/test.py deleted file mode 100644 index ea5b46f..0000000 --- a/authentication/test.py +++ /dev/null @@ -1,74 +0,0 @@ -from django.test import TestCase - -from django.test import TestCase -from django.urls import reverse -from rest_framework.test import APIClient -from rest_framework import status -from authentication.models import User -from groups.models import Group - -class AuthenticationAPITests(TestCase): - - def setUp(self): - self.client = APIClient() - self.user = User.objects.create_user( - username='testuser', - email='testuser@example.com', - password='testpassword' - ) - self.client.force_authenticate(user=self.user) - - def test_login(self): - url = reverse('login') - data = {'username': 'testuser', 'password': 'testpassword'} - response = self.client.post(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertIn('access_token', response.data) - self.assertIn('refresh_token', response.data) - - def test_change_password(self): - url = reverse('change-password') - data = {'old_password': 'testpassword', 'new_password': 'newpassword'} - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, "password changed successfully") - - def test_retrieve_user(self): - url = reverse('user-retrieve') - response = self.client.get(url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data['username'], 'testuser') - self.assertEqual(response.data['email'], 'testuser@example.com') - - def test_update_user(self): - url = reverse('user-update') - data = {'username': 'newuser'} - response = self.client.patch(url, data, format='json') - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(response.data, "user updated successfully") - self.user.refresh_from_db() - self.assertEqual(self.user.username, 'newuser') - - def test_delete_account(self): - test_group = Group.objects.create(title='testgroup', owner=self.user) - other_user = User.objects.create_user( - username='otheruser', - email='otheruser@example.com', - password='otherpassword' - ) - - test_group.add_member(other_user) - - url = reverse('delete-account') - response = self.client.delete(url) - - - self.assertEqual(response.data, "account deleted successfully") - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertEqual(other_user.groups.count(), 0) - test_group.delete() - other_user.delete() - - def cleanup(self): - self.user.delete() - \ No newline at end of file diff --git a/authentication/tests.py b/authentication/tests.py new file mode 100644 index 0000000..ba4b5d8 --- /dev/null +++ b/authentication/tests.py @@ -0,0 +1,163 @@ +import pytest +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APIClient +from .models import User, TempUser +import boto3 +from moto import mock_aws +from django.core import mail +from django.conf import settings +import os + +@pytest.fixture +def api_client(): + return APIClient() + +@pytest.fixture +def test_user(): + user = User.objects.create_user( + email='test@example.com', + username='testuser', + password='testpass123' + ) + return user + +@pytest.fixture +def test_temp_user(): + temp_user = TempUser.objects.create( + email='temp@example.com', + username='tempuser', + password='temppass123', + otp='123456' + ) + return temp_user + +@pytest.fixtureh +def auth_client(api_client, test_user): + tokens = test_user.tokens() + api_client.credentials(HTTP_AUTHORIZATION=f'Bearer {tokens["access"]}') + return api_client + +@pytest.fixture +def aws_credentials(): + boto3.setup_default_session( + aws_access_key_id="testing", + aws_secret_access_key="testing", + aws_session_token="testing" + ) + +@pytest.fixture +def s3_bucket(aws_credentials): + with mock_aws(): + s3 = boto3.client('s3') + s3.create_bucket(Bucket=settings.AWS_STORAGE_BUCKET_NAME) + yield s3 + +@pytest.mark.django_db +class TestAuthentication: + def test_register_user_success(self, api_client): + url = reverse('register') + data = { + 'email': 'new@example.com', + 'username': 'newuser', + 'password': 'newpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_201_CREATED + assert TempUser.objects.filter(email=data['email']).exists() + + def test_verify_email_success(self, api_client, test_temp_user): + url = reverse('verify-email') + data = {'otp': test_temp_user.otp} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert User.objects.filter(email=test_temp_user.email).exists() + assert not TempUser.objects.filter(email=test_temp_user.email).exists() + + def test_login_success(self, api_client, test_user): + url = reverse('login') + data = { + 'username': test_user.username, + 'password': 'testpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert 'access_token' in response.data + assert 'refresh_token' in response.data + + + def test_password_reset_request(self, api_client, test_user): + url = reverse('password-reset') + data = {'email': test_user.email} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_200_OK + assert len(mail.outbox) == 1 + assert mail.outbox[0].to[0] == test_user.email + + otp = mail.outbox[0].body.split()[-1] + confirmation_url = reverse('password-reset-confirm') + data = {'otp': otp} + response = api_client.post(confirmation_url, data) + assert response.status_code == status.HTTP_200_OK + assert 'access_token' in response.data + assert 'refresh_token' in response.data + + + + + def test_change_password(self, auth_client, test_user): + url = reverse('change-password') + data = { + 'old_password': 'testpass123', + 'new_password': 'newpass123' + } + response = auth_client.patch(url, data) + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + assert test_user.check_password('newpass123') + + @mock_aws + def test_update_user_profile(self, auth_client, test_user, s3_bucket): + url = reverse('user-update') + with open('test_image.jpg', 'wb') as f: + f.write(b'fake image content') + + with open('test_image.jpg', 'rb') as image: + data = { + 'username': 'updateduser', + 'level': 'B2', + 'city': 'Test City', + 'profile_image': image + } + response = auth_client.patch(url, data, format='multipart') + + assert response.status_code == status.HTTP_200_OK + test_user.refresh_from_db() + assert test_user.username == 'updateduser' + assert test_user.level == 'B2' + assert test_user.city == 'Test City' + assert test_user.profile_image is not None + + os.remove('test_image.jpg') + + def test_delete_account(self, auth_client, test_user): + url = reverse('delete-account') + response = auth_client.delete(url) + assert response.status_code == status.HTTP_200_OK + assert not User.objects.filter(id=test_user.id).exists() + + def test_register_existing_user(self, api_client, test_user): + url = reverse('register') + data = { + 'email': test_user.email, + 'username': test_user.username, + 'password': 'newpass123' + } + response = api_client.post(url, data) + assert response.status_code == status.HTTP_400_BAD_REQUEST + + def test_login_failure_wrong_credentials(self, api_client, test_user): + url = reverse('login') + data = {'username': test_user.username, 'password': 'invalidpass'} + response = api_client.post(url, data) + assert response.status_code == status.HTTP_401_UNAUTHORIZED \ No newline at end of file diff --git a/authentication/urls.py b/authentication/urls.py index 1061d1d..e326c69 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -6,16 +6,16 @@ ) urlpatterns = [ - path('register/', RegisterUserView.as_view()), - path('verify-email/', VerifyEmailView.as_view()), + path('register/', RegisterUserView.as_view(), name='register'), + path('verify-email/', VerifyEmailView.as_view(), name='verify-email'), path('login/', LoginUserView.as_view(), name='login'), - path('password-reset/', PasswordResetRequestView.as_view()), - path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password_reset_confirm'), + path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset'), + path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), path('change-password/', ChangePasswordView.as_view(), name='change-password'), diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..29e505d --- /dev/null +++ b/pytest.ini @@ -0,0 +1,4 @@ +[pytest] +testpaths = authentication +python_files = test_*.py +addopts = --maxfail=1 --disable-warnings -q \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 9b0b184..f59cf60 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,6 +12,7 @@ certifi==2024.8.30 cffi==1.17.1 channels==4.2.0 channels_redis==4.2.1 +charset-normalizer==3.4.1 click==8.1.7 click-didyoumean==0.3.1 click-plugins==1.1.1 @@ -23,6 +24,7 @@ cryptography==43.0.3 daphne==4.1.2 Django==5.1.2 django-cors-headers==4.6.0 +django-elasticsearch-dsl==8.0 django-filter==24.3 django-prometheus==2.3.1 django-redis==5.4.0 @@ -30,6 +32,9 @@ django-storages==1.14.4 djangorestframework==3.15.2 djangorestframework-simplejwt==5.3.1 drf-spectacular==0.27.2 +elastic-transport==8.15.1 +elasticsearch==8.17.0 +elasticsearch-dsl==8.17.0 execnet==2.1.1 factory_boy==3.3.1 Faker==33.1.0 @@ -41,19 +46,24 @@ idna==3.10 incremental==24.7.2 inflection==0.5.1 iniconfig==2.0.0 +Jinja2==3.1.5 jmespath==1.0.1 jsonschema==4.23.0 jsonschema-specifications==2024.10.1 kombu==5.4.2 +MarkupSafe==3.0.2 mccabe==0.7.0 +moto==5.0.25 msgpack==1.1.0 mysql-connector-python==9.1.0 mysqlclient==2.2.5 packaging==24.2 pillow==11.0.0 +pip-check-reqs==2.5.3 pluggy==1.5.0 prometheus_client==0.21.1 prompt_toolkit==3.0.48 +psycopg2==2.9.10 pyasn1==0.6.1 pyasn1_modules==0.4.1 pycodestyle==2.12.1 @@ -69,6 +79,8 @@ python-dotenv==1.0.1 PyYAML==6.0.2 redis==5.2.0 referencing==0.35.1 +requests==2.32.3 +responses==0.25.3 rpds-py==0.20.1 s3transfer==0.10.3 service-identity==24.2.0 @@ -83,4 +95,6 @@ uritemplate==4.1.1 urllib3==2.2.3 vine==5.1.0 wcwidth==0.2.13 -zope.interface==7.1.1 \ No newline at end of file +Werkzeug==3.1.3 +xmltodict==0.14.2 +zope.interface==7.1.1 From 7dc4cf96f22373fa15fbac434241917ab3b3a2c7 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 30 Dec 2024 08:36:15 +0330 Subject: [PATCH 54/60] Refactor/forget password (#45) * refactor: all groups and suggestions * refactor: forget password --- authentication/migrations/0004_otp.py | 24 +++++++++ authentication/models.py | 8 +++ authentication/serializers.py | 7 +-- authentication/services.py | 73 ++++++++++++++++++--------- authentication/urls.py | 3 +- authentication/utils.py | 1 - authentication/views.py | 23 +++++---- home/serializers.py | 11 ++-- home/services.py | 45 ++++++++--------- home/tasks.py | 8 ++- home/views.py | 2 +- 11 files changed, 130 insertions(+), 75 deletions(-) create mode 100644 authentication/migrations/0004_otp.py diff --git a/authentication/migrations/0004_otp.py b/authentication/migrations/0004_otp.py new file mode 100644 index 0000000..c73803a --- /dev/null +++ b/authentication/migrations/0004_otp.py @@ -0,0 +1,24 @@ +# Generated by Django 5.1.2 on 2024-12-27 16:54 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('authentication', '0003_alter_tempuser_otp'), + ] + + operations = [ + migrations.CreateModel( + name='OTP', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('otp', models.CharField(max_length=6)), + ('date_created', models.DateTimeField(auto_now_add=True)), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='forget_password_otp', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/authentication/models.py b/authentication/models.py index 16c3d89..2b2672b 100644 --- a/authentication/models.py +++ b/authentication/models.py @@ -44,3 +44,11 @@ class TempUser(models.Model): def __str__(self): return self.username + +class OTP(models.Model): + user = models.ForeignKey(User, on_delete=models.CASCADE, related_name='forget_password_otp') + otp = models.CharField(max_length=6) + date_created = models.DateTimeField(auto_now_add=True) + + def __str__(self): + return self.otp diff --git a/authentication/serializers.py b/authentication/serializers.py index 03d5d25..7c4e821 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -36,12 +36,7 @@ class Meta: model = User fields = ['email'] - -class PasswordResetConfirmSerializer(serializers.ModelSerializer): - password = serializers.CharField(min_length=8, max_length=64, write_only=True) - class Meta: - model = User - fields = ['password'] + class UserRetriveSerializer(serializers.ModelSerializer): diff --git a/authentication/services.py b/authentication/services.py index 46fbeaa..cde004e 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -1,4 +1,5 @@ from datetime import timedelta +import random from django.utils import timezone from django.utils.encoding import smart_str, smart_bytes from django.utils.http import urlsafe_base64_decode, urlsafe_base64_encode @@ -46,11 +47,11 @@ def check_temp_user(cls, username, email): temp_user_by_username = TempUser.objects.filter(username=username).first() temp_user_by_email = TempUser.objects.filter(email=email).first() - if temp_user_by_username and (timezone.now() - temp_user_by_username.date_joined) > timedelta(minutes=0): + if temp_user_by_username and (timezone.now() - temp_user_by_username.date_joined) > timedelta(minutes=5): temp_user_by_username.delete() return - if temp_user_by_email and (timezone.now() - temp_user_by_email.date_joined) > timedelta(minutes=0): + if temp_user_by_email and (timezone.now() - temp_user_by_email.date_joined) > timedelta(minutes=5): temp_user_by_email.delete() return @@ -76,10 +77,29 @@ def verify_email(cls, otp): raise ValidationError({'detail': 'invalid one time password', 'status': status.HTTP_400_BAD_REQUEST}) temp_user = TempUser.objects.get(otp=otp) + + if User.objects.filter(username=temp_user.username).exists(): + raise ValidationError({'detail': 'user already verified', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.create(username=temp_user.username, email=temp_user.email, password=temp_user.password) user.set_password(temp_user.password) user.save() temp_user.delete() + + @staticmethod + def resend_verification_email(email): + if not TempUser.objects.filter(email=email).exists(): + raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + + temp_user = TempUser.objects.get(email=email) + + if (timezone.now() - temp_user.date_joined) < timedelta(minutes=0): + raise ValidationError({'detail': 'Please wait for 2 minutes before requesting another OTP', 'status': status.HTTP_400_BAD_REQUEST}) + + send_otp_email(temp_user.email) + temp_user.refresh_from_db() + temp_user.date_joined = timezone.now() + temp_user.save() @classmethod def check_level(cls, level): @@ -155,34 +175,41 @@ def request_reset_password(email): user = User.objects.get(email=email) - uidb64 = urlsafe_base64_encode(smart_bytes(user.id)) - token = PasswordResetTokenGenerator().make_token(user) - - absolute_url = f'http://localhost:5173/reset-password/{uidb64}/{token}' + if (OTP.objects.filter(user=user).exists()) and (timezone.now() - OTP.objects.get(user=user).date_created) < timedelta(minutes=2): + raise ValidationError({'detail': 'Please wait for 2 minutes before requesting another OTP', 'status': status.HTTP_400_BAD_REQUEST}) - email_subject = 'Reset your password' - email_body = f'Click the link below to reset your password\n{absolute_url}' + if OTP.objects.filter(user=user).exists(): + old_otp = OTP.objects.get(user=user) + old_otp.delete() + otp = random.randint(100000, 999999) + OTP.objects.create(user=user, otp=otp) + email_subject = 'Forgot Password' + email_body = f'Your OTP is {otp}' + email = EmailMessage(subject=email_subject, body=email_body, from_email=settings.EMAIL_HOST_USER, to=[email]) email.send() @staticmethod - def confirm_reset_password(uidb64, token, new_password): - id = smart_str(urlsafe_base64_decode(uidb64)) - - if not User.objects.filter(id=id).exists(): - raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) - - user = User.objects.get(id=id) - - is_valid = PasswordResetTokenGenerator().check_token(user, token) - if not is_valid: - raise ValidationError({'detail': 'The reset link is invalid', 'status': status.HTTP_400_BAD_REQUEST}) - - user.set_password(new_password) - user.save() + def confirm_reset_password(otp): + print(otp) + print(OTP.objects.all()) + if not OTP.objects.filter(otp=otp).exists(): + raise ValidationError({'detail': 'Invalid OTP', 'status': status.HTTP_400_BAD_REQUEST}) + if (timezone.now() - OTP.objects.get(otp=otp).date_created) > timedelta(minutes=2): + raise ValidationError({'detail': 'OTP has expired', 'status': status.HTTP_400_BAD_REQUEST}) + + otp = OTP.objects.get(otp=otp) + user = otp.user + tokens = user.tokens() + otp.delete() + return { + 'email': user.email, + 'username': user.username, + 'access_token': str(tokens['access']), + 'refresh_token': str(tokens['refresh']), + } - return user @classmethod def delete_account(cls, user): diff --git a/authentication/urls.py b/authentication/urls.py index e326c69..8865688 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -8,12 +8,13 @@ urlpatterns = [ path('register/', RegisterUserView.as_view(), name='register'), path('verify-email/', VerifyEmailView.as_view(), name='verify-email'), + path('resend-verification-email/', ResendVerificationEmailView.as_view()), path('login/', LoginUserView.as_view(), name='login'), - + path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset'), path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), path('change-password/', ChangePasswordView.as_view(), name='change-password'), diff --git a/authentication/utils.py b/authentication/utils.py index c659cd1..11c447b 100644 --- a/authentication/utils.py +++ b/authentication/utils.py @@ -13,7 +13,6 @@ def send_otp_email(email): otp = random.randint(100000, 999999) while TempUser.objects.filter(otp=otp).exists(): otp = random.randint(100000, 999999) - temp_user = TempUser.objects.get(email=email) temp_user.otp = otp temp_user.save() diff --git a/authentication/views.py b/authentication/views.py index 3ac4f6a..3157fe5 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -41,9 +41,17 @@ def post(self, request): return Response(e.detail.get('detail'), status=e.detail.get('status')) except Exception as e: return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - +class ResendVerificationEmailView(APIView): + def post(self, request): + try: + UserService.resend_verification_email(request.data['email']) + return Response("verification email sent successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class LoginUserView(APIView): serializer_class = UserLoginSerializer @@ -79,22 +87,15 @@ def post(self, request): class PasswordResetConfirmView(APIView): - serializer_class = PasswordResetConfirmSerializer - def post(self, request, *args, **kwargs): - serializer = PasswordResetConfirmSerializer(data=request.data) - serializer.is_valid(raise_exception=True) - uidb64 = kwargs.get('uidb64') - token = kwargs.get('token') + def post(self, request): try: - UserService.confirm_reset_password(uidb64, token, serializer.validated_data['password']) - return Response("password reset successfully", status=status.HTTP_200_OK) + tokens = UserService.confirm_reset_password(request.data['otp']) + return Response(tokens, status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) except Exception as e: return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) - - class UserRetriveView(APIView): diff --git a/home/serializers.py b/home/serializers.py index 5757b87..ad3f6ae 100644 --- a/home/serializers.py +++ b/home/serializers.py @@ -23,16 +23,21 @@ class Meta: fields = ['id', 'day_of_week', 'start_time', 'end_time'] class SuggestionSerializer(serializers.ModelSerializer): + requested = serializers.SerializerMethodField() class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'requested'] + def get_requested(self, obj): + request = self.context['request'] + return request.user in obj.pending_members.all() + class GroupSerializer(serializers.ModelSerializer): member_count = serializers.SerializerMethodField() - + requested = serializers.SerializerMethodField() class Meta: model = Group - fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count'] + fields = ['id', 'title', 'description', 'level', 'city', 'neighborhood', 'created_at', 'meeting_url', 'private', 'member_count', 'requestd'] def get_member_count(self, obj): diff --git a/home/services.py b/home/services.py index 9df33e9..f9f4fb1 100644 --- a/home/services.py +++ b/home/services.py @@ -166,33 +166,30 @@ def get_suggestions(user) -> list[Group]: if level_index < len(VALID_LEVELS) - 1: levels_to_consider.append(VALID_LEVELS[level_index + 1]) - groups = Group.objects.filter(level__in=levels_to_consider).order_by('?')[:100] - - groups = list(groups) - user_time_slots = UserTimeSlotService.get_user_time_slots(user) - - groups = Group.objects.filter(id__in=[group.id for group in groups]).annotate( - total_overlap=Coalesce( - Sum( - ExpressionWrapper( - Case( - *[ - When( - time_slots__day_of_week=user_slot.day_of_week, - then=F('time_slots__end_time') - F('time_slots__start_time') - ) - for user_slot in user_time_slots - ], + groups = Group.objects.filter(level__in=levels_to_consider)\ + .exclude(id__in=[group.id for group in user.groups.all()])\ + .order_by('?')[:100].annotate( + total_overlap=Coalesce( + Sum( + ExpressionWrapper( + Case( + *[ + When( + time_slots__day_of_week=user_slot.day_of_week, + then=F('time_slots__end_time') - F('time_slots__start_time') + ) + for user_slot in user_time_slots + ], + output_field=FloatField() + ), output_field=FloatField() - ), - output_field=FloatField() - ) - ), - Value(0.0), - output_field=FloatField() + ) + ), + Value(0.0), + output_field=FloatField() + ) ) - ) if user.neighborhood: groups = groups.annotate( diff --git a/home/tasks.py b/home/tasks.py index 2df6b29..fd9415c 100644 --- a/home/tasks.py +++ b/home/tasks.py @@ -28,13 +28,11 @@ def find_matching_groups(user): if level_index < len(VALID_LEVELS) - 1: levels_to_consider.append(VALID_LEVELS[level_index + 1]) - groups = Group.objects.filter(level__in=levels_to_consider).order_by('?')[:100] - - groups = list(groups) - user_time_slots = UserTimeSlotService.get_user_time_slots(user) - groups = Group.objects.filter(id__in=[group.id for group in groups]).annotate( + groups = Group.objects.filter(level__in=levels_to_consider)\ + .exclude(id__in=[group.id for group in user.groups.all()])\ + .order_by('?')[:1000].annotate( total_overlap=Coalesce( Sum( ExpressionWrapper( diff --git a/home/views.py b/home/views.py index 0486d09..b7be878 100644 --- a/home/views.py +++ b/home/views.py @@ -54,7 +54,7 @@ def get(self, request): paginator = self.pagination_class() paginator.page_size = 4 paginated_data = paginator.paginate_queryset(suggestions, request) - serializer = self.serializer_class(paginated_data, many=True) + serializer = self.serializer_class(paginated_data, many=True, context={'request': request}) return paginator.get_paginated_response(serializer.data) except Exception: return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) From 657eb72177f486e28f59381115830956eac0fa4e Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Mon, 30 Dec 2024 21:02:45 +0330 Subject: [PATCH 55/60] Feat/all notifs (#46) * feat: add all notifications * feat: save notifications * feat: delete notification * feat: delete all notifications --- FD/urls.py | 2 ++ authentication/services.py | 3 ++ groups/services.py | 10 ++++++- notifications/serializers.py | 2 +- notifications/services.py | 18 ++++++++++++ notifications/urls.py | 8 ++++++ notifications/views.py | 56 ++++++++++++++++++++++++++++++++++-- 7 files changed, 95 insertions(+), 4 deletions(-) create mode 100644 notifications/services.py diff --git a/FD/urls.py b/FD/urls.py index 76fdbb8..16a62ab 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -19,4 +19,6 @@ def trigger_error(request): path('api/schema/', SpectacularAPIView.as_view(), name='schema'), path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), + + path('notifications/', include('notifications.urls')), ] diff --git a/authentication/services.py b/authentication/services.py index cde004e..1169b88 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -10,6 +10,8 @@ from rest_framework import status import boto3 + +from notifications.models import Notification from .models import * from .utils import send_otp_email from FD import settings @@ -216,6 +218,7 @@ def delete_account(cls, user): for group in user.owned_groups.all(): for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + Notification.objects.create(recipient=member, message=f"{group.title} has been deleted") group.delete() if user.profile_image: diff --git a/groups/services.py b/groups/services.py index 37fdc0e..780efa1 100644 --- a/groups/services.py +++ b/groups/services.py @@ -64,17 +64,20 @@ def update_group(title, data): GroupService.check_title(data.get('title')) for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} has been renamed to {data.get('title')}") + Notification.objects.create(recipient=member, message=f"{group.title} has been renamed to {data.get('title')}") group.title = data.get('title') if 'description' in data and data.get('description') != group.description: for member in group.members.all(): - NotificationConsumer.send_notification(member, f"{member.username} updated the description of {group.title}") + NotificationConsumer.send_notification(member, f"{member.username} updated the noticeboard of {group.title}") + Notification.objects.create(recipient=member, message=f"{member.username} updated the noticeboard of {group.title}") group.description = data.get('description') if 'level' in data and data.get('level') != group.level: GroupService.check_level(data.get('level')) for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} level has been updated to {data.get('level')}") + Notification.objects.create(recipient=member, message=f"{group.title} level has been updated to {data.get('level')}") group.level = data.get('level') if not group.image and data.get('image'): @@ -105,6 +108,7 @@ def delete_group(cls, title): group = Group.objects.get(title=title) for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} has been deleted") + Notification.objects.create(recipient=member, message=f"{group.title} has been deleted") if group.image: cls.delete_s3_object(group.image.name) @@ -115,6 +119,8 @@ def join_request(title, user): group = Group.objects.get(title=title) message = "" group_status = "" + print(user.username) + print(group.members.all()) if group.private: if user in group.pending_members.all(): @@ -134,6 +140,7 @@ def join_request(title, user): message = f"{user.username} joined {group.title}" NotificationConsumer.send_notification(group.owner, message) + Notification.objects.create(recipient=group.owner, message=message) return group_status @@ -163,6 +170,7 @@ def accept_request(title, username): group = Group.objects.get(title=title) group.accept_pending_member(user) NotificationConsumer.send_notification(user, f"your request to join {group.title} has been accepted") + Notification.objects.create(recipient=user, message=f"your request to join {group.title} has been accepted") @staticmethod def decline_request(title, username): diff --git a/notifications/serializers.py b/notifications/serializers.py index 85a7d97..53d8dd0 100644 --- a/notifications/serializers.py +++ b/notifications/serializers.py @@ -7,6 +7,6 @@ class NotificationSerializer(serializers.ModelSerializer): class Meta: model = Notification fields = '__all__' - read_only_fields = '__all__' + diff --git a/notifications/services.py b/notifications/services.py new file mode 100644 index 0000000..6fd8df0 --- /dev/null +++ b/notifications/services.py @@ -0,0 +1,18 @@ +from .models import Notification +from rest_framework.exceptions import ValidationError + +class NotificationService: + @staticmethod + def get_notifications(user): + result = Notification.objects.filter(recipient=user) + return result[::-1] + + def delete_notification(user, notification_id): + try: + notification = Notification.objects.get(recipient=user, id=notification_id) + notification.delete() + except Notification.DoesNotExist: + raise ValidationError({'detail': 'Notification not found', 'status': 404}) + + def delete_all_notifications(user): + Notification.objects.filter(recipient=user).delete() \ No newline at end of file diff --git a/notifications/urls.py b/notifications/urls.py index e69de29..40dacc4 100644 --- a/notifications/urls.py +++ b/notifications/urls.py @@ -0,0 +1,8 @@ +from django.urls import path, include +from .views import * + +urlpatterns = [ + path('all-notifications/', AllNotificationsView.as_view(), name='all-notifications'), + path('delete-notification/', DeleteNotificationView.as_view(), name='delete-notification'), + path('delete-all-notifications/', DeleteAllNotificationsView.as_view(), name='delete-all-notifications'), +] \ No newline at end of file diff --git a/notifications/views.py b/notifications/views.py index 91ea44a..8303a94 100644 --- a/notifications/views.py +++ b/notifications/views.py @@ -1,3 +1,55 @@ -from django.shortcuts import render +from rest_framework import status +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework.exceptions import ValidationError +from rest_framework.permissions import IsAuthenticated +from rest_framework.pagination import PageNumberPagination + +from .serializers import * +from .services import * + +class AllNotificationsView(APIView): + permission_classes = [IsAuthenticated] + serializer_class = NotificationSerializer + pagination_class = PageNumberPagination + def get(self, request): + try: + result = NotificationService.get_notifications(request.user) + paginator = self.pagination_class() + paginator.page_size = 10 + paginated_data = paginator.paginate_queryset(result, request) + + serializer = self.serializer_class(paginated_data, many=True) + return paginator.get_paginated_response(serializer.data) + + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + +class DeleteNotificationView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + try: + response = NotificationService.delete_notification(request.user, request.data.get('notification_id')) + return Response("Notification deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + +class DeleteAllNotificationsView(APIView): + permission_classes = [IsAuthenticated] + + def delete(self, request): + try: + response = NotificationService.delete_all_notifications(request.user) + return Response("All notifications deleted successfully", status=status.HTTP_200_OK) + except ValidationError as e: + return Response(e.detail.get('detail'), status=e.detail.get('status')) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + -# Create your views here. From e9ce13686f01cef51c047d68346de1c0b216f905 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 31 Dec 2024 00:49:12 +0330 Subject: [PATCH 56/60] Fix/change password (#47) * feat: add all notifications * feat: save notifications * feat: delete notification * feat: delete all notifications * fix: change password * feat: add error test apis --- FD/urls.py | 32 ++++++++++++++++++++++++++++++++ authentication/serializers.py | 3 +-- authentication/services.py | 8 +------- authentication/urls.py | 2 +- authentication/views.py | 3 +-- 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/FD/urls.py b/FD/urls.py index 16a62ab..a17a2ac 100644 --- a/FD/urls.py +++ b/FD/urls.py @@ -2,10 +2,35 @@ from django.urls import include, path from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView from django_prometheus import exports +from rest_framework.response import Response +from rest_framework import status def trigger_error(request): division_by_zero = 1 / 0 +from rest_framework.decorators import api_view + +@api_view(['GET']) +def trigger_400(request): + return Response("Bad Request", status=status.HTTP_400_BAD_REQUEST) + +@api_view(['GET']) +def trigger_401(request): + return Response("Unauthorized", status=status.HTTP_401_UNAUTHORIZED) + +@api_view(['GET']) +def trigger_403(request): + return Response("Forbidden", status=status.HTTP_403_FORBIDDEN) + +@api_view(['GET']) +def trigger_404(request): + return Response("Not Found", status=status.HTTP_404_NOT_FOUND) + +@api_view(['GET']) +def trigger_500(request): + return Response("Internal Server Error", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + urlpatterns = [ path('trigger_error/', trigger_error), path('', include('django_prometheus.urls')), @@ -21,4 +46,11 @@ def trigger_error(request): path('api/schema/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'), path('notifications/', include('notifications.urls')), + + path('trigger_400/', trigger_400), + path('trigger_401/', trigger_401), + path('trigger_403/', trigger_403), + path('trigger_404/', trigger_404), + path('trigger_500/', trigger_500), + ] diff --git a/authentication/serializers.py b/authentication/serializers.py index 7c4e821..3e9345d 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -53,8 +53,7 @@ class Meta: class ChangePasswordSerializer(serializers.Serializer): - old_password = serializers.CharField(min_length=8, max_length=64, write_only=True) new_password = serializers.CharField(min_length=8, max_length=64, write_only=True) class Meta: model = User - fields = ['old_password', 'new_password'] + fields = ['new_password'] diff --git a/authentication/services.py b/authentication/services.py index 1169b88..5af0a6a 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -139,13 +139,7 @@ def update_user(cls, user, data): return user @staticmethod - def change_password(user, old_password, new_password): - if not user.check_password(old_password): - raise ValidationError({'detail': 'Invalid password', 'status': status.HTTP_400_BAD_REQUEST}) - - if old_password == new_password: - raise ValidationError({'detail': 'New password cannot be the same as the old password', 'status': status.HTTP_400_BAD_REQUEST}) - + def change_password(user, new_password): user.set_password(new_password) user.save() return user diff --git a/authentication/urls.py b/authentication/urls.py index 8865688..0acdf69 100644 --- a/authentication/urls.py +++ b/authentication/urls.py @@ -16,7 +16,7 @@ path('password-reset/', PasswordResetRequestView.as_view(), name='password-reset'), - path('password-reset-confirm///', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), + path('password-reset-confirm/', PasswordResetConfirmView.as_view(), name='password-reset-confirm'), path('change-password/', ChangePasswordView.as_view(), name='change-password'), diff --git a/authentication/views.py b/authentication/views.py index 3157fe5..6e7e700 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -139,10 +139,9 @@ def patch(self, request): username = request.user.username user = User.objects.get(username=username) - old_password = serializer.validated_data.get('old_password') new_password = serializer.validated_data.get('new_password') - UserService.change_password(user, old_password, new_password) + UserService.change_password(user, new_password) return Response("password changed successfully", status=status.HTTP_200_OK) except ValidationError as e: From 803d45720e464c9f1a5885ea9286556fb9629151 Mon Sep 17 00:00:00 2001 From: Mahziar <74906698+smm-1383@users.noreply.github.com> Date: Tue, 31 Dec 2024 02:54:30 +0330 Subject: [PATCH 57/60] Fix/permissions (#48) * fix: accounts * refactor: modify group detail api * fix: scheduling --- groups/permissions.py | 9 ------ groups/services.py | 72 +++++++++++++++++++++++++++++++++++-------- groups/views.py | 37 +++++++++++----------- home/permissions.py | 9 ------ home/services.py | 21 +++++++++++-- home/views.py | 14 ++++----- 6 files changed, 103 insertions(+), 59 deletions(-) delete mode 100644 groups/permissions.py delete mode 100644 home/permissions.py diff --git a/groups/permissions.py b/groups/permissions.py deleted file mode 100644 index 4ee7820..0000000 --- a/groups/permissions.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.permissions import BasePermission - -class IsGroupOwner(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.owner == request.user - -class IsGroupMember(BasePermission): - def has_object_permission(self, request, view, obj): - return request.user in obj.members.all() \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index 780efa1..26381fa 100644 --- a/groups/services.py +++ b/groups/services.py @@ -46,19 +46,25 @@ def create_group(cls, user, data): return group @staticmethod - def retrieve_group(title): + def retrieve_group(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if not Group.objects.get(title=title).members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) group = Group.objects.get(title=title) return group @staticmethod - def update_group(title, data): + def update_group(title, data, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) - group = Group.objects.get(title=title) + group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) if 'title' in data and data.get('title') != group.title: GroupService.check_title(data.get('title')) @@ -102,10 +108,14 @@ def update_group(title, data): group.save() @classmethod - def delete_group(cls, title): + def delete_group(cls, title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + for member in group.members.all(): NotificationConsumer.send_notification(member, f"{group.title} has been deleted") Notification.objects.create(recipient=member, message=f"{group.title} has been deleted") @@ -119,8 +129,6 @@ def join_request(title, user): group = Group.objects.get(title=title) message = "" group_status = "" - print(user.username) - print(group.members.all()) if group.private: if user in group.pending_members.all(): @@ -152,35 +160,63 @@ def cancel_join_request(title, user): group.pending_members.remove(user) @staticmethod - def pending_requests(title): + def pending_requests(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if group.owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + return group.pending_members.all() @staticmethod - def accept_request(title, username): + def accept_request(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + if User.objects.get(username=username) not in Group.objects.get(title=title).pending_members.all(): + raise ValidationError({'detail': 'This user has not sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + if User.objects.get(username=username) in Group.objects.get(title=title).members.all(): + raise ValidationError({'detail': 'This user is already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.get(username=username) group = Group.objects.get(title=title) + + group.accept_pending_member(user) NotificationConsumer.send_notification(user, f"your request to join {group.title} has been accepted") Notification.objects.create(recipient=user, message=f"your request to join {group.title} has been accepted") @staticmethod - def decline_request(title, username): + def decline_request(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) + if User.objects.get(username=username) not in Group.objects.get(title=title).pending_members.all(): + raise ValidationError({'detail': 'This user has not sent a request to join this group', 'status': status.HTTP_400_BAD_REQUEST}) + + if User.objects.get(username=username) in Group.objects.get(title=title).members.all(): + raise ValidationError({'detail': 'This user is already a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + user = User.objects.get(username=username) group = Group.objects.get(title=title) + + group.decline_pending_member(user) @staticmethod @@ -189,22 +225,27 @@ def leave_group(title, user): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) + if group.owner == user: raise ValidationError({'detail': 'You cannot leave your own group', 'status': status.HTTP_400_BAD_REQUEST}) - if not group.members.filter(username=user.username).exists(): - raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) group.save() @staticmethod - def kick_member(title, username): + def kick_member(title, username, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + if not User.objects.filter(username=username).exists(): raise ValidationError({'detail': 'User not found', 'status': status.HTTP_404_NOT_FOUND}) @@ -214,16 +255,21 @@ def kick_member(title, username): user = User.objects.get(username=username) group = Group.objects.get(title=title) + if not group.members.filter(username=user.username).exists(): raise ValidationError({'detail': 'this user is not a member of this group', 'status': status.HTTP_400_BAD_REQUEST}) group.remove_member(user) group.save() @staticmethod - def member_list(title): + def member_list(title, user): if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) group = Group.objects.get(title=title) + + if not group.members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) + return group.members.all() diff --git a/groups/views.py b/groups/views.py index 0f809e5..f785d9c 100644 --- a/groups/views.py +++ b/groups/views.py @@ -9,7 +9,6 @@ from authentication.models import User from .serializers import * from .models import * -from .permissions import * from .services import * @@ -33,11 +32,12 @@ def post(self, request): class GroupRetrieveView(APIView): + permission_classes = [IsAuthenticated] serializer_class = GroupRetrieveSerializer def get(self, request, *args, **kwargs): try: - group = GroupService.retrieve_group(kwargs.get('title')) + group = GroupService.retrieve_group(kwargs.get('title'), request.user) serializer = self.serializer_class(group) return Response(serializer.data, status=status.HTTP_200_OK) except ValidationError as e: @@ -49,12 +49,12 @@ def get(self, request, *args, **kwargs): class GroupUpdateView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] parser_classes = [MultiPartParser, FormParser, JSONParser] def patch(self, request, *args, **kwargs): try: - GroupService.update_group(kwargs.get('title'), request.data) + GroupService.update_group(kwargs.get('title'), request.data, request.user) return Response("group updated successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -65,11 +65,11 @@ def patch(self, request, *args, **kwargs): class GroupDeleteView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] def delete(self, request, *args, **kwargs): try: - GroupService.delete_group(kwargs.get('title')) + GroupService.delete_group(kwargs.get('title'), request.user) return Response("group deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -109,13 +109,13 @@ def post(self, request, *args, **kwargs): class GroupPendingRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupPendingRequestSerializer pagination_class = PageNumberPagination def get(self, request, *args, **kwargs): try: - group_requests = GroupService.pending_requests(kwargs.get('title')) + group_requests = GroupService.pending_requests(kwargs.get('title'), request.user) paginator = self.pagination_class() paginated_data = paginator.paginate_queryset(group_requests, request) serializer = self.serializer_class(paginated_data, many=True) @@ -128,14 +128,14 @@ def get(self, request, *args, **kwargs): class GroupAcceptRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupAcceptRequestSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.accept_request(kwargs.get('title'), serializer.validated_data['username']) + GroupService.accept_request(kwargs.get('title'), serializer.validated_data['username'], request.user) return Response("request accepted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -144,14 +144,14 @@ def post(self, request, *args, **kwargs): class GroupDeclineRequestView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupDeclineRequestSerializer def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username']) + GroupService.decline_request(kwargs.get('title'), serializer.validated_data['username'], request.user) return Response("request declined successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -161,7 +161,7 @@ def post(self, request, *args, **kwargs): class GroupLeaveView(APIView): - permission_classes = [IsAuthenticated, IsGroupMember] + permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): try: @@ -177,14 +177,14 @@ def post(self, request, *args, **kwargs): class GroupKickView(APIView): serializer_class = GroupKickSerializer - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] def post(self, request, *args, **kwargs): serializer = self.serializer_class(data=request.data) serializer.is_valid(raise_exception=True) try: - GroupService.kick_member(kwargs.get('title'), serializer.validated_data['username']) + GroupService.kick_member(kwargs.get('title'), serializer.validated_data['username'], request.user) return Response("user kicked successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) @@ -199,7 +199,8 @@ class GroupMemberListView(APIView): def get(self, request, *args, **kwargs): try: - members = GroupService.member_list(kwargs.get('title')) + print(request.user) + members = GroupService.member_list(kwargs.get('title'), request.user) paginator = self.pagination_class() paginator.page_size = 4 @@ -209,6 +210,6 @@ def get(self, request, *args, **kwargs): return paginator.get_paginated_response(serializer.data) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - except Exception as e: - return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + # except Exception as e: + # return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) diff --git a/home/permissions.py b/home/permissions.py deleted file mode 100644 index 4ee7820..0000000 --- a/home/permissions.py +++ /dev/null @@ -1,9 +0,0 @@ -from rest_framework.permissions import BasePermission - -class IsGroupOwner(BasePermission): - def has_object_permission(self, request, view, obj): - return obj.owner == request.user - -class IsGroupMember(BasePermission): - def has_object_permission(self, request, view, obj): - return request.user in obj.members.all() \ No newline at end of file diff --git a/home/services.py b/home/services.py index f9f4fb1..4a03dc7 100644 --- a/home/services.py +++ b/home/services.py @@ -102,8 +102,16 @@ def validate_overlap(group, day_of_week: str, start_time: float, end_time: float @classmethod - def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: + def create_group_time_slot(cls, title, user, day_of_week: str, start_time: float, end_time: float) -> GroupTimeSlot: + if not Group.objects.filter(title=title).exists(): + raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + + group = Group.objects.get(title=title) + print(group.owner) + print(user) cls.validate_time_range(start_time, end_time) cls.validate_overlap(group, day_of_week, start_time, end_time) @@ -116,17 +124,24 @@ def create_group_time_slot(cls, group, day_of_week: str, start_time: float, end_ return time_slot - def get_group_time_slots(title) -> QuerySet[GroupTimeSlot]: + def get_group_time_slots(title, user) -> QuerySet[GroupTimeSlot]: if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + if not Group.objects.get(title=title).members.filter(username=user.username).exists(): + raise ValidationError({'detail': 'You are not a member of this group', 'status': status.HTTP_403_FORBIDDEN}) + group = Group.objects.get(title=title) return group.time_slots.all() @staticmethod - def delete_time_slot(title, time_slot_id: int) -> None: + def delete_time_slot(title, time_slot_id: int, user) -> None: if not Group.objects.filter(title=title).exists(): raise ValidationError({'detail': 'Group not found', 'status': status.HTTP_404_NOT_FOUND}) + + if Group.objects.get(title=title).owner != user: + raise ValidationError({'detail': 'You are not the owner of this group', 'status': status.HTTP_403_FORBIDDEN}) + group = Group.objects.get(title=title) if not group.time_slots.filter(id=time_slot_id).exists(): raise ValidationError({'detail': 'Time slot not found', 'status': status.HTTP_404_NOT_FOUND}) diff --git a/home/views.py b/home/views.py index b7be878..5bd53e3 100644 --- a/home/views.py +++ b/home/views.py @@ -14,7 +14,6 @@ from .serializers import * from .services import * from groups.models import* -from .permissions import * from .filters import * class HomeView(APIView): @@ -137,7 +136,7 @@ def delete(self, request, *args, **kwargs): class GroupTimeSlotCreateView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] serializer_class = GroupTimeSlotSerializer def post(self, request, *args, **kwargs): @@ -145,7 +144,8 @@ def post(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) try: time_slot = GroupTimeSlotService.create_group_time_slot( - group=Group.objects.get(title=kwargs.get('title')), + kwargs.get('title'), + request.user, **serializer.validated_data ) return Response(self.serializer_class(time_slot).data, status=status.HTTP_201_CREATED) @@ -162,7 +162,7 @@ class GroupTimeSlotListView(APIView): def get(self, request, *args, **kwargs): try: - time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title')) + time_slots = GroupTimeSlotService.get_group_time_slots(kwargs.get('title'), request.user) serializer = self.serializer_class(time_slots, many=True) return Response(serializer.data, status=status.HTTP_200_OK) except ValidationError as e: @@ -172,16 +172,16 @@ def get(self, request, *args, **kwargs): class GroupTimeSlotDeleteView(APIView): - permission_classes = [IsAuthenticated, IsGroupOwner] + permission_classes = [IsAuthenticated] def delete(self, request, *args, **kwargs): try: - GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id')) + GroupTimeSlotService.delete_time_slot(kwargs.get('title'), kwargs.get('id'), request.user) return Response("time slot deleted successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) except Exception as e: - return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) From 80620e8c2f0fbcd884b1a2616312d14162a78dae Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 31 Dec 2024 05:53:45 +0330 Subject: [PATCH 58/60] Fix/chat notif (#49) --- chat/consumers.py | 41 +++++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 20 deletions(-) diff --git a/chat/consumers.py b/chat/consumers.py index 9c7de57..1e025ec 100644 --- a/chat/consumers.py +++ b/chat/consumers.py @@ -20,18 +20,16 @@ from django.db.models import F class ChatConsumer(WebsocketConsumer): - connected_users = set() + connected_users = {} def connect(self): self.room_name = self.scope["url_route"]["kwargs"]["room_name"] - self.room_group_name = f"chat_{self.room_name}" try: self.chat = Chat.objects.get(group__title=self.room_name) except Chat.DoesNotExist: self.close() - token = self.scope['url_route']['kwargs']['token'] user = self.get_user_from_token(token) @@ -39,9 +37,8 @@ def connect(self): self.close() self.scope['user'] = user - self.connected_users.add(user.id) + self.connected_users[user.id] = self.connected_users.get(user.id, 0) + 1 - if user not in self.chat.group.members.all(): self.accept() self.send(text_data=json.dumps({ @@ -50,19 +47,20 @@ def connect(self): })) self.close() + # Only join the room group async_to_sync(self.channel_layer.group_add)( - self.room_group_name, self.channel_name + self.room_name, + self.channel_name ) self.accept() - messages = self.get_chat_messages() + messages = self.get_chat_messages() self.send(text_data=json.dumps({ 'type': 'chat_history', 'messages': messages }, cls=DjangoJSONEncoder)) - def get_chat_messages(self): return list(self.chat.messages.all().order_by('-timestamp').values( 'content', 'sender__username', 'timestamp' @@ -81,11 +79,15 @@ def get_user_from_token(self, token): return None def disconnect(self, close_code): - + # Only leave the room group async_to_sync(self.channel_layer.group_discard)( - self.room_group_name, self.channel_name + self.room_name, + self.channel_name ) - self.connected_users.remove(self.scope["user"].id) + + self.connected_users[self.scope['user'].id] = self.connected_users.get(self.scope['user'].id, 1) - 1 + if self.connected_users[self.scope['user'].id] <= 0: + del self.connected_users[self.scope['user'].id] def receive(self, text_data): text_data_json = json.loads(text_data) @@ -97,22 +99,22 @@ def receive(self, text_data): content=message, sender=user ) - group_members = Group.objects.get(title=self.room_name).members.exclude(id__in=self.connected_users) - for member in group_members: - if member.id not in self.connected_users: - NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") - + # Broadcast to room async_to_sync(self.channel_layer.group_send)( - self.room_group_name, + self.room_name, { - "type": "chat_message", - "message": db_message.content, + "type": "chat_message", + "message": db_message.content, "username": db_message.sender.username, "timestamp": db_message.timestamp.isoformat(), } ) + # Send notifications to offline members + group_members = self.chat.group.members.exclude(id__in=self.connected_users.keys()) + for member in group_members: + NotificationConsumer.send_notification(member, f"New message in {self.room_name} from {user.username}") def chat_message(self, event): @@ -121,4 +123,3 @@ def chat_message(self, event): 'username': event['username'], 'timestamp': event['timestamp'] })) - From 65f9357ba4bdeafc1fcd8dd789663a813c8863a6 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Tue, 31 Dec 2024 21:49:17 +0330 Subject: [PATCH 59/60] Fix/authentication issues (#50) * fix: swagger * chore: commenting --- authentication/serializers.py | 11 ++++++++++- authentication/views.py | 8 ++++---- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/authentication/serializers.py b/authentication/serializers.py index 3e9345d..38f2123 100644 --- a/authentication/serializers.py +++ b/authentication/serializers.py @@ -16,6 +16,12 @@ class VerifyEmailSerializer(serializers.Serializer): class Meta: model = TempUser fields = ['otp'] + +class ResendVerificationEmailSerializer(serializers.Serializer): + email = serializers.EmailField() + class Meta: + model = TempUser + fields = ['email'] class UserLoginSerializer(serializers.ModelSerializer): @@ -30,13 +36,16 @@ class Meta: class UserLogoutSerializer(serializers.Serializer): refresh_token = serializers.CharField() + class PasswordResetRequestSerializer(serializers.ModelSerializer): email = serializers.EmailField() class Meta: model = User fields = ['email'] - + +class PasswordResetConfirmSerializer(serializers.Serializer): + otp = serializers.CharField(max_length=6) class UserRetriveSerializer(serializers.ModelSerializer): diff --git a/authentication/views.py b/authentication/views.py index 6e7e700..e9fa925 100644 --- a/authentication/views.py +++ b/authentication/views.py @@ -43,7 +43,7 @@ def post(self, request): return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class ResendVerificationEmailView(APIView): - + serializer_class = ResendVerificationEmailSerializer def post(self, request): try: UserService.resend_verification_email(request.data['email']) @@ -81,13 +81,13 @@ def post(self, request): return Response("password reset link sent successfully", status=status.HTTP_200_OK) except ValidationError as e: return Response(e.detail.get('detail'), status=e.detail.get('status')) - # except Exception as e: - # return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + except Exception as e: + return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) class PasswordResetConfirmView(APIView): - + serializer_class = PasswordResetConfirmSerializer def post(self, request): try: tokens = UserService.confirm_reset_password(request.data['otp']) From 839038a15877677c25e009b58c730d7c20c24d67 Mon Sep 17 00:00:00 2001 From: KianYari <164557175+KianYari@users.noreply.github.com> Date: Wed, 29 Jan 2025 12:08:49 +0330 Subject: [PATCH 60/60] Fix/sprint5 issues (#51) * fix: create group validation * fix: user and group profile images * suggestion fixed * filtering and ordering fixed * my groups filtering fixed * suggestion fixed --------- Co-authored-by: Mahziar Co-authored-by: Kian Yari --- authentication/services.py | 2 +- groups/serializers.py | 5 +---- groups/services.py | 5 +++-- home/services.py | 11 +++++++---- home/views.py | 16 +++++++++++++--- requirements.txt | 1 - 6 files changed, 25 insertions(+), 15 deletions(-) diff --git a/authentication/services.py b/authentication/services.py index 5af0a6a..69307d1 100644 --- a/authentication/services.py +++ b/authentication/services.py @@ -123,7 +123,7 @@ def update_user(cls, user, data): path = data.get('profile_image').name + f'_{user.id}' user.profile_image.save(path, data.get('profile_image')) - if user.profile_image and not data.get('profile_image'): + if user.profile_image and data.get('profile_image') == '': cls.delete_s3_object(user.profile_image.name) user.profile_image = None diff --git a/groups/serializers.py b/groups/serializers.py index 31b4530..814861f 100644 --- a/groups/serializers.py +++ b/groups/serializers.py @@ -54,7 +54,4 @@ class GroupKickSerializer(serializers.Serializer): class GroupMemberListSerializer(serializers.ModelSerializer): class Meta: model = User - fields = ['username', 'profile_image', 'level'] - - - \ No newline at end of file + fields = ['username', 'profile_image', 'level'] \ No newline at end of file diff --git a/groups/services.py b/groups/services.py index 26381fa..a619700 100644 --- a/groups/services.py +++ b/groups/services.py @@ -35,10 +35,11 @@ def check_title(title): def check_level(level): if level not in GroupService.VALID_LEVELS: - raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(Group.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) + raise ValidationError({'detail': f'Invalid level. Please select one of the following: {", ".join(GroupService.VALID_LEVELS)}', 'status': status.HTTP_400_BAD_REQUEST}) @classmethod def create_group(cls, user, data): + cls.check_level(data.get('level')) cls.check_title(data.get('title')) group = Group.objects.create(owner=user, **data) Chat.objects.create(group=group) @@ -90,7 +91,7 @@ def update_group(title, data, user): path = data.get('image').name + f'_{group.id}' group.image.save(path, data.get('image')) - if group.image and not data.get('image'): + if group.image and data.get('image') == '': GroupService.delete_s3_object(group.image.name) group.image = None diff --git a/home/services.py b/home/services.py index 4a03dc7..cd7242d 100644 --- a/home/services.py +++ b/home/services.py @@ -172,6 +172,7 @@ def get_suggestions(user) -> list[Group]: group_ids = [int(group_id) for group_id in group_ids] groups = Group.objects.filter(id__in=group_ids) return groups + VALID_LEVELS = ['A1', 'A2', 'B1', 'B2', 'C1', 'C2'] user_level = user.level.upper() level_index = VALID_LEVELS.index(user_level) @@ -182,9 +183,12 @@ def get_suggestions(user) -> list[Group]: levels_to_consider.append(VALID_LEVELS[level_index + 1]) user_time_slots = UserTimeSlotService.get_user_time_slots(user) + user_group_ids = list(user.joined_groups.values_list('id', flat=True)) + + groups = Group.objects.filter(level__in=levels_to_consider)\ - .exclude(id__in=[group.id for group in user.groups.all()])\ - .order_by('?')[:100].annotate( + .exclude(id__in=user_group_ids)\ + .annotate( total_overlap=Coalesce( Sum( ExpressionWrapper( @@ -202,10 +206,9 @@ def get_suggestions(user) -> list[Group]: ) ), Value(0.0), - output_field=FloatField() ) ) - + if user.neighborhood: groups = groups.annotate( total_overlap=ExpressionWrapper( diff --git a/home/views.py b/home/views.py index 5bd53e3..a325185 100644 --- a/home/views.py +++ b/home/views.py @@ -20,8 +20,10 @@ class HomeView(APIView): permission_classes = [IsAuthenticated] serializer_class = HomeSerializer pagination_class = PageNumberPagination - filter_backends = [SearchFilter] + filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] + filterset_class = GroupFilter search_fields = ['title'] + ordering_fields = ['title', 'level', 'member_count'] def get(self, request): try: @@ -46,17 +48,25 @@ class SuggestionsView(APIView): permission_classes = [IsAuthenticated] serializer_class = SuggestionSerializer pagination_class = PageNumberPagination + filter_backends = [OrderingFilter] + ordering_fields = ['title'] def get(self, request): try: suggestions = SuggestionService.get_suggestions(request.user) + filtered_suggestions = self.filter_queryset(suggestions) paginator = self.pagination_class() paginator.page_size = 4 - paginated_data = paginator.paginate_queryset(suggestions, request) + paginated_data = paginator.paginate_queryset(filtered_suggestions, request) serializer = self.serializer_class(paginated_data, many=True, context={'request': request}) return paginator.get_paginated_response(serializer.data) except Exception: return Response("something went wrong. Please try again", status=status.HTTP_500_INTERNAL_SERVER_ERROR) + + def filter_queryset(self, queryset): + for backend in list(self.filter_backends): + queryset = backend().filter_queryset(self.request, queryset, self) + return queryset class AllGroupsView(APIView): @@ -66,7 +76,7 @@ class AllGroupsView(APIView): filter_backends = [DjangoFilterBackend, SearchFilter, OrderingFilter] filterset_class = GroupFilter search_fields = ['title'] - ordering_fields = ['level', 'member_count'] + ordering_fields = ['title', 'level', 'member_count'] def get(self, request): try: diff --git a/requirements.txt b/requirements.txt index f59cf60..c9fd64b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -63,7 +63,6 @@ pip-check-reqs==2.5.3 pluggy==1.5.0 prometheus_client==0.21.1 prompt_toolkit==3.0.48 -psycopg2==2.9.10 pyasn1==0.6.1 pyasn1_modules==0.4.1 pycodestyle==2.12.1