From dcb18736081d179936f456aeea62a5f9eeec1f1e Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 13 Jun 2026 13:43:29 +0330 Subject: [PATCH] complete mattermost integration --- .env.example | 41 +++++++ .gitignore | 2 + .../__pycache__/conversations.cpython-313.pyc | Bin 3628 -> 4321 bytes .../__pycache__/messages.cpython-313.pyc | Bin 2442 -> 3089 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 2277 -> 3016 bytes apps/chat/api/views/conversations.py | 30 ++++- apps/chat/api/views/messages.py | 38 ++++-- apps/chat/api/views/read_state.py | 46 +++++-- .../__pycache__/conversations.cpython-313.pyc | Bin 2159 -> 2721 bytes .../__pycache__/messages.cpython-313.pyc | Bin 948 -> 1523 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 714 -> 1062 bytes apps/chat/serializers/conversations.py | 7 ++ apps/chat/serializers/messages.py | 7 ++ apps/chat/serializers/read_state.py | 5 + apps/chat/tests/test_conversations.py | 73 +++++++++++ apps/chat/tests/test_messages.py | 116 ++++++++++++++++++ apps/chat/tests/test_read_state.py | 86 +++++++++++++ apps/chat/websocket/consumers.py | 23 ++++ apps/chat/websocket/routing.py | 7 ++ conftest.py | 22 ++++ main/__pycache__/settings.cpython-313.pyc | Bin 7201 -> 7477 bytes main/asgi.py | 26 ++-- main/settings.py | 18 ++- pytest.ini | 2 + 24 files changed, 509 insertions(+), 40 deletions(-) create mode 100644 .env.example create mode 100644 apps/chat/websocket/routing.py create mode 100644 conftest.py create mode 100644 pytest.ini diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18b410d --- /dev/null +++ b/.env.example @@ -0,0 +1,41 @@ +# Database +DB_NAME=gooyal_chat +DB_USER=postgres +DB_HOST=127.0.0.1 +DB_PASSWORD= +DB_PORT=5432 + +DEBUG=true + +# OAuth2 / SSO provider +OAUTH2_PROVIDER_BASE_PUBLIC_URL=https://accounts.example.com/oauth2 +OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2 +OAUTH2_PROVIDER_CLIENT_ID= +OAUTH2_PROVIDER_CLIENT_SECRET= +OAUTH2_PROVIDER_SCOPES= + +# Redis (used for cache, channel layer, and long-poll) +REDIS_BASE_URL=redis://127.0.0.1:6379/1 + +# Mattermost +# MATTERMOST_TOKEN must be a Personal Access Token (not a session token — session tokens expire) +# Enable at: System Console → Integrations → Integration Management → Enable Personal Access Tokens +MATTERMOST_URL=https://chat.example.com +MATTERMOST_TOKEN= +MATTERMOST_TEAM_ID= +MATTERMOST_SERVICE_USERNAME=chat-service +MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid + +# MinIO object storage +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET_CHAT=chat + +# Chat long-poll tuning +CHAT_LONG_POLL_TIMEOUT_SECONDS=25 +CHAT_LONG_POLL_INTERVAL_SECONDS=1 + +# Native library paths (macOS Homebrew — leave empty on Linux) +GDAL_LIBRARY_PATH= +GEOS_LIBRARY_PATH= diff --git a/.gitignore b/.gitignore index c25a548..3c2dd46 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ media /delme.py /log/accounts.log /log/errors.log +__pycache__ +.claude \ No newline at end of file diff --git a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc index 6750f1cfe310b1e70db21ca09b4cd771f4e74e22..e9bbfe9f671b6e20c0e11af040ef09040de5d635 100644 GIT binary patch delta 2045 zcmZuyO>7fa5Ps|3wb$$Q4~}Cy2~o{YD25P{5YvVNg(g6;fud|e)W)jW*qg-Q*k+zh z0HFph7003#61SqN2P(A^6_wgkrQX_}Z1q68QdA+es&c4AL^!ImZ_PLC#CcIdsT zSw3DZo;SdlH)|%?e)C$*EEjS-KWEMxUkJ9F*Nj@7<056u~?q5kkv|_@LVs9M* z)2NwNO1OBp+k5N~2EWI$3~K*3p4_}+uO_zy*oaBJ~!AukQ*60aeVy5 zXi~BzZkA@Cox-TNcM+rB!18MOO8Ke@+@ND|35Ts&@yf**5`GQg7F$zUbnwx_w>Q7O z*=!%Zqb@1oCrVdS>3TTYp!dMXTkxZKnRU$t7Y7+3FT z7%vpJUX%6+J}WJyj@VJ7UYj$^wPM~mL@r-4I2P6pS525Na*hYv^Z^xRJwMKMcUrfl zuEp2Mxr<;2L3k6WbNB$l$84$lwT9HW)X~+DqEF@UQmA*SE!v3pHNyK>Wzp?kRWw;` zd6BhljC^ZhjLuL~$&?@p)m7d*u(sEii~aXHTGOz(MOX<*+bl{id9i;Z5{dL*AZA zYGWY50IA6Km+&^ZnF{1c54J=mP?l{^W%`ntuW{QwQ!L?k2;L#DQh@^MX8OAALkZxQ zAm^+&X+84pN{vw1Q36K~K4PSzWC#+(srTV1f-TljQOg_BJ>1cF>uh84 z{igQ8Gg*wN&lDDkHRFTdNzM3?M)>He&yB4fNXln~RuE~+q3g{~`<+X6*ixO6L$$Tr z3ow)8RV;&%ua^wytJjJp4$d{`LUsVm0M(EFD;6qn89dg{TK54;za7CZ^h-m1SFvX~ zcxfnPRI9u%KWEhX<|_pgL#kG%Kk0L_;^^9Mo%Z(#0juIq2tn%;|BgkcXB=fa*?Bc% zwsIH))l244n8OXQM2w$8cqR&hu*};3WXG4;wq@44%y$35hW>U*f@_r_JXe`;3}>X4 f*`YOeL{RTft}%SA^$iJUg!>E68ND$bB<$b_G!vJ(9osorlldd(QVq z{--|UD49$!w5K)e-gJD&$ZqeU_n4Jfi5Hp8f#--zMah;y4!K-ZY!%dyE5(Q%g=omt zV$6<1JmisL!qz~ub|)O=@?e1sU&v z2Hjy;$8hpYG`((EsY5ETc%UpT&E6CypW>33=aXWO+n;gDp&j)iJZ5(p@Dc;5#6d1e zy$qC+4C=aKslgjFjrv0me79L?)MvnRo8Fy@w|RI0|5%o&N8409=~hR7GV!%^#UQ`X z{!U#I-%9yq>0C@+Avd%_`&@{&$(iHR!9x2pb!8>qNvvocwxozzxnI*e{(>Q%NZs4= zd(vCXQs#qr%lE(;b1JKCI*t?+EVp7n7Y>wFx9I{k3?k+5g%CjpXzf6E49t%f7e8^v z-x+ge7H)mGcALCf=-&_%`FRIHbAVn%0VJ~haR8RfxkD)AkJuh{SD z>~7|VzVG|??!S0*d_O($DE=Rzmzk0M%+=pA6E89oKhN%GW*_M;K$fuEh!%n2QnmDbYbQypD+W!|rb_r*BRuFR)zj;R`Jq$dX zir$m6fwJMQdq5it;zS+4@}+l}7QZB}^I>t4_&7$Athx0qaa$W2qNziYfEE}etJu(4 zYk)&r3lwr8CU&%*{xQnO8nBAn{Cq3W9=cT=yc1Rw#IM?w!Z@{=APCn-T7fizWkQB{ zmf&rKKyIM~hRL)CVtM;@+Y-iiR_AV-iVxJ+=3V36Ror43PxfZ~1(ftF^ebNqB`l$y7C0;NTF4|Qt* z3eQ;sx8`j&;QpypGmt$O9mS%JMjfXa5#ObHvm}SWbT>Bq(ef8=bF|i2^{PJ3_<73G z!jX~egG(ZBb_>Hya!vG^Ij)N-b8stMR4A|Ty62w|#I9qrR@Iw^Wo$vJ@^2vgC3DUX zSkEh#KVVl6*zhYh`L`r<=_JPZ2bd`wu=kH637*(pK4y3v_m1#6zPowC=nS9#4_k9B Apa1{> diff --git a/apps/chat/api/views/__pycache__/messages.cpython-313.pyc b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc index 1a330376ee960e853d27ee20cdad6e0de9515090..4e8e04da36996fdc5b681e4c53c585db78e02c82 100644 GIT binary patch literal 3089 zcma)8&2Jmm5r0eWE|(O)mlUZF+qBb2Vq}X}P&rW2CRJ?(HPt$XR+QXQz+z3VtWBvU z^WIXneCjFCQ3VpjKraE>gM4#O&7prla^r#p$S$G+K?4N3NsxQ*%#usWj)3-ooHz64 zeaz1O-f#9S5(yD#=Io7+mnA~}jfU5RzH+t;%I8EO3b#Uv9OZJT`&N8KKlK-R%Dc9I zMJS3?baj3uP?V_T>cUE}7@{Fp7gt7#VHz$*XvDPxE74+%#&RS}t`S8VCrWTLwBYZZ zOXH71`H^EZ@|?q$N_X$%eNOzNhHfr3jQbkZc63XpPDFoV>1I`Bm2G`Td(Jr`vox#8 zV3D+{vqs%yI_QC=`*+t3{W0v3_>In(_DENBYG^g%DQuEfV2d9O!?NgbaM!5lm0oN; zInp=I4nX;wY!b>Tg!&YY`U`~e@E114Yh*K^_%@}RoZ?^fDf~ED=r5x|*BgRI7dA%} zQ3)v0BEN}t5bTgLGO)v7N0g`%TjUFVCB7-aoP?5GPyLfPe%9sQyV)XOc*S$Webwa4c$V9|w5)n*^&HTbBc4%?d0NT0(2vXOOT5Bk2+#|? zel_9E9`=ejoPVJ|;Q1l_f+>_`Z*4j4X(hp%b5TCZqu#vLtfvioCAjGO3CV}<0lN6A zW@&4n#HeO4UDcmd^oC{BO()0zkUCY3suSMn!Bw^P8oI+J1} z=kSd>06KmS@^d&6hhx~EK>UHc43W{<7lFTp{~ET_Ki>~^rDR8%u%(H^J1y+zyRorO zY{rhw?DO9zuCxm07&Udo6?Ch<-?{+>|x8WDtXh|Dq5FTW` z0hIJqzIo%J#`J0qt(@81d8kv0cMh)YNL6PQYV-gSsOno9B+`)@026m@9Ow>Hn}ZQ| z{jvMD1!kBP{llm4uIg5knz_1J+sk2YSWepl2F+R92FsO9<4MjkAf=YJ)5y*27_}Ne z67W@Jc{p4`&M>H6rEftr$6te3dkzTR(ab$?hZ-IsGnxe7D|~{8arxxa|BFv9%-d@vY)(c&&Mv1O5r#qj~VU*rL}_ zyMba3M4qSPsJnjGg`;b99(^IlZVUoxk+JRd2u`dkzBb z{3MCX2k&&`XZT?-sTn&p(@9;oQ`ZkS+Np*8;J>cTe&#>8`&slwYG3$XzS@@G=*aW7JpT$! zB+}jVrEW5F5+td&Pl%8T?MpACeIA(kDrINi?__V=+1u?Hkk1{8FnUhz<)e)h&ZEt7e$GSqJy1*0i>D(=sZ(LQtt`jOolt0u$^Q zjA3!;k5R0Qy;|V1`4pNw3WR1ap=njcZz!XpYL-QfhfPaos!HF(2^eWF_fgbP;EQKp zfcTVjFI{Qz99Y`&Fz>s0WI;;9r79&l$tP)}U-^fD3U@4}PM z*1uF*w^mxEbvwNAPq2S?D7W7Ib!+{B9sVuY-FT)Om%C$^yOR%2gF+;F5(uV3Cn=K3 z*g&n8o&9Mmxp0~Z!x`fdVVOId0QJmOdog3A_woscZ#JP$y45XRrCTa%K&{a#&6-B% znwC*xy=-8boG274OWndx&Bt~6`(EkGygE1sru&iULflEc=5;F-4dQN>v4+iIHJck& zMK!YCw`9Oj!60kvUEhQ c9+B%uWabB=9Fc{W;yky^9Zdg+VDDz>zY=i3w*UYD delta 1157 zcmZ8g&2Jk;6rb6T*>yJFt(y-!shey{6K4ffCCGb!axzI3F&=Wza1U+-%#Gim<`4DTA+$d6Rh|oLpHYEi{n&151oA>6;y!U1^ z_eGz5kWTjyInFP7H!kU$cCNK3rANdkKCO^41?nPKDoR-eHQ{PSE9;;qT&u9M0Y<|0 zidjxUD&efsQ?|fzNsgQ%zA;REbHOO87>N+*T~2vD`$#=1wE6jq7oJkVqPQA2qs`r% zN=1g-^z2oNCUP9O5+0ES0@Np<_!LxM8783lszlw_fGz7D6V~N0imJ=uNA+-R-;zGH z7ZD{T#FP`|gZq3;DcHKC#TOA8D!<2GO)I`yRHRythK3qPJ+jc}=`7>FvVx@@=@|c9 zDNxEE8lIugGhd(9`7cI}zonj>IDU8zA+!Deu$QaPrj^sgGiLZ>b-HEC4R8>SA!HCV z37!RbU!g5kBu0L4caYozD z=oafZg$)gLcOTr{aoo1!ZVh&v$qntd%xEVwwv(A?XC`*@?vuW)kGJ!k{F(0HXm@D5 zn;Gb4=m7sjpZe(<+olUbyDf_hSSexN4k18sAJZ)KsNSfBo(7nHIEC;E!nlMvY_`>E zo}#FE-;a3Icc?4bTaaFxcjRfHT5Yx=;R8Z2c;I?xyt;Jv7D5880ogW>eNY=_*^+ zI1aAMw})%D;O1fH@S-(D8NXr;Q-j~IPE(VAZMoO5-;yBzi!RJ_tGAY#E8#mZEluz@ oMVRV8R7&X{8F)s<_Q-2{Wbzp~w?~S9u@PFL4-3x;uF1{*2E%~_r2qf` diff --git a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc index 9abd17f682a1bc04122066b076716508bf31a49e..a2e5ead992400a712bac68c4359b0ebe378ba221 100644 GIT binary patch literal 3016 zcmaJ@&2JmW6`$oUx#WrzDN>{*%ds}96N$A=R!NOkjuF6LO-0(0!4<0p60lg4D`{ZK9)|HUZk;aL-N^ce_L%V91Ed66d}3O7y444eTfiZ;-5Kz@-I=@@oU!<7w#>1~@rkmchPbC%*6coYJ8 z69Tc$hdl=IR@c!28pq*S6CxAh-KbM^DHoYbsf>T)JrCB;N4Uhy#u~-z;&N@nK>N4? z2KU3<-%i1$bcSKg(4(r?_}yX^X0jp}xluP7#Rt@R1gXV}%a+X?r-A0%h?k~2HfciH#$v(pFJ*}d%S;RW>%nXi|>xv+ns&>bDy9shi>&yHo1 z`{0;JIb}yViIddWVSMuC^z6a(&AsWH-O2pHqPW}el1 z$!JPBCefJE1KY^AfI34ws_VAVGnPs49|4{Ze*|5g)jg+JHQjJsYCUX&e{`L` zk5k`6GKFL~AecsM7RYZ&_npa(biO-2(UEfh2&cQLsct6M8N1v`UwIuCBazq9Ojzl~ zK1rk2z+?Cqz2yyw{KS6fb_p#pTb9LshK>aB)Td(6~!OLZOf_AhTP z>Gu{FKG7d6-n*wS&ELPfSY6V!ySEn??r7?kVPRcR*Bq)Es%>tmO&{G=6E`lX71M1~ zo2gc_2_*v}vazjhfuB^v_NS-@rPij&K2m9aDvmT;?zY;nn5#Or>RL_HX}g7dh^E1O zK2$eZjanWgyn(w2T9~B0zXCanyDO)ts|i{tD1o-Pf`52TJ4GlPP_P;rCLf0Ea8;@V zehDT5|Fx!t=R$U*8mU423=p_T21r(ov|-QIlNIlAfIn9dPTp!$q#6zWv15akssWsKR|sWIhVqROP9^w*;`w>_VWZ`= zJW%7(BL+AdZa7ArrhCPoi;vB19)SV~8*)MCA~m*n*xwTm?E@Q9 zFLk&G8}sy=Fcdec5-7}c=!48zX&kz+W!Nl_}i}l delta 1173 zcmaJ=OHUI~6u$H7v{PD2g|-xt76>@J1Y>y^LomKVOo+6i(UMGtb|{b1>X|9TgajAH zuy8>$F}QT8OXHt#X#)w(Xx*`JV=%zpdxjQ;g*Tb++h z2a}H$wKbVBpD@D@nUf6UFibaZ$t(dTL4Zv#z$Mr)0Y1Tjkl|&)^29YYoiJ2G!|Yu9Cw{wUT@q8FCFR|TTfo@~f>^aWtXMT-M%g}q``8OPXjD_QG|Bwgkwwxxrc zp}mZ8IblYK+r*p=pxQcfh`rFtCxihm=SbGq?8%1OZpLADbKR_C%FJ?I8(yjg^tvl^ z;KKuIENMa2l|l~aZDkkWjVwN`8Y;MO>snD^SwW-4=?46b0|#bsQjkT9*R|}tC4%<6 zpy`GsrWRB~Sxnmn?2?uQjWma%BcI@>Q95!EF(>#;AGV>tqVSS@_XoDtw%3d6?PDIIu8q-U>ftkgDE4o3Ha zaw#a6!)?Ds-Y0GHM-uUcc0;`%ZtsMK_Cmv@(C|+E$XDmcAuUNhk}26?#)a4h;7C|a zirwU5N>L317Uv3vrYj1xQX5=EXhv|?0LQ|<%4>4W)(fL2K4?zS?{w9E?tjLvr8;-s zTa>&zt6I*`?ZJ!CB%e3L`%EV?Zt+WcK4%HpyqeZ!37V)50ti8b9`l!TxEEWrxQu34 z{Q_>2oVHy2OL~zM>xkrC+ZL)^gHFjBScSl zK#SY}{pM5YS}X{3Hk5f#SG2V}EDzA5{YdRevKr4>SLvhEK{R=H7vnSs-5q(H#}nf1 zs%JZ~oHthT=|WZ;hjHqMeCyct0mm@RH{!04rhO8rkp2qktB}k4WTHZ@A31!?=+?a> SLd8*+mx*pYJ|}6KLGIyP?CkwNbHc>s0#`T9Y;jg9%OIB(P=-k#86~>Z zPjq=nZ2mf!2)M#|WJz2|HP^FoEpVya6~&O;NY{#%@7l$m=BdA*-FDf{is4vQwrU#7 zHmg;df1K9@yyvI=lQ$)wq?-3o&h3ln0;nruUh)SfftfDzhNK>CH^SpK$$UQQy`|%& z=;Gqc)HLe&(T2moIA++N&EsgM4a0Fw*R~APb-}*gaG7Hmpm{$ER~JC#VgaD4Gsxi{ zd6mqzl83gFhjxRRnRa_GX#NV|u;%kBbn!355aI|41et>` zuhuK9<|{!b9Z2B?J__?ZfFaTxb4|!_v)g+rq*Fl!Bb;thU$&}tt>V2D-uGe&ex!c= z2D4lT1#2z?$a=5Dqr3)HIvc}>&N&Y2Xu&C5mi-I7> zED!gilT{PK#8JbKPw`E?YOk`;mqXnX2!r0=%GlsuCE+pv!hkmsxf!+<>3lxsrz@~* zIBR?;rq!sKP-wVz%?W#!0Oq*HGMKCERvmtUpxsGxZOtiIRnsj5yWs?j0>OB-|3(ok zz27~D>+^+|H|2Ji%m3F%J;UW1HFg%Jcxt%H&NPRAL`vyvQhZIm{ga&QC<6U{W4v{+ rv~{r5A)IuyIMp6pYvoRDeH8Qp}GWLNW3nuarO3f1{ delta 798 zcmZ{i&ubGw6vtdDvadsLLT!+2JR5cQ0uYH`v;{MlQh9L>Ch=OG*K(SO}}jvKgYo0@v7vA25q%*O0@NkFA`4Gyu2D3h3f|MOh{3!;d=Gzv?)-{_Po~G z!b->UsDho_9Tz+G;PQ$&^#4KBxw*?zgXXz*RK3`+@xbTO(8!eCcB*dE8N{MN&aCG8 z?OzjMZsjoGWKe!kYQ+xg&k z`mf_Pwy(a^1&kYCQiGGUXIp$M zjfBLE<)t$dOe_qnY)C9542e$S2e2WfF!#=eAp>Xo(fje-`#$fTKT0#0nkADmN7sk- zi%%8=FI51BSH4y$a2GgLT;kM73+#%FFu$$}p^wFGRFS;Sc{%!*FnjBHJ4cr|mA?m6 zK@~T79BYZY3-kM#M>|B+D1D?&0n3!L_+L(pR!OLFob1X9BzYC}jXTJ;^)6BoHg(H< ziSSP*QxS;hpy!%HB4&@KiP$kMge8{ZiPYX7m~CVe$v_?5wVYa$Zir!Y=$aTAMAj{9 z@J#EwmPKG_?^$~PsY1Kc3OGsOJ=aiD{%5e|XZgGew}Xl`6IljH^g>OV^Wp%J`1Zbz zgGA7t#A{SZ=*MXtlGilb(H*m`>5hZVJ=a0Drs1@I$Uk~;n=)Q=)6DWDQyOPV-!tXm zt!Zv$l3O3s_e%ZS{njY=a9E$h#R*&;!_{}CPt`BR2&%*CTx(_^aBC+~5!?jFJCowZsJL;$G4CW51$A%?CxyzW LQ2EUTZlX-=wLkt~qQ2*kxQK;i>4 fBO~Ko2DLA2Y>YmPGq|ry=v|c1`^o^~fpr4_eRM>C diff --git a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc index 643a26c446e28987c073cdaa8157cf9f0eaba85d..411c7f168a6b2080c6e28e9514b182e6e50fe5ba 100644 GIT binary patch delta 426 zcmX@bx{QPGGcPX}0}yP`(#?utoX97^m@-k_T2F#Oi6NLpiXoUahB1f*qyQC|GDG-9 zEa_~T>=V8fl7*4fyBhBH4dC04m*(W)8sDV0CB(~ATJhig9wnji+F&< zE&k;Eyt34y;>427{5+r&igN~iL|_ExWLYLt9w0C zV{|j(PtMOPOD!r+EXmBzi_c8C#a>#RS_ES7IOXT(q$cLMWv1q&+~UeeERHYDD@skA zJe{#>avHNCj}XwHB32;br^zyTF|)cWJ4gU5Dhy=Z;;_lhPbtkwwJQ<>av6cRSPDpd fU}j`wyvv~Wg^ig}XNLB5DT9kr245L~9I##h_^dRk diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py index 8b3f322..4eaf918 100644 --- a/apps/chat/serializers/conversations.py +++ b/apps/chat/serializers/conversations.py @@ -1,8 +1,14 @@ +from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from apps.chat.models import Conversation, ConversationParticipant +class CreateConversationSerializer(serializers.Serializer): + user_1_id = serializers.UUIDField() + user_2_id = serializers.UUIDField() + + class ConversationSerializer(serializers.ModelSerializer): participants = serializers.SerializerMethodField() @@ -10,6 +16,7 @@ class ConversationSerializer(serializers.ModelSerializer): model = Conversation fields = ["id", "type", "created_at", "participants"] + @extend_schema_field(serializers.ListField(child=serializers.UUIDField())) def get_participants(self, obj): return list( ConversationParticipant.objects.filter(conversation_id=obj.id) diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index 833568b..fba8f8c 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -1,6 +1,13 @@ from rest_framework import serializers +class SendMessageSerializer(serializers.Serializer): + sender_id = serializers.UUIDField() + message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text") + text = serializers.CharField(required=False, allow_null=True, allow_blank=True) + file = serializers.FileField(required=False, allow_null=True) + + class MessageSerializer(serializers.Serializer): post_id = serializers.CharField() sender_id = serializers.UUIDField(allow_null=True) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py index 23a5c17..9c3ecc6 100644 --- a/apps/chat/serializers/read_state.py +++ b/apps/chat/serializers/read_state.py @@ -1,6 +1,11 @@ from rest_framework import serializers +class MarkReadSerializer(serializers.Serializer): + user_id = serializers.UUIDField() + post_id = serializers.CharField() + + class ReadStateSerializer(serializers.Serializer): conversation_id = serializers.UUIDField() user_id = serializers.UUIDField() diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py index e69de29..ec81a64 100644 --- a/apps/chat/tests/test_conversations.py +++ b/apps/chat/tests/test_conversations.py @@ -0,0 +1,73 @@ +import uuid +from unittest.mock import Mock + +import pytest + +from apps.chat.models import ( + Conversation, + ConversationParticipant, + MattermostAccountMapping, +) +from apps.chat.services.conversation import ConversationService + + +def _make_mm_client(mm_user_ids=("mm-u1", "mm-u2"), channel_id="mm-ch-1"): + client = Mock() + client.get_or_create_user.side_effect = list(mm_user_ids) + client.create_private_channel.return_value = channel_id + return client + + +@pytest.mark.django_db +def test_create_conversation_success(): + user_1 = uuid.uuid4() + user_2 = uuid.uuid4() + + svc = ConversationService(mattermost_client=_make_mm_client()) + conv = svc.create(user_1, user_2) + + assert Conversation.objects.filter(id=conv.id).exists() + + participants = ConversationParticipant.objects.filter(conversation=conv) + assert participants.count() == 2 + participant_ids = set(participants.values_list("user_id", flat=True)) + assert user_1 in participant_ids + assert user_2 in participant_ids + + +@pytest.mark.django_db +def test_create_conversation_creates_mattermost_mapping(): + user_1 = uuid.uuid4() + user_2 = uuid.uuid4() + + mm_client = _make_mm_client(mm_user_ids=("mm-user-aaa", "mm-user-bbb")) + svc = ConversationService(mattermost_client=mm_client) + svc.create(user_1, user_2) + + mapping_1 = MattermostAccountMapping.objects.get(user_id=user_1) + mapping_2 = MattermostAccountMapping.objects.get(user_id=user_2) + + assert mapping_1.mattermost_user_id == "mm-user-aaa" + assert mapping_2.mattermost_user_id == "mm-user-bbb" + + +@pytest.mark.django_db +def test_list_for_user_returns_only_user_conversations(): + target_user = uuid.uuid4() + other_user = uuid.uuid4() + + conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target") + ConversationParticipant.objects.create( + conversation=conv_with_user, user_id=target_user + ) + + conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other") + ConversationParticipant.objects.create( + conversation=conv_without_user, user_id=other_user + ) + + svc = ConversationService(mattermost_client=Mock()) + result = svc.list_for_user(target_user, page=0, page_size=20) + + assert len(result) == 1 + assert result[0].id == conv_with_user.id diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index e69de29..afb7fca 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -0,0 +1,116 @@ +import uuid +from unittest.mock import Mock + +import pytest + +from apps.chat.models import Conversation, MattermostAccountMapping +from apps.chat.services.message import MessageService + + +def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None): + """Return a MessageService with all external deps mocked.""" + mm_client = Mock() + mm_client.post_message.return_value = mm_post_id + + storage = Mock() + if storage_url: + storage.upload_file.return_value = storage_url + + return ( + MessageService( + mattermost_client=mm_client, + storage_service=storage, + publishers=publishers if publishers is not None else [], + ), + mm_client, + storage, + ) + + +@pytest.mark.django_db +def test_send_text_message(): + conv = Conversation.objects.create(mattermost_channel_id="ch-send-text") + sender_id = uuid.uuid4() + + ws_publisher = Mock() + svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher]) + + result = svc.send(conv.id, sender_id, "text", text="Hello world") + + mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world") + ws_publisher.publish.assert_called_once() + + event = ws_publisher.publish.call_args[0][0] + assert event.post_id == "post-abc" + assert event.sender_id == sender_id + assert event.message_type == "text" + + assert result["post_id"] == "post-abc" + assert result["text"] == "Hello world" + + +@pytest.mark.django_db +def test_send_image_message(): + conv = Conversation.objects.create(mattermost_channel_id="ch-send-image") + sender_id = uuid.uuid4() + + file_url = "http://minio.local/chat/abc123/photo.jpg" + svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) + + fake_file = Mock() + fake_file.name = "photo.jpg" + + result = svc.send(conv.id, sender_id, "image", file=fake_file) + + storage.upload_file.assert_called_once_with(fake_file, "photo.jpg") + mm_client.post_message.assert_called_once_with("ch-send-image", file_url) + assert result["url"] == file_url + + +@pytest.mark.django_db +def test_list_messages_normalized(): + conv = Conversation.objects.create(mattermost_channel_id="ch-list") + sender_uuid = uuid.uuid4() + mm_user_id = "mm-user-xyz" + + MattermostAccountMapping.objects.create( + user_id=sender_uuid, mattermost_user_id=mm_user_id + ) + + raw_posts = [ + { + "id": "p1", + "user_id": mm_user_id, + "message": "First message", + "create_at": 1700000000000, + }, + { + "id": "p2", + "user_id": mm_user_id, + "message": "http://minio.local/chat/file.pdf", + "create_at": 1700000001000, + }, + ] + + mm_client = Mock() + mm_client.get_posts.return_value = raw_posts + svc, _, _ = _make_service(publishers=[]) + svc._mm = mm_client # inject after construction to keep _make_service simple + + messages = svc.list_messages(conv.id, page=0, per_page=20) + + assert len(messages) == 2 + + text_msg = messages[0] + assert text_msg["post_id"] == "p1" + assert text_msg["sender_id"] == sender_uuid + assert text_msg["message_type"] == "text" + assert text_msg["text"] == "First message" + assert text_msg["url"] is None + + file_msg = messages[1] + assert file_msg["post_id"] == "p2" + assert file_msg["sender_id"] == sender_uuid + assert file_msg["message_type"] == "file" + assert file_msg["url"] == "http://minio.local/chat/file.pdf" + assert file_msg["text"] is None diff --git a/apps/chat/tests/test_read_state.py b/apps/chat/tests/test_read_state.py index e69de29..5eeb480 100644 --- a/apps/chat/tests/test_read_state.py +++ b/apps/chat/tests/test_read_state.py @@ -0,0 +1,86 @@ +import uuid +from unittest.mock import Mock, patch + +import pytest + +from apps.chat.models import Conversation, ConversationReadState +from apps.chat.services.read_state import ReadStateService + + +def _make_conv(channel_id="ch-read"): + return Conversation.objects.create(mattermost_channel_id=channel_id) + + +@pytest.mark.django_db +def test_mark_read_creates_read_state(): + conv = _make_conv() + user_id = uuid.uuid4() + + svc = ReadStateService(mattermost_client=Mock()) + read_state = svc.mark_read(conv.id, user_id, "post-111") + + assert read_state.conversation_id == conv.id + assert read_state.user_id == user_id + assert read_state.last_read_mattermost_post_id == "post-111" + assert ConversationReadState.objects.filter( + conversation=conv, user_id=user_id + ).count() == 1 + + +@pytest.mark.django_db +def test_mark_read_updates_existing(): + conv = _make_conv() + user_id = uuid.uuid4() + + svc = ReadStateService(mattermost_client=Mock()) + svc.mark_read(conv.id, user_id, "post-old") + svc.mark_read(conv.id, user_id, "post-new") + + rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id) + assert rows.count() == 1 + assert rows.first().last_read_mattermost_post_id == "post-new" + + +@pytest.mark.django_db +@patch("apps.chat.services.read_state.cache") +def test_has_unread_true(mock_cache): + mock_cache.get.return_value = None # force cache miss → MM lookup + + conv = _make_conv() + user_id = uuid.uuid4() + ConversationReadState.objects.create( + conversation=conv, + user_id=user_id, + last_read_mattermost_post_id="post-old", + ) + + mm_client = Mock() + mm_client.get_latest_post_id.return_value = "post-new" + + svc = ReadStateService(mattermost_client=mm_client) + result = svc.has_unread(conv.id, user_id) + + assert result is True + mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id) + + +@pytest.mark.django_db +@patch("apps.chat.services.read_state.cache") +def test_has_unread_false(mock_cache): + mock_cache.get.return_value = None # force cache miss → MM lookup + + conv = _make_conv() + user_id = uuid.uuid4() + ConversationReadState.objects.create( + conversation=conv, + user_id=user_id, + last_read_mattermost_post_id="post-current", + ) + + mm_client = Mock() + mm_client.get_latest_post_id.return_value = "post-current" + + svc = ReadStateService(mattermost_client=mm_client) + result = svc.has_unread(conv.id, user_id) + + assert result is False diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py index e69de29..d8866f8 100644 --- a/apps/chat/websocket/consumers.py +++ b/apps/chat/websocket/consumers.py @@ -0,0 +1,23 @@ +import json + +from channels.generic.websocket import AsyncWebsocketConsumer + + +class ChatConsumer(AsyncWebsocketConsumer): + async def connect(self): + self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"] + self.group_name = f"chat_{self.chat_id}" + await self.channel_layer.group_add(self.group_name, self.channel_name) + await self.accept() + + async def disconnect(self, close_code): + await self.channel_layer.group_discard(self.group_name, self.channel_name) + + async def receive(self, text_data=None, bytes_data=None): + # Frontend receives only; messages are sent via HTTP API + pass + + async def chat_message(self, event): + """Handle chat.message events forwarded from the channel layer.""" + payload = {k: v for k, v in event.items() if k != "type"} + await self.send(text_data=json.dumps(payload)) diff --git a/apps/chat/websocket/routing.py b/apps/chat/websocket/routing.py new file mode 100644 index 0000000..1f87234 --- /dev/null +++ b/apps/chat/websocket/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.chat.websocket.consumers import ChatConsumer + +websocket_urlpatterns = [ + path("ws/chat//", ChatConsumer.as_asgi()), +] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a1dd7ea --- /dev/null +++ b/conftest.py @@ -0,0 +1,22 @@ +import pytest + + +@pytest.fixture(scope="session") +def django_db_setup(django_test_environment, django_db_blocker): + """ + Override pytest-django's default DB setup to: + - Use the local unix-socket superuser instead of the .env credentials + - Remove psycopg3 connection pooling (pool=True conflicts with test + transaction wrapping and causes PoolTimeout during test-DB creation) + """ + from django.conf import settings + from django.test.utils import setup_databases + + db = settings.DATABASES["default"] + db["USER"] = "hashdal" + db["PASSWORD"] = "" + db["HOST"] = "" + db["OPTIONS"] = {} # clear pool=True; psycopg3 pool conflicts with pytest-django + + with django_db_blocker.unblock(): + setup_databases(verbosity=0, interactive=False) diff --git a/main/__pycache__/settings.cpython-313.pyc b/main/__pycache__/settings.cpython-313.pyc index 1e0345afa3c8e93723c97367f34cbcddd286be9b..0f64b4fea1b4481e28aba19d7724dcd876734357 100644 GIT binary patch delta 850 zcmY+CT~E_c7{~iU*}}^l4B5cf0BROOM-&t`5y~uEx2_v~HefQXa}+ihn>n2!a5F!E zH>97yOD~KuiU^?XkBN0i7v{|EuP}C|R2_uFP#~q%8kWO1yOhQ^)7C_W8s*DsICvnV{Dm9eC zs$!%uG8k(Z>lhmtSqtx|GmBJvCXg|7^N%;wYg&qbOQWZ&A^M8U_15TOb7ZOI9z1bQ zww%))jveVfz0~Vtn$%Ed)cib}^>>CT&eie+Px?H^+s$iJ--r3;-SK@cZH+7j;-;30sDyFhX4Qo delta 603 zcmX|;%TC)s6ozMPq97oMhzN`Tl}n{60tHkOAm-90A(%MM^*CuMhHwkF1&cBJyZ}N% z-gMh#k-DfX_NY(5hD{b#AE6bh)MaN*ry0qg^qv3A@z`^(e_HN;4uyIMUr#&Hi!al+ z{TluD^_8o%U@I12uOY`4AjsnUNz4&2E0irl9xNz8F{aE@wgfX!a)MI?$_Xmw5CRpb z&QpMx%JXHQVCR#j&h!c5=u;%|uZge~+*O0RvkKiwm93$_gE|Z+71mluecy@Spl^5l zE&2!K&i|?$mBOk6yVBF>d(e=cLEnc1X9HiIML&ci>6=cl&cEnbp2fhpDQyewL|P8* z6f$^f6YWf9d9-tB)@eO@;SY5^^wCN0MR>T+gi^75z}zN_20SAi_dkXYslPP%Kxx7M z6VX*Kt&O)b{C~BBR>?4OMWd~{SB!1&63Yr~3K*heu#mFtY_(7VyOB;owo$8E%oK56 z#OnFw6|;mE1yfvNxq*a9_<&*YhtwBvI%x9NS?R(fx^ipzDMRR873nKJNWWc5*7 c`xWZelt&Wz8S(B$x0T;R{_I$Qc3tz%zleO0&j0`b diff --git a/main/asgi.py b/main/asgi.py index 31b565c..d04d7e7 100644 --- a/main/asgi.py +++ b/main/asgi.py @@ -6,22 +6,22 @@ It exposes the ASGI callable as a module-level variable named ``application``. import os -from django.core.asgi import get_asgi_application - -from apps.chat.websocket import websocket_application - os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings') -django_asgi_application = get_asgi_application() +from django.core.asgi import get_asgi_application +django_asgi_app = get_asgi_application() -async def application(scope, receive, send): - if scope["type"] == "http": - await django_asgi_application(scope, receive, send) - return +from channels.auth import AuthMiddlewareStack +from channels.routing import ProtocolTypeRouter, URLRouter - if scope["type"] == "websocket": - await websocket_application(scope, receive, send) - return +from apps.chat.websocket.routing import websocket_urlpatterns - raise ValueError(f"Unsupported ASGI scope type: {scope['type']}") +application = ProtocolTypeRouter( + { + "http": django_asgi_app, + "websocket": AuthMiddlewareStack( + URLRouter(websocket_urlpatterns) + ), + } +) diff --git a/main/settings.py b/main/settings.py index 8e9d500..47446b4 100644 --- a/main/settings.py +++ b/main/settings.py @@ -223,9 +223,12 @@ SPECTACULAR_SETTINGS = { 'DESCRIPTION': 'service api reference', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, - # OTHER SETTINGS - "PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"], - + 'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'], + 'SECURITY': [], + "PARSER_WHITELIST": [ + "rest_framework.parsers.JSONParser", + "rest_framework.parsers.MultiPartParser", + ], } AUTH_USER_MODEL = 'users.User' @@ -254,6 +257,15 @@ CACHES = { } } +CHANNEL_LAYERS = { + "default": { + "BACKEND": "channels_redis.core.RedisChannelLayer", + "CONFIG": { + "hosts": [config('REDIS_BASE_URL')], + }, + } +} + from main.other_settings.logging import get_logging_setting LOGGING = get_logging_setting() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..71ddc8e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = main.settings