From b7239defcf864647ddbd937fee2c3813d33d7963 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Wed, 10 Jun 2026 17:33:02 +0330 Subject: [PATCH 1/9] add clients and chat core. --- .../chat/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes apps/chat/__pycache__/admin.cpython-313.pyc | Bin 0 -> 198 bytes apps/chat/__pycache__/apps.cpython-313.pyc | Bin 0 -> 519 bytes apps/chat/__pycache__/urls.cpython-313.pyc | Bin 0 -> 307 bytes apps/chat/api/__init__.py | 0 .../api/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 158 bytes .../chat/api/__pycache__/urls.cpython-313.pyc | Bin 0 -> 1046 bytes apps/chat/api/urls.py | 16 ++ apps/chat/api/views/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../__pycache__/conversations.cpython-313.pyc | Bin 0 -> 3628 bytes .../__pycache__/messages.cpython-313.pyc | Bin 0 -> 2442 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 0 -> 2277 bytes apps/chat/api/views/conversations.py | 72 +++++++ apps/chat/api/views/messages.py | 41 ++++ apps/chat/api/views/read_state.py | 46 ++++ apps/chat/apps.py | 1 + apps/chat/events/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 161 bytes .../events/__pycache__/event.cpython-313.pyc | Bin 0 -> 664 bytes apps/chat/events/base.py | 0 apps/chat/events/event.py | 11 + apps/chat/events/publishers/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 172 bytes .../__pycache__/longpoll.cpython-313.pyc | Bin 0 -> 1461 bytes .../__pycache__/push.cpython-313.pyc | Bin 0 -> 644 bytes .../__pycache__/websocket.cpython-313.pyc | Bin 0 -> 1228 bytes apps/chat/events/publishers/longpoll.py | 23 ++ apps/chat/events/publishers/push.py | 7 + apps/chat/events/publishers/websocket.py | 20 ++ apps/chat/integrations/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 167 bytes apps/chat/integrations/mattermost/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 178 bytes .../__pycache__/client.cpython-313.pyc | Bin 0 -> 8951 bytes .../__pycache__/exceptions.cpython-313.pyc | Bin 0 -> 487 bytes apps/chat/integrations/mattermost/client.py | 201 ++++++++++++++++++ .../integrations/mattermost/exceptions.py | 2 + apps/chat/migrations/0001_initial.py | 68 ++++++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 165 bytes apps/chat/models.py | 3 - apps/chat/models/__init__.py | 12 ++ .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 533 bytes .../__pycache__/conversation.cpython-313.pyc | Bin 0 -> 1901 bytes .../__pycache__/mapping.cpython-313.pyc | Bin 0 -> 1456 bytes .../__pycache__/participant.cpython-313.pyc | Bin 0 -> 1770 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 0 -> 1897 bytes apps/chat/models/conversation.py | 30 +++ apps/chat/models/mapping.py | 18 ++ apps/chat/models/participant.py | 31 +++ apps/chat/models/read_state.py | 32 +++ apps/chat/serializers/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 166 bytes .../__pycache__/conversations.cpython-313.pyc | Bin 0 -> 2159 bytes .../__pycache__/messages.cpython-313.pyc | Bin 0 -> 948 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 0 -> 714 bytes apps/chat/serializers/conversations.py | 27 +++ apps/chat/serializers/messages.py | 10 + apps/chat/serializers/read_state.py | 7 + apps/chat/services/__init__.py | 0 .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/account.cpython-313.pyc | Bin 0 -> 630 bytes .../__pycache__/conversation.cpython-313.pyc | Bin 0 -> 3513 bytes .../__pycache__/message.cpython-313.pyc | Bin 0 -> 5439 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 0 -> 2426 bytes .../__pycache__/storage.cpython-313.pyc | Bin 0 -> 2290 bytes apps/chat/services/account.py | 7 + apps/chat/services/conversation.py | 65 ++++++ apps/chat/services/message.py | 118 ++++++++++ apps/chat/services/read_state.py | 48 +++++ apps/chat/services/realtime.py | 12 ++ apps/chat/services/storage.py | 42 ++++ apps/chat/tests.py | 3 - apps/chat/tests/__init__.py | 0 apps/chat/tests/test_conversations.py | 0 apps/chat/tests/test_messages.py | 0 apps/chat/tests/test_read_state.py | 0 apps/chat/urls.py | 5 + apps/chat/views.py | 3 - apps/chat/websocket/__init__.py | 0 apps/chat/websocket/consumers.py | 0 .../core/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 154 bytes apps/core/__pycache__/admin.cpython-313.pyc | Bin 0 -> 696 bytes apps/core/__pycache__/apps.cpython-313.pyc | Bin 0 -> 519 bytes apps/core/__pycache__/models.cpython-313.pyc | Bin 0 -> 5062 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 1713 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 165 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 163 bytes .../__pycache__/admin.cpython-313.pyc | Bin 0 -> 425 bytes .../__pycache__/apps.cpython-313.pyc | Bin 0 -> 544 bytes .../__pycache__/models.cpython-313.pyc | Bin 0 -> 3096 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 5915 bytes .../__pycache__/0002_initial.cpython-313.pyc | Bin 0 -> 4655 bytes ...n_authorization_grant_type.cpython-313.pyc | Bin 0 -> 1121 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 174 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 155 bytes apps/users/__pycache__/admin.cpython-313.pyc | Bin 0 -> 330 bytes apps/users/__pycache__/apps.cpython-313.pyc | Bin 0 -> 522 bytes apps/users/__pycache__/models.cpython-313.pyc | Bin 0 -> 3994 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 0 -> 3753 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 166 bytes env.sample | 12 +- main/__pycache__/__init__.cpython-313.pyc | Bin 0 -> 149 bytes main/__pycache__/settings.cpython-313.pyc | Bin 0 -> 7201 bytes main/__pycache__/urls.cpython-313.pyc | Bin 0 -> 2090 bytes main/asgi.py | 21 +- .../__pycache__/__init__.cpython-313.pyc | Bin 0 -> 164 bytes .../__pycache__/logging.cpython-313.pyc | Bin 0 -> 2149 bytes main/settings.py | 25 +++ main/urls.py | 1 + requirements.in | 5 +- utils/__pycache__/exceptions.cpython-313.pyc | Bin 0 -> 3903 bytes 112 files changed, 1029 insertions(+), 16 deletions(-) create mode 100644 apps/chat/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/__pycache__/admin.cpython-313.pyc create mode 100644 apps/chat/__pycache__/apps.cpython-313.pyc create mode 100644 apps/chat/__pycache__/urls.cpython-313.pyc create mode 100644 apps/chat/api/__init__.py create mode 100644 apps/chat/api/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/api/__pycache__/urls.cpython-313.pyc create mode 100644 apps/chat/api/urls.py create mode 100644 apps/chat/api/views/__init__.py create mode 100644 apps/chat/api/views/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/api/views/__pycache__/conversations.cpython-313.pyc create mode 100644 apps/chat/api/views/__pycache__/messages.cpython-313.pyc create mode 100644 apps/chat/api/views/__pycache__/read_state.cpython-313.pyc create mode 100644 apps/chat/api/views/conversations.py create mode 100644 apps/chat/api/views/messages.py create mode 100644 apps/chat/api/views/read_state.py create mode 100644 apps/chat/events/__init__.py create mode 100644 apps/chat/events/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/events/__pycache__/event.cpython-313.pyc create mode 100644 apps/chat/events/base.py create mode 100644 apps/chat/events/event.py create mode 100644 apps/chat/events/publishers/__init__.py create mode 100644 apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc create mode 100644 apps/chat/events/publishers/__pycache__/push.cpython-313.pyc create mode 100644 apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc create mode 100644 apps/chat/events/publishers/longpoll.py create mode 100644 apps/chat/events/publishers/push.py create mode 100644 apps/chat/events/publishers/websocket.py create mode 100644 apps/chat/integrations/__init__.py create mode 100644 apps/chat/integrations/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/integrations/mattermost/__init__.py create mode 100644 apps/chat/integrations/mattermost/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc create mode 100644 apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc create mode 100644 apps/chat/integrations/mattermost/client.py create mode 100644 apps/chat/integrations/mattermost/exceptions.py create mode 100644 apps/chat/migrations/0001_initial.py create mode 100644 apps/chat/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/models.py create mode 100644 apps/chat/models/__init__.py create mode 100644 apps/chat/models/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/models/__pycache__/conversation.cpython-313.pyc create mode 100644 apps/chat/models/__pycache__/mapping.cpython-313.pyc create mode 100644 apps/chat/models/__pycache__/participant.cpython-313.pyc create mode 100644 apps/chat/models/__pycache__/read_state.cpython-313.pyc create mode 100644 apps/chat/models/conversation.py create mode 100644 apps/chat/models/mapping.py create mode 100644 apps/chat/models/participant.py create mode 100644 apps/chat/models/read_state.py create mode 100644 apps/chat/serializers/__init__.py create mode 100644 apps/chat/serializers/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/serializers/__pycache__/conversations.cpython-313.pyc create mode 100644 apps/chat/serializers/__pycache__/messages.cpython-313.pyc create mode 100644 apps/chat/serializers/__pycache__/read_state.cpython-313.pyc create mode 100644 apps/chat/serializers/conversations.py create mode 100644 apps/chat/serializers/messages.py create mode 100644 apps/chat/serializers/read_state.py create mode 100644 apps/chat/services/__init__.py create mode 100644 apps/chat/services/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/chat/services/__pycache__/account.cpython-313.pyc create mode 100644 apps/chat/services/__pycache__/conversation.cpython-313.pyc create mode 100644 apps/chat/services/__pycache__/message.cpython-313.pyc create mode 100644 apps/chat/services/__pycache__/read_state.cpython-313.pyc create mode 100644 apps/chat/services/__pycache__/storage.cpython-313.pyc create mode 100644 apps/chat/services/account.py create mode 100644 apps/chat/services/conversation.py create mode 100644 apps/chat/services/message.py create mode 100644 apps/chat/services/read_state.py create mode 100644 apps/chat/services/realtime.py create mode 100644 apps/chat/services/storage.py delete mode 100644 apps/chat/tests.py create mode 100644 apps/chat/tests/__init__.py create mode 100644 apps/chat/tests/test_conversations.py create mode 100644 apps/chat/tests/test_messages.py create mode 100644 apps/chat/tests/test_read_state.py create mode 100644 apps/chat/urls.py delete mode 100644 apps/chat/views.py create mode 100644 apps/chat/websocket/__init__.py create mode 100644 apps/chat/websocket/consumers.py create mode 100644 apps/core/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/core/__pycache__/admin.cpython-313.pyc create mode 100644 apps/core/__pycache__/apps.cpython-313.pyc create mode 100644 apps/core/__pycache__/models.cpython-313.pyc create mode 100644 apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 apps/core/migrations/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc create mode 100644 apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/users/__pycache__/__init__.cpython-313.pyc create mode 100644 apps/users/__pycache__/admin.cpython-313.pyc create mode 100644 apps/users/__pycache__/apps.cpython-313.pyc create mode 100644 apps/users/__pycache__/models.cpython-313.pyc create mode 100644 apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc create mode 100644 apps/users/migrations/__pycache__/__init__.cpython-313.pyc create mode 100644 main/__pycache__/__init__.cpython-313.pyc create mode 100644 main/__pycache__/settings.cpython-313.pyc create mode 100644 main/__pycache__/urls.cpython-313.pyc create mode 100644 main/other_settings/__pycache__/__init__.cpython-313.pyc create mode 100644 main/other_settings/__pycache__/logging.cpython-313.pyc create mode 100644 utils/__pycache__/exceptions.cpython-313.pyc diff --git a/apps/chat/__pycache__/__init__.cpython-313.pyc b/apps/chat/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9c11b9024ddf8ea2abe5e8893d5578a6afde19c2 GIT binary patch literal 154 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iQerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgcTp3nU`4-AFo$Xd5gm)H$SB` ZC)KWq6=)L3reYA|BQql-V-Yiu1pqaNB#SA6lGRRIHzoSe%iPn4=$1 zl%JKFTvDtbo|#vipRb>skyxUiSWr+5Wr57pE2zB1VUwGmQks)$SHuA{h7pL1L5vT~ OjEs!;7!-=wfE)m2Ju!p; literal 0 HcmV?d00001 diff --git a/apps/chat/__pycache__/apps.cpython-313.pyc b/apps/chat/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f270aaf6023ac3e51b179b5e9994c578440544e0 GIT binary patch literal 519 zcmYLFJxjzu5Zz63AD$kHsHiBYg_0(z4irT20}F*iSj0wH)=MszBbUV4O`(;QwHE#e ze~*nUSXpT$oc7LMPTXSV&AvCoycsr|4T7)!xz&Hf{BDZBR~duhCW0rz2&1RugaTb7 zi)4c^Ynd?nlD6zp2Ug^`l@C+6R8Q>Z`C*oJVSwHM3>0iXD%rf6B?i&mL$jfiQ zoOu+CN7=-1PhKY%?v7rUwqKkDnEvD2uB|7>zRegghcDaB5~Xkkk*@EScK2WwOLKEM XhvCbnl)jPm@2W+29+rLxrcu`~#b}2P literal 0 HcmV?d00001 diff --git a/apps/chat/__pycache__/urls.cpython-313.pyc b/apps/chat/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..dee0d90f72713f365e38964e3572a8c6826b9f65 GIT binary patch literal 307 zcmey&%ge<81Z}~ZSy@2(F^B^LY*5B$6Ch(MLoh=yqc?*WV-celQxTH_Lky!5Loibe z6Od#!WrV0TWrEQ9j77}pESjt@L0nCyTPy{MB^kHaGxL&jN>fs+SP}~|^{a#u3kr($ zk~0!Z^niT5(xRMVKTVcf+$mX!dFlCJei1WJe-R6ixWx^W2bxlnT9jA3lHoJR;9I8p zp~b01#rheE#ThAyIr;%b`B|ySCB^#TnR&(e`T8KU^+C2kSRfnp3My}L*yQG?l;)(` z6@feta(b~MkodsN$jEq~LG>dW11o2T*hKLQlDd}#^)9pMe_&?fXKLgw;sB}w0Q;~_ Ao&W#< literal 0 HcmV?d00001 diff --git a/apps/chat/api/__init__.py b/apps/chat/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/api/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6a8d32fd6cc4544699d91fafe2f730380011d6ea GIT binary patch literal 158 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iYerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMjFqV$AD@|*SrQ+wS5SG2!zMRB br8Fniu80+A8pyU{5aS~=BO_xGGmr%UyzwRx literal 0 HcmV?d00001 diff --git a/apps/chat/api/__pycache__/urls.cpython-313.pyc b/apps/chat/api/__pycache__/urls.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c46a4d27433942442f5059131669c8993066531c GIT binary patch literal 1046 zcmaiz&ui0A9Kc_i*ECty&QXz}Wz2!Lp|oMh4%5<2hEP%!x8im%P~Mu*rnE_UFRd65 z@#e{!caQTw@VKKFp+64AfOzoaZ8F5G@4c>dheF>W@B8EP{rSj6JiY|z!6#btYXpFw zOqm|;p~^){0N?|tfQnEChJb|)CP&H({i^ zUu+6Z2w?~L4J{Jh*!SF?jS2D{&)vs1^6ewXei^PkCpNz7d+ZROd1$Qq#3lsQIY?6^ z_8Z85)U#cm@X+|7jjW1)nJJKJ)J^n2Wp}Q|ORxr9)V70LU0R0dg>Kie?lZaSSVjFZ zBEbwf9@e7OgY}7h0yCMz?MXAVLY$*hJ-9g+r}Zs8P&mruAT<}W`8?CEC)!hOIaIAC za_e5ci(3SXbeG|6)aj5sOOi(&C*PwxqUx2c5)3JaqYO^XgQp#1i6QIr5Y}mxu$|%< zOC~cihDeo_3su?;eNXCi$tQ3@eIVS?*$WcAfk?wbE&Unxns$vo#IfTN&*MARS*fYy z=xnBZ=T#^d+Md;I*+rbBndmPC$b0%Z7X?B14pM)iC`A6qK-m5PO1~u#Ro<=jliE1B zHB4@elJ`F62C(!E%6;W-KeIQ^l!lqoC}Z>w&;XuryLS3uoGlKs#Zh+e?9BikbL*Xt tugB@aFkKj>i)Swe@PJ#>r|V;Fcc|@-w1;2f19)_/chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("chats//messages/", MessageView.as_view(), name="chat-messages"), + path("chats//read/", ReadStateView.as_view(), name="chat-read"), + path("chats//events/", ChatEventsView.as_view(), name="chat-events"), +] diff --git a/apps/chat/api/views/__init__.py b/apps/chat/api/views/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e8241c33827ad5f1d7620663bb6a1e9894cc35f6 GIT binary patch literal 164 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~ieerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMjFqWhmYG^!tREkrnU`4-AFo$X hd5gm)H$SB`C)KWq6=){N&SDVbBQql-V-Yiu1pwFND0Bb- literal 0 HcmV?d00001 diff --git a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6750f1cfe310b1e70db21ca09b4cd771f4e74e22 GIT binary patch literal 3628 zcmbUkTWlLy^^Rx!%=nSmT|0@>IFB}U8sa>fELln+w2d0O%~qb6F7isFsXa;Eb;jO1 z?fZQVj5tsytGn9!SZb?|;EM*Ox zO>l85wHi2=u*L1vZs690BkrV51Ggny@iy8P=P7Tj?Fo0>Lp=uWNON9X>qCMV0 zJ0c`R`ibD`B0}4{_9?b*3-udR9;ogXYGBzD^}a-rD5GMhJra38}`<_ho zH(Gg3Iw}< zm}*EVmj5bmIU>)d&CnrJhCV`)_6E{2y)!V?I9uV;5PbEKZZokIXIgNQjcMMLOL|PK zIScHJWspR<1>IRvWh#z~xoiWS0F-hd8g)?giOyvuO`>?LZp#=>q&8Sk%nXWOqWi8b zF5VO;#>U0V$*Z%ASFeEYIaSUt>2@l=Uy@agcA&5yh51ZNSgE)_f&}Q&K_Y}ZeTBBp9CBTAY^bGJ*HtOD;e@MsSL%meB*0S32< z;q)W&+Tygj>N3dwKeH%=_tNT2On4)Y(1Z-eh&eDMj68}1Y|W$ep;XFZMy)w3E%^ZX zhOu>QBoCv%$q*R9V&=!8Ca#%T|AQu0;J%>A+RQUEM-^MzZJvKfv!~Is#yF%oQnn=C z!m;trg_PAS)QU4k6j#dWC)a*Yh|-qE)G$UmU}DKPOsqL4JDa$PrP`7h0mp`5onev~ z9><1YGrg7UJ;s8iU>6*MbLQ}l6#g_x*^_-=lsx&=6l;1?al=kW^30drDOiLyfuCU& zPs($yu}OmJkn#vd^?VzOyy8u{l0#osBUorEYS{wR;g)3sP4Ovr$}6}>54{s1H~v6K z8;OwgS4{bI%*0YI@RMi8k{mNB&Dr$tAn7CNev@t|_%+bNU5dMxEPSv;zRvcNCFVpK z_)?HrGi9RQ1t@CJA}KZm-HE8ELbrX1!2&JR+gbySQhv7pH6#laL$@0RK9^ktIc_O0 zX>%{JaYZc@y`wvdQ2tRN)F=xy0Zv;l%DSsw=nVyYvDy1o-39eoEGf_jvO1raR83r| zw{KLxA}Q<9&U)Kmq3Y{wPSJE8S^!;#L#VW(>g=+t8Qp|pHc@O-x;qagMO-RS5slS3 zTv7*e?1kG#7tn1>xx6M*I&K(Upjj~c_PXA-D&?WUh{g`gK;3!uyBWENZS_Hx>;0LGr9x6-@xoE>au~j zTkil4qq17cYdVKydI5FR&8z!NT~_Hd5LF)l?0>DJUeUjXM~44X@f`&>Vgjo_fZK#N z=IQw9=B}GP~>T*m$$%AFB9=YX0*T|M^FATeDUFbk+N2ncEEnH|@31NF_8< z3yoDmV_V&iZ&yRJ)xfvP&KLf(RsXrNW7j`W^`C*cBU0_?FWaAoBDK(HB{cd-eH^WZ zu9V%++x=zRp8|d5E4$sjyS|~CFIMrzYQE`;Z~BS$+qGY}T!a54$(N^JsCSd%JyN-_C_SmJzYIaBKytMrU*1wIXa5_~*R?YXq!+H-L3 z!JY6xEj(Tck8cfrI`qlV6Xtib+u`x;@WO`k&#|v=u0Lx3+4mo3H~2jVar-~G@#7oM zyG}nCx<9lNjy#yZKflv|_7}0A$94wCcKSyEzH-~!JO0Kx-JVxx$f^E=w}>zF$I!&q z^b@WUx>^gxE1`JRo7i?F{&5f`fu4QBpn3ftx%ODV{&m1Nh=V)510V579Q>y`2p93| z!SEbo`3)1D^IM)W{G8AF)XO0B^K<>yr+o}SqiMryfa1;32!cKYSS2XlHyuE5thHke zr3jF&Vp?Epk6N!Y_1=!Gh3F}rD`B`|r`0_WeUMO)aPnG#zE^*y1&{`9&;Z-EHbLG>E|;2Ig`89DVi>3c>- wo{@8(lM8>hu#DxkgD~F&&eSvV)`88%xHfMc5SR|ShMDWk=Gtq5vvCdo2AP8=`2YX_ literal 0 HcmV?d00001 diff --git a/apps/chat/api/views/__pycache__/messages.cpython-313.pyc b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1a330376ee960e853d27ee20cdad6e0de9515090 GIT binary patch literal 2442 zcmb7G-%r~{96!fD;sl5ZHYBAC(spgI=#tTiR!CL54yXontiq&BN+rt;hPn+-dgs8H zsI;dleHxQmZF?HhDviGNu@CzrYGsn6Q`yVfhrXfFzVFV719Vgqr{wGJ-F<)E_r9O6 zz1z_d1!P>lrQW&51MoMU1cS`|v3rX=9s><%%rux{Ad{keXgV~-Le}TmX>N*#yw7vf z!c-WBeV(5dry?-o^TKp=stvZKKoXn;TDTWz;?3{~+i(Hfmm+HP4V|bA=5o2o@uv(H zkYhL%L{`LE6O~IgG6^5Pa((iKWv&u7{*{T4v1Do{w2Y$loe9ZeY;SSJDwu_ahX!Su zlid_y9)p_zG8%v(je)Ed>IIO~SVDP?gTfN83b?Ili~4)hMsy0zfPFh6p>)3cfY^Nu z@_}`bV`f9X!~vo)G@~O;DVsf9YBoswK3y%Ou_H~3Hg^O)w&jf{TEae)P4-Z4TuV#H zM*^LZK!c{?y|H)ld`GaA3k6n!XO7KrIX)+7{3vgS^HLz0j|W^X9LOG)zkF99H>(8P z-cu7sLmvY*JcA?1v=>aMTMM{-xltgxvsN}S=a}C*m|L`prpjZfP_kD{hz!Rn*+dw| zh%rc`f}NDms31p(+6L(@V{XB449qT>4(6{;UYpP$O%CR$JIo>nLYiPms{+zX#GPNx z&0W{eeLAd<%}!jIn;2I`DqUQ}VQAi~n8?B5!fnI(fO9NB6IF^1q83xlfyc5rM54~# zHqh+_qnN!8OLxqIgR=9MjY_2~HJCNZWfX8$c7;?g$~IGhGUYYQl}p5V=f3LB(3N)L z2t6U>d+<_8{+#%=@0Y&KyY8`bHRZEvq@L*iY5m7_FOhZ=>8;pvp_aH@ZLdo`Kde7o z_oM+=8rT$SQo72~wfhh6ds51kQky+BX|T$@iVxJ{C%pI>H-6@2U+RgxIsaQD~sm`9dfFjWpx8}KA0rzkm1$Q)_q+_GV>sZq>Ywu9RVV&EFlhQ>02RKJ10j-rJe zJ*aLUsxU!!_s}SlsVc%_bhVd~HetRmDq^@<=hV7m1tFb8*3tH@VVk9FL}u^H#t_5#$SlzFhyd03TD(W7Iob=mQ7v9 zQC(jyEmVq>Z`bvE6{FbPk#v30g2*XawrQ7i9rhFTMkcy-oxY{5f^Iktv~E=#6A^(= zr~q}+NE)p;ND2KT+Grnue?l?-8|GcAgP&j;qI7dc?Z8~6LfWz4b7Q0hI{G5Ha&xr= z?>72s;^1P2O#P1o|HiTM-mc&MAVmi5r+qO)J7k9R(B>xt$^^IVw{F~fK!|=qv?Tn3 z^Uv|#0kL?ok-AH8lsKZVctneMhh-RM8+5-0C$_=aZ7}#6T-*jDZ-stlg4syF1N7-1 F{$DtI@s0oh literal 0 HcmV?d00001 diff --git a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9abd17f682a1bc04122066b076716508bf31a49e GIT binary patch literal 2277 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 literal 0 HcmV?d00001 diff --git a/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7c86df23edcaf9138fc33d4e6c83a6569a665433 GIT binary patch literal 1461 zcmZWp&2Jk;6rcU_+VKY_CZT+UF2zMSYHivkl%N)g4@;Ie)@0oX()GNl@#LdrGij7()iV5O#2$|Z; z$V|+_X@Y2?EtT}A0xpy!{z97sn39&waMN>L(hO|Rh5dY;FlM!hN!*}%6_|ZQLZ~U+ zBKkL-H(_TJT|`te5tU7eDyDoKJfA_`AX67;CM&>W>Z43+|}z&H2?a@a)6z(X>n*%5YSQ*KC0|MeQ#^l zWjU2a>UClES>KgC|dW8=S*ltxB@OHsn=R=jFYs%07+{fX@0uvid>s4;Qy57?S-dC0mdn z#T$4nP)y+gJOb&Rfb^exJqx{Mkc8J2 zdQzY-z4Ji&GI$oq@B~%?hBCs4q(?4Zw5C|J7gcErjn^#VLe1hXLTy{D=lRe~XCnxG zdiKoR!jk%$e}lwxfzj0!j_B4fe>!!q(sxFqNsG)BmsyoAuvzKcn)H2Ho2E(gBC~0> z*_b}-OijAE?S)fQ%r>9hXt;Ancbc6I*AaV$1Yh9Sc(SJikoQm1jg_jiwN}av!dc&drgxT}NcgH;WjCU~WC+|b=-bvoIapSXofEUOX{2$DH Uf}Jmf_zh2QB45ORfuQsG2RAyL^#A|> literal 0 HcmV?d00001 diff --git a/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..89ddeee6bdf54583588d2fb00a749e3f697d9ba1 GIT binary patch literal 1228 zcmZWo%}*0S6rb(x_KOx!L#=#-4N;Ot*^MAK@Wurcjhlu8lF7Cm%HpzHXSSMp(u?6J z2O^x%lkw)Cg3$!B0b^o3c&ifQ)%W&8L?`Kczj<%wz2Elt=A>B66IffzmGx&SLcZW8 zSxO8}W&msxlbF&1nU}bvAxG?kHFONdb0k|8i&X>xlg!c z5}q<8E}NEjf57~^?V4kYHO9YXpg^E9>=2|m z^V{5AtjD~=ZdtH0{7ycB*(S?U7zHyO0~gv>rF(zhi8H-qD&@+{YI38Rx|dGysh^H3 zj*%*mhf3J-pS9eR1R!5X@TpH~K(_ZZnjllw70C1_`%^zBB^qg@Nh-=>pn?!SeNsTD zZ$uhYltA%2Ef!>2p75#N2y%kC4aN}`;zJZqRB{}p2{K_7yTS3F3-V3wwObVLaHJQ= z!somX5Jyf>*6KK()xbW2IjcN}(c+nrNQ$@?^urz+tl*YW6^sjG%@S)3%Q5b9Z=KbB zVJz9M@H_))7*?w#A{5Fjj8>w)Fdnm-2q!{sb$~~d2nPQj6g|$NIHCsTDLE=vK9sL+ z77s5D?^O3zc2{=p>6nZklZmfn>Q}lb<$n+`A+6t6N+vV_ literal 0 HcmV?d00001 diff --git a/apps/chat/events/publishers/longpoll.py b/apps/chat/events/publishers/longpoll.py new file mode 100644 index 0000000..101487f --- /dev/null +++ b/apps/chat/events/publishers/longpoll.py @@ -0,0 +1,23 @@ +import json + +from django_redis import get_redis_connection + +from apps.chat.events.event import MessageSentEvent + +_KEY_TTL = 300 + + +class LongPollPublisher: + def publish(self, event: MessageSentEvent) -> None: + redis = get_redis_connection("default") + key = f"chat_events:{event.chat_id}" + payload = json.dumps( + { + "post_id": event.post_id, + "sender_id": str(event.sender_id), + "message_type": event.message_type, + **{k: v for k, v in event.payload.items() if v is not None}, + } + ) + redis.rpush(key, payload) + redis.expire(key, _KEY_TTL) diff --git a/apps/chat/events/publishers/push.py b/apps/chat/events/publishers/push.py new file mode 100644 index 0000000..fbba604 --- /dev/null +++ b/apps/chat/events/publishers/push.py @@ -0,0 +1,7 @@ +from apps.chat.events.event import MessageSentEvent + + +class PushPublisher: + # Future: FCM / APNS integration + def publish(self, event: MessageSentEvent) -> None: + pass diff --git a/apps/chat/events/publishers/websocket.py b/apps/chat/events/publishers/websocket.py new file mode 100644 index 0000000..22e2f0f --- /dev/null +++ b/apps/chat/events/publishers/websocket.py @@ -0,0 +1,20 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + +from apps.chat.events.event import MessageSentEvent + + +class WebSocketPublisher: + def publish(self, event: MessageSentEvent) -> None: + channel_layer = get_channel_layer() + group_name = f"chat_{event.chat_id}" + async_to_sync(channel_layer.group_send)( + group_name, + { + "type": "chat.message", + "post_id": event.post_id, + "sender_id": str(event.sender_id), + "message_type": event.message_type, + **event.payload, + }, + ) diff --git a/apps/chat/integrations/__init__.py b/apps/chat/integrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..067b0fd5281282c7ff9365e6f7bb7878933eb589 GIT binary patch literal 167 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iGerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq4|BlA2zWSdy8aSF9f&pP83g m5+AQuP>P{wB#AY&>+I)f&o-%5reCLr%KNa|LgerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq4|BlA2zWSdy8aSFE3#SW=Q& xl$&2%q8}fhnU`4-AFo$Xd5gm)H$SB`C)KWq6=(y<1;rr7M`lJw#v*1Q3jo1fE`b05 literal 0 HcmV?d00001 diff --git a/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7b0176a121276e9ed3e82a5ab439e04fa03c8db4 GIT binary patch literal 8951 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 literal 0 HcmV?d00001 diff --git a/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..efcff159104ca7f4ef3fdb483bf62cd912f7782a GIT binary patch literal 487 zcmYLFu};G<5VeyQO4X`jK&m=LY++~?x*;S61_r8%2vtIrP9}95S4kYsuAqDXGfO{$ zUy9h6*pLD{cGJQQzIS);o&8+4+f8EPZtO3{CV$vs7xgVDdj_mYmmHIj^hii2WZ+ga zq;to2qn*NLx-H7C0c$cLNJD~7NYM=)tc5Pt=QY1xbgr4!98;Q0zm7$v_6V3xK$ig5Hl4|SrUv;E_kF>Fcz7T zGKgZP1D5BiLL$?Aj%K0ERFM7|4ER&T^G&y(uh4$p=CGEPz3hd&*G{pu>koaWIJn$t mD!!#vjJ82V str: + return f"chat-{UUID(str(user_id)).hex}" + + def _email_for(self, user_id: UUID) -> str: + return f"chat-{UUID(str(user_id)).hex}@mm.internal" + + @staticmethod + def _random_password() -> str: + alphabet = string.ascii_letters + string.digits + "!@#$%" + return "".join(secrets.choice(alphabet) for _ in range(32)) + + # ------------------------------------------------------------------ + # Public API — return only opaque strings, never raw Mattermost dicts + # with identifying fields surfaced beyond this layer. + # ------------------------------------------------------------------ + + def get_or_create_user(self, user_id: UUID) -> str: + """ + Ensure a Mattermost account exists for the given external UUID. + Returns the opaque mattermost_user_id string. + """ + username = self._username_for(user_id) + + try: + user = self._driver.users.get_user_by_username(username) + return user["id"] + except ResourceNotFound: + pass + except Exception as exc: + raise MattermostError(f"User lookup failed: {exc}") from exc + + try: + user = self._driver.users.create_user( + options={ + "username": username, + "email": self._email_for(user_id), + "password": self._random_password(), + } + ) + return user["id"] + except Exception as exc: + # Guard against a concurrent request having created the account + # between our lookup and our create attempt. + try: + user = self._driver.users.get_user_by_username(username) + return user["id"] + except Exception: + pass + raise MattermostError(f"User creation failed: {exc}") from exc + + def create_private_channel(self, mm_user_ids: list[str]) -> str: + """ + Create a new private Mattermost channel and add all members. + Each call always creates a brand-new channel. + Returns the opaque mattermost_channel_id string. + """ + if not self._team_id: + raise MattermostError("MATTERMOST_TEAM_ID is not configured") + + channel_name = f"conv-{secrets.token_hex(8)}" + + try: + channel = self._driver.channels.create_channel( + options={ + "team_id": self._team_id, + "name": channel_name, + "display_name": "Conversation", + "type": "P", # P = private channel (not a DM) + } + ) + except Exception as exc: + raise MattermostError(f"Channel creation failed: {exc}") from exc + + channel_id: str = channel["id"] + + for mm_user_id in mm_user_ids: + try: + self._driver.channels.add_user( + channel_id, options={"user_id": mm_user_id} + ) + except Exception as exc: + raise MattermostError( + f"Adding user {mm_user_id} to channel failed: {exc}" + ) from exc + + return channel_id + + def post_message(self, channel_id: str, message: str) -> str: + """ + Post a message to the given channel using the admin token. + Returns the opaque mattermost_post_id string. + """ + try: + post = self._driver.posts.create_post( + options={ + "channel_id": channel_id, + "message": message, + } + ) + return post["id"] + except Exception as exc: + raise MattermostError(f"Post creation failed: {exc}") from exc + + def get_posts( + self, + channel_id: str, + page: int = 0, + per_page: int = 20, + since: int | None = None, + ) -> list[dict]: + """ + Fetch posts from a channel and return them in chronological order. + + `since` is a Unix timestamp in **milliseconds**. When provided, + `page` is ignored and all posts after that timestamp are returned. + """ + params: dict = {"per_page": per_page} + if since is not None: + params["since"] = since + else: + params["page"] = page + + try: + data = self._driver.posts.get_posts_for_channel(channel_id, params=params) + except Exception as exc: + raise MattermostError(f"Fetching posts failed: {exc}") from exc + + order: list[str] = data.get("order", []) + posts_map: dict = data.get("posts", {}) + + # Mattermost returns newest-first; reverse to get chronological order. + return [posts_map[pid] for pid in reversed(order) if pid in posts_map] + + def get_latest_post_id(self, channel_id: str) -> str | None: + """ + Return the ID of the most recent post in the channel, or None if empty. + Used to derive `has_unread` against a stored last_read_mattermost_post_id. + """ + try: + data = self._driver.posts.get_posts_for_channel( + channel_id, params={"page": 0, "per_page": 1} + ) + except Exception as exc: + raise MattermostError(f"Fetching latest post failed: {exc}") from exc + + order: list[str] = data.get("order", []) + return order[0] if order else None diff --git a/apps/chat/integrations/mattermost/exceptions.py b/apps/chat/integrations/mattermost/exceptions.py new file mode 100644 index 0000000..2237877 --- /dev/null +++ b/apps/chat/integrations/mattermost/exceptions.py @@ -0,0 +1,2 @@ +class MattermostError(Exception): + """Raised when any Mattermost API call fails.""" diff --git a/apps/chat/migrations/0001_initial.py b/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..e7cbb68 --- /dev/null +++ b/apps/chat/migrations/0001_initial.py @@ -0,0 +1,68 @@ +# Generated by Django 5.2.13 on 2026-06-10 12:09 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='Conversation', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('mattermost_channel_id', models.CharField(db_index=True, max_length=255, unique=True)), + ('type', models.CharField(choices=[('direct', 'Direct'), ('group', 'Group')], default='direct', max_length=20)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ], + options={ + 'indexes': [models.Index(fields=['type'], name='chat_conver_type_857bd7_idx'), models.Index(fields=['created_at'], name='chat_conver_created_656b50_idx')], + }, + ), + migrations.CreateModel( + name='MattermostAccountMapping', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField(db_index=True, unique=True)), + ('mattermost_user_id', models.CharField(db_index=True, max_length=255, unique=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ], + options={ + 'indexes': [models.Index(fields=['mattermost_user_id'], name='chat_matter_matterm_e23c84_idx')], + }, + ), + migrations.CreateModel( + name='ConversationParticipant', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField()), + ('joined_at', models.DateTimeField(auto_now_add=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='participants', to='chat.conversation')), + ], + options={ + 'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_697713_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_59b4dd_idx')], + 'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_participant_per_conversation')], + }, + ), + migrations.CreateModel( + name='ConversationReadState', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('user_id', models.UUIDField()), + ('last_read_mattermost_post_id', models.CharField(blank=True, max_length=255, null=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='read_states', to='chat.conversation')), + ], + options={ + 'indexes': [models.Index(fields=['user_id'], name='chat_conver_user_id_e7609f_idx'), models.Index(fields=['conversation', 'user_id'], name='chat_conver_convers_9555aa_idx')], + 'constraints': [models.UniqueConstraint(fields=('conversation', 'user_id'), name='unique_read_state_per_user')], + }, + ), + ] diff --git a/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3d079a82c4a8f073ca3617d589c72295cdeb4c4d GIT binary patch literal 165 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i0erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq53_UX)mpnV(mzA0MBYmst`Y kuUAlci^C>2KczG$)vkyYXeh|iVi4maGb1Bo5i^hl06xPhUH||9 literal 0 HcmV?d00001 diff --git a/apps/chat/models.py b/apps/chat/models.py deleted file mode 100644 index 71a8362..0000000 --- a/apps/chat/models.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.db import models - -# Create your models here. diff --git a/apps/chat/models/__init__.py b/apps/chat/models/__init__.py new file mode 100644 index 0000000..d9080a4 --- /dev/null +++ b/apps/chat/models/__init__.py @@ -0,0 +1,12 @@ +from apps.chat.models.conversation import Conversation, ConversationType +from apps.chat.models.mapping import MattermostAccountMapping +from apps.chat.models.participant import ConversationParticipant +from apps.chat.models.read_state import ConversationReadState + +__all__ = [ + "Conversation", + "ConversationType", + "ConversationParticipant", + "MattermostAccountMapping", + "ConversationReadState", +] diff --git a/apps/chat/models/__pycache__/__init__.cpython-313.pyc b/apps/chat/models/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..6c62aefcc917780bd4ca2549f3b4f6c70b96e8de GIT binary patch literal 533 zcmZWm%Sr<=6iwz?tCeoVf(qTvruhMt5jQeJr8D0JyZxby@3Bm>2T zD|cdlz?-Skfo5?yH|OTulVsKDv=OZLFqpk!gg#C2zpOv93W2OsA1`Ef4XTUY! z16&7feFDyMkWQHz%=tJgfH&f8j_Tf4<9r>e_S45(a~7>;Zh+7~>CgwM1u26axM@-mdlRi+vD(0c&)d{r~^~ literal 0 HcmV?d00001 diff --git a/apps/chat/models/__pycache__/conversation.cpython-313.pyc b/apps/chat/models/__pycache__/conversation.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09d1e62ad403aae7d565efc7d5c1cd1b91f62d9b GIT binary patch literal 1901 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 {self.mattermost_user_id}" diff --git a/apps/chat/models/participant.py b/apps/chat/models/participant.py new file mode 100644 index 0000000..528c30d --- /dev/null +++ b/apps/chat/models/participant.py @@ -0,0 +1,31 @@ +import uuid + +from django.db import models + +from apps.chat.models.conversation import Conversation + + +class ConversationParticipant(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + Conversation, + related_name="participants", + on_delete=models.CASCADE, + ) + user_id = models.UUIDField() + joined_at = models.DateTimeField(auto_now_add=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["conversation", "user_id"], + name="unique_participant_per_conversation", + ) + ] + indexes = [ + models.Index(fields=["user_id"]), + models.Index(fields=["conversation", "user_id"]), + ] + + def __str__(self): + return f"{self.user_id} in {self.conversation_id}" diff --git a/apps/chat/models/read_state.py b/apps/chat/models/read_state.py new file mode 100644 index 0000000..259c75b --- /dev/null +++ b/apps/chat/models/read_state.py @@ -0,0 +1,32 @@ +import uuid + +from django.db import models + +from apps.chat.models.conversation import Conversation + + +class ConversationReadState(models.Model): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + conversation = models.ForeignKey( + Conversation, + related_name="read_states", + on_delete=models.CASCADE, + ) + user_id = models.UUIDField() + last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True) + updated_at = models.DateTimeField(auto_now=True) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["conversation", "user_id"], + name="unique_read_state_per_user", + ) + ] + indexes = [ + models.Index(fields=["user_id"]), + models.Index(fields=["conversation", "user_id"]), + ] + + def __str__(self): + return f"{self.user_id} read state for {self.conversation_id}" diff --git a/apps/chat/serializers/__init__.py b/apps/chat/serializers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/serializers/__pycache__/__init__.cpython-313.pyc b/apps/chat/serializers/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..830c4325b828d40c1a7513c718bdfdbd3253d972 GIT binary patch literal 166 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iWerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgauTanV6GV1=129pP83g5+AQu iP!p>dO_mQLyzgLe?oHV725}+QK}qzsJNvvQ7=98yzBvK=te||9^jE8@HjK5saxiE24zZs%lGWc z6IkkwX;=7pzqY7T$1Ivvz2X%8cEzsJS%k$7StV3Z2@R+M4X%o3xjaUux>veR2f*pz zRKVM$5mg)^G^&QEv=LS!e8}&FdhG2%81SbY=^7xN44m{jLPJ@I>{kK}t3evs5S0+W zc(&n*hlZmcNMJ(|dB7}j(b=vVJXWNJ?ieLaceq?Vi3R(OEGSVP)wGJfWoR0YY1)=u zs#&;}HSKXtx4I|&nzn9I=2&LMsMwlDF?2ixXv5;+be=*wVGov zfF=Rjfgj=d>@wtxWy?A%mvk$;NbOCd=&-+*@hpY%Nxnt!RRXAeB z(GkoAyw@|9g+jDv3shB@5eo4$_5)MhZxs?}oCZ}<4b2>5J|;?d0Vv+@f3^k*l{Rh2 zfSbuk4dMfPOMnyCE!_daiI&M6iZ#k3_L>`8%#P(4lwyQ<)TSl4wYJUUPjssW`N11d zJt8x#bsmIE3?q@r&`Tf5`)jb`T0>RG4>`g{Cs8i!i#}tD|?BFPDD(`-V#xYdAURyEhsUX zf|;H}kw!6q0#nKdPbv(!QMOBSNK4v}03-svhT=R5wB=dFS5nNbE3h!x6%atB^k_SM zvzflxN>A74{}ltFyZ;an@1mhl*CV`ks*I1D<9~3{TgD&iC(v=uXnnqp69ojmyJz?i zF+dFU9D_PWKGeGj;Kw+GEhR=QgQV%fAxOLIWKm~L72Q>{|7EFPuC(tYc ze>{Y*m7pkLvq>DnWJqPjOiQ3 znH(Ai+Y|*pju{|;L()K78f{9WEh$r<-H#8oP9Q|X+7_< z?RqnP{l)j~Y_6HjwX$=K^q2K{7p62*N-H()gZ-X+In_wt|6$%^F@ASJk$4|9z|D0E z?fj`tA9;0=#Ev?Fy>-Iv*UW9+UYF>9h#mYSHEtE~m~X><*XerU(L3JX#9ev^K8$^h zeFoxfP!NPa$tQo4*~73b#CBKOmv1*N-##Ru9FB&CyzuMA_SIXBtGC_})LiHP2Z=-J A1ONa4 literal 0 HcmV?d00001 diff --git a/apps/chat/serializers/__pycache__/messages.cpython-313.pyc b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e9a52699dcfbed8c51ccc483dea151db814f8f8 GIT binary patch literal 948 zcmah|%}*0S6rbsqe(ciHra}s$5J(NnfxUVP#{pS7ly|<^CjEdy>`byvVj1l@7 z8s}%Aube)R$^kNwiC57I20V$1XbPFJF=Q%Rcq4uexO~qmkq*jsTpu_I4IK-OQE)8R zc}Ia&FOw0vsPtWW56~6@Y$Ax67?iE}#u>w_=}1itNGqV3iLo4KGDhn7=*WF;o z%>fxtUmK5^2{XA61NFZdn#?F(!y&pOW2PXxoz_*6e@+>*wyAm1i<7W{o3cNu<+|P; zY5J}!;+($a(wDSp&GKEoq{kpB^F#{huI~V~KcRJfNTdj9S`A8wPzh;xw(o}eAR)WH z<@O^(gwz~h+;y6?=@A0qbVPcwZo!g6U0Wnu9^=HZMY>X1E=RsJqfMJa=pAfClnLK& zQAmYg#Rpep$`+@aPJ>1WwF*=^Y+`XSObFu^cdF9h;Jo%ZWwNj)v8VDu;;B)Q+rW&v z#p<@@8ZW@xp;gX|mrj#;o>8q^+^||L7HL;D$LOK9G{{bO zv(w+QrS`L4?q-lHb#tYo8=c&}_HwUqJ1ER_3o}O}ox=V0T2C7Zw8^eEd00N4?`Tij z<(@hms1sdv;_&WqsiQt_FZS}|L4LNIpFOg_R66<9_bV`ZUU*%Pi!4ytzZ%Fv?0N7e z>aTh^;fQAV@eyuJY9jS88rLJZB~AJBAvP|>F9l=#1Ks(ZNZ>X6Q45N5o#Nad6k2EK Ezel*{JOBUy literal 0 HcmV?d00001 diff --git a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..643a26c446e28987c073cdaa8157cf9f0eaba85d GIT binary patch literal 714 zcmah`O=}ZD7@o0UxO%UH<8OHXuw=RxCSddc z!3Bv(M4yv=3iKAaPVSJXR3nkoqK)!YFuQl10)ul@4i+vg3245e1xTLAGXc6i3nrvA zA)_+{7o(o6E5rmB2l6f^xpm5Il|y zHzNAm>!ls=3pof@@+HPnej*slUB*sSoFx{oGIpBrWb$X7u|o;kBr+ALVhpSo`Es-Q z?8#Fpk~m*)tMnaOz>QQXlW|_j&=?C=A1jpzo)&R#hijIlfF0%67}MC5Y%^{Q$Tyi0 z8dFy=^$0h~_rfM7(IIL&F;BvmppHe`=Yn*9Wzo MueF`u1j$(Y2b(pky#N3J literal 0 HcmV?d00001 diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py new file mode 100644 index 0000000..8b3f322 --- /dev/null +++ b/apps/chat/serializers/conversations.py @@ -0,0 +1,27 @@ +from rest_framework import serializers + +from apps.chat.models import Conversation, ConversationParticipant + + +class ConversationSerializer(serializers.ModelSerializer): + participants = serializers.SerializerMethodField() + + class Meta: + model = Conversation + fields = ["id", "type", "created_at", "participants"] + + def get_participants(self, obj): + return list( + ConversationParticipant.objects.filter(conversation_id=obj.id) + .order_by("joined_at") + .values_list("user_id", flat=True) + ) + + +class ConversationListSerializer(serializers.Serializer): + id = serializers.UUIDField() + type = serializers.CharField() + created_at = serializers.DateTimeField() + participants = serializers.ListField(child=serializers.UUIDField()) + has_unread = serializers.BooleanField() + last_message = serializers.DictField(allow_null=True, required=False) diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py new file mode 100644 index 0000000..833568b --- /dev/null +++ b/apps/chat/serializers/messages.py @@ -0,0 +1,10 @@ +from rest_framework import serializers + + +class MessageSerializer(serializers.Serializer): + post_id = serializers.CharField() + sender_id = serializers.UUIDField(allow_null=True) + message_type = serializers.CharField(default="text") + text = serializers.CharField(allow_null=True, required=False) + url = serializers.CharField(allow_null=True, required=False) + created_at = serializers.DateTimeField(allow_null=True, required=False) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py new file mode 100644 index 0000000..23a5c17 --- /dev/null +++ b/apps/chat/serializers/read_state.py @@ -0,0 +1,7 @@ +from rest_framework import serializers + + +class ReadStateSerializer(serializers.Serializer): + conversation_id = serializers.UUIDField() + user_id = serializers.UUIDField() + has_unread = serializers.BooleanField() diff --git a/apps/chat/services/__init__.py b/apps/chat/services/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/services/__pycache__/__init__.cpython-313.pyc b/apps/chat/services/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d2d6c02f5d370698dd3aa87de5e8e63dc3fa3177 GIT binary patch literal 163 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i8erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgauSvmYJMdtREkrnU`4-AFo$X hd5gm)H$SB`C)KWq6=)>L%3=`XBQql-V-Yiu1pu-qC_n%J literal 0 HcmV?d00001 diff --git a/apps/chat/services/__pycache__/account.cpython-313.pyc b/apps/chat/services/__pycache__/account.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1830d01be743e0a35cab77bc8fdc3bf64c96643 GIT binary patch literal 630 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 literal 0 HcmV?d00001 diff --git a/apps/chat/services/__pycache__/storage.cpython-313.pyc b/apps/chat/services/__pycache__/storage.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1cde293b6f118e28bbe043514809767ad62b0776 GIT binary patch literal 2290 zcmZuy&2JM&6rc6(`YRjfi{iuy!33ykwb-IbB%l!lQlsXBgnF}7m62AP^~BlW?7Fk- zDoHOrRF%_G1rDgKidy&7aLbWD!!!r1A#f=@a0{do*S^`!+JG|B&b)cw`{w;-_S@T| z2*&UE)ba%pp?}!MAHo5#KLf-)BqN!dM#Nh=!ofZ;9hl+?pJY6KT9^`v=+eS;a4JMX zNz{vmkSr#U92^aJ-blE}r9v;*W;Vr9u~IP#BS3_0>^O#5v>Ef7VH%e18F+Iumyck7 z58Xs8J_*Pi;pIRA5kcl}in4H191&Q&Wq}0cAPE&isW6SnjzzR0mNEI-(D921xX`&b zyfX$5-~K7!-b4AOEPs$Rf?t=>DAR#qatil>N1{R-LU|~hH{`(Rfi30^^}@?S(@P*9 z^6iwvwZ&G&dtGNyAQ>mXp*ibI~YP2rf{`(~z?h zN-leqc6!^(6BAikR;ROH`W#TmW#<_u#nY&!>)5u{TX>B|ZLAaQxO5O`@JhQpFISX_ zX|S32?D9Mn^d-%q;*#T(Z7Q&3oxhODq(U@Q#E#}Tgs>{n=nbt@!C68qLRcwiNYzUQ zHXR@qD*7$#(1^OA*;uWRQc9qLjZ2F(jLkyXGE9ebGMk{SFX0tzGml9a^m0bAF|jjC zn!QxeN}21#TE@C#XTC5@+p;n&xr|mW+a3kFtSRk`?KNq-yhg*S3IK3amB!z0-Lx-Y zptY+oeT#N`68F#5PmgXpJ3V97@UxEiZuda_*rlEB@wznrysP(qZ|&mt=uX%9YH&9` z(2$Po#s}}O)>EH6oZpFGd27??>3bOwd!tq1w)CHbdXF=`v4@L0@hoFK8yLD9-wIZP zje((G&eeu%tF`5N-`T40Q*0k{K{UK*l(4C-U>byTst{8@fDDtOFnlh|zIz8|3MX^^ z{m^VgsIJ20-n4g}AT)Zg&H)Tfz6Cyo_rYfh`DU}`Sk!mM5K@{ztw6(%GvBB}3O7!!k3L?qKJP?SN#3 zav8XgB|xzQz)+pFGETMA5Hz=oqf-!&@^I!fvSO@YCbns`SYe1|8^X?y@=JJ?#2FPW zR~*$^V1OVjZ5l8v8oILPV0&_o^Z_RoCJ81GSSyheE?LkV4bW$-WAZ*@a4K!{oT#kX zY<9#9du)(nOrxz*E?HVZWlm_~9T2@CW1;K^FaeGRPuB`tS04`!KN=jaI-6@7Yq!67 zc%&-qc68qv`Eji(JWrf_oJjwjNN=s~B+hMgRHbJf{f++7>df=L!QbM4^#9ubOMC5e z7Zpd(R@)k3X;a#e9*2)V3LkGsotrZoGYu)eE5$aaewe!B>_~vd&PLzqy*3m{zC^)D zbT5W_;yI39PbxTXwqvcNQ)wEf)zeleEG4bQQWT!aJZJpdhY~4Qj+Fd bool: + # Future: call external Account Service HTTP API + return True diff --git a/apps/chat/services/conversation.py b/apps/chat/services/conversation.py new file mode 100644 index 0000000..8a0fe75 --- /dev/null +++ b/apps/chat/services/conversation.py @@ -0,0 +1,65 @@ +from uuid import UUID + +from django.db import transaction + +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import ( + Conversation, + ConversationParticipant, + MattermostAccountMapping, +) +from apps.chat.services.account import AccountService + + +class ConversationService: + def __init__( + self, + account_service: AccountService | None = None, + mattermost_client: MattermostClient | None = None, + ): + self._account = account_service or AccountService() + self._mm = mattermost_client or MattermostClient() + + def create(self, user_1_id: UUID, user_2_id: UUID) -> Conversation: + if not self._account.validate_user(user_1_id): + raise ValueError(f"User {user_1_id} is not valid") + if not self._account.validate_user(user_2_id): + raise ValueError(f"User {user_2_id} is not valid") + + mm_user_1 = self._mm.get_or_create_user(user_1_id) + mm_user_2 = self._mm.get_or_create_user(user_2_id) + + MattermostAccountMapping.objects.get_or_create( + user_id=user_1_id, + defaults={"mattermost_user_id": mm_user_1}, + ) + MattermostAccountMapping.objects.get_or_create( + user_id=user_2_id, + defaults={"mattermost_user_id": mm_user_2}, + ) + + channel_id = self._mm.create_private_channel([mm_user_1, mm_user_2]) + + with transaction.atomic(): + conversation = Conversation.objects.create(mattermost_channel_id=channel_id) + ConversationParticipant.objects.create( + conversation=conversation, user_id=user_1_id + ) + ConversationParticipant.objects.create( + conversation=conversation, user_id=user_2_id + ) + + return conversation + + def list_for_user( + self, user_id: UUID, page: int, page_size: int + ) -> list[Conversation]: + offset = page * page_size + conversation_ids = ConversationParticipant.objects.filter( + user_id=user_id + ).values_list("conversation_id", flat=True) + return list( + Conversation.objects.filter(id__in=conversation_ids).order_by( + "-created_at" + )[offset : offset + page_size] + ) diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py new file mode 100644 index 0000000..e62fce2 --- /dev/null +++ b/apps/chat/services/message.py @@ -0,0 +1,118 @@ +from datetime import datetime, timezone +from uuid import UUID + +from apps.chat.events.event import MessageSentEvent +from apps.chat.events.publishers.longpoll import LongPollPublisher +from apps.chat.events.publishers.push import PushPublisher +from apps.chat.events.publishers.websocket import WebSocketPublisher +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import Conversation, MattermostAccountMapping +from apps.chat.services.account import AccountService +from apps.chat.services.storage import StorageService + + +class MessageService: + def __init__( + self, + account_service: AccountService | None = None, + storage_service: StorageService | None = None, + mattermost_client: MattermostClient | None = None, + publishers: list | None = None, + ): + self._account = account_service or AccountService() + self._storage = storage_service or StorageService() + self._mm = mattermost_client or MattermostClient() + self._publishers = ( + publishers + if publishers is not None + else [WebSocketPublisher(), PushPublisher(), LongPollPublisher()] + ) + + def send( + self, + conversation_id, + sender_id: UUID, + message_type: str, + text: str | None = None, + file=None, + ) -> dict: + if not self._account.validate_user(sender_id): + raise ValueError(f"Sender {sender_id} is not valid") + + file_url: str | None = None + if file is not None: + filename = getattr(file, "name", f"{sender_id}") + file_url = self._storage.upload_file(file, filename) + + if message_type == "text": + mm_message = text or "" + else: + mm_message = file_url or text or "" + + conversation = Conversation.objects.get(id=conversation_id) + post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message) + + event = MessageSentEvent( + chat_id=conversation_id, + post_id=post_id, + sender_id=sender_id, + message_type=message_type, + payload={"text": text, "file_url": file_url}, + ) + for publisher in self._publishers: + publisher.publish(event) + + return { + "post_id": post_id, + "sender_id": sender_id, + "message_type": message_type, + "text": text, + "url": file_url, + "created_at": None, + } + + def list_messages( + self, + conversation_id, + page: int, + per_page: int, + since: int | None = None, + ) -> list[dict]: + conversation = Conversation.objects.get(id=conversation_id) + raw_posts = self._mm.get_posts( + conversation.mattermost_channel_id, + page=page, + per_page=per_page, + since=since, + ) + + mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")} + mappings = { + m.mattermost_user_id: m.user_id + for m in MattermostAccountMapping.objects.filter( + mattermost_user_id__in=mm_user_ids + ) + } if mm_user_ids else {} + + return [self._normalize_post(p, mappings) for p in raw_posts] + + @staticmethod + def _normalize_post(post: dict, mappings: dict) -> dict: + mm_uid = post.get("user_id") + sender_id = mappings.get(mm_uid) + msg = post.get("message", "") + is_url = msg.startswith("http") + created_ms = post.get("create_at") + created_at = ( + datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) + if created_ms + else None + ) + return { + "post_id": post.get("id"), + "sender_id": sender_id, + "message_type": "file" if is_url else "text", + "text": None if is_url else msg, + "url": msg if is_url else None, + "created_at": created_at, + } diff --git a/apps/chat/services/read_state.py b/apps/chat/services/read_state.py new file mode 100644 index 0000000..679b9c9 --- /dev/null +++ b/apps/chat/services/read_state.py @@ -0,0 +1,48 @@ +from uuid import UUID + +from django.core.cache import cache + +from apps.chat.integrations.mattermost.client import MattermostClient +from apps.chat.models import Conversation, ConversationReadState + +_LATEST_POST_CACHE_TTL = 60 + + +class ReadStateService: + def __init__(self, mattermost_client: MattermostClient | None = None): + self._mm = mattermost_client or MattermostClient() + + def mark_read( + self, conversation_id, user_id: UUID, post_id: str + ) -> ConversationReadState: + read_state, _ = ConversationReadState.objects.update_or_create( + conversation_id=conversation_id, + user_id=user_id, + defaults={"last_read_mattermost_post_id": post_id}, + ) + return read_state + + def has_unread(self, conversation_id, user_id: UUID) -> bool: + try: + read_state = ConversationReadState.objects.get( + conversation_id=conversation_id, user_id=user_id + ) + except ConversationReadState.DoesNotExist: + return True + + last_read = read_state.last_read_mattermost_post_id + + cache_key = f"latest_post:{conversation_id}" + latest_post_id = cache.get(cache_key) + + if latest_post_id is None: + conversation = Conversation.objects.get(id=conversation_id) + latest_post_id = self._mm.get_latest_post_id( + conversation.mattermost_channel_id + ) + cache.set(cache_key, latest_post_id or "", _LATEST_POST_CACHE_TTL) + + if not latest_post_id: + return False + + return latest_post_id != last_read diff --git a/apps/chat/services/realtime.py b/apps/chat/services/realtime.py new file mode 100644 index 0000000..190fe10 --- /dev/null +++ b/apps/chat/services/realtime.py @@ -0,0 +1,12 @@ +from asgiref.sync import async_to_sync +from channels.layers import get_channel_layer + + +class RealtimeService: + def publish_message(self, chat_id, payload: dict) -> None: + channel_layer = get_channel_layer() + group_name = f"chat_{chat_id}" + async_to_sync(channel_layer.group_send)( + group_name, + {"type": "chat.message", **payload}, + ) diff --git a/apps/chat/services/storage.py b/apps/chat/services/storage.py new file mode 100644 index 0000000..eff1738 --- /dev/null +++ b/apps/chat/services/storage.py @@ -0,0 +1,42 @@ +import io +import mimetypes +from uuid import uuid4 + +from django.conf import settings +from minio import Minio + + +class StorageService: + def __init__(self): + endpoint = getattr(settings, "MINIO_ENDPOINT", None) + if not endpoint: + raise ValueError("MINIO_ENDPOINT is not configured") + + secure = getattr(settings, "MINIO_SECURE", False) + self._client = Minio( + endpoint, + access_key=getattr(settings, "MINIO_ACCESS_KEY", None), + secret_key=getattr(settings, "MINIO_SECRET_KEY", None), + secure=secure, + ) + self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat") + scheme = "https" if secure else "http" + self._base_url = f"{scheme}://{endpoint}" + + def upload_file(self, file, filename: str) -> str: + data = file.read() if hasattr(file, "read") else file + size = len(data) + + content_type, _ = mimetypes.guess_type(filename) + if not content_type: + content_type = "application/octet-stream" + + object_name = f"{uuid4().hex}/{filename}" + self._client.put_object( + self._bucket, + object_name, + io.BytesIO(data), + size, + content_type=content_type, + ) + return f"{self._base_url}/{self._bucket}/{object_name}" diff --git a/apps/chat/tests.py b/apps/chat/tests.py deleted file mode 100644 index 7ce503c..0000000 --- a/apps/chat/tests.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.test import TestCase - -# Create your tests here. diff --git a/apps/chat/tests/__init__.py b/apps/chat/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/tests/test_read_state.py b/apps/chat/tests/test_read_state.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/urls.py b/apps/chat/urls.py new file mode 100644 index 0000000..f332565 --- /dev/null +++ b/apps/chat/urls.py @@ -0,0 +1,5 @@ +from django.urls import path, include + +urlpatterns = [ + path("api/", include("apps.chat.api.urls")), +] diff --git a/apps/chat/views.py b/apps/chat/views.py deleted file mode 100644 index 91ea44a..0000000 --- a/apps/chat/views.py +++ /dev/null @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here. diff --git a/apps/chat/websocket/__init__.py b/apps/chat/websocket/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/core/__pycache__/__init__.cpython-313.pyc b/apps/core/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..461e6fe948f9f694112658e22a255effe501c69f GIT binary patch literal 154 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iQerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%esX?Ms(yTYW?p7Ve7s&kMLI^14(mlnK2qJiBv}hMmAux=))6K-q&iZB|$sG`;*RAfFajhoU?V2zMfTyZ2sUF6 z{jAyd%|@+%QuTCx`+@O3@VW6nv3$l&SS70GB{$bHu4%o!k#d!wD7BDDxHgh7t)n;p)^f}DswhBHz-w_X^~K- zHH!B$%@w8S#vO!hsvPv&=6X6|MJD$WS-9)b{htY}7oID0QWL&#q?%gQ&oq_L z+_aHr)(FSyt##8|?tJ@EJ&DJ7*HZU)YGA{qM+iBG?m6sU1%$jK$M?>5dZ#sk3~H_?%?hMVUn3>;etc(g9}a&p^-}#ns%mlc9wB^tnLZU zR4^0`OwIi_hSFekk(scXTfMVZ@ajEYRZ&&bUavziE^jv{*O>o1@gA)?nQkJvA&fA( zPySG#-^e=oL74ZMFn>q~ep3ft7KE)QCqinMwySDam7_dM8oNO{7t=3DZpe@TWduA% z!Dk)>%!d{Wpq;hCw(aaHkj;#z0L#z5rV~+SDoT%|6P3zBM>~18T^sc~mqmKEB&teB z2{zr4gK)tva$brP$+_)tjtzC;_y^8UYf;RdWzI)AXj9~+EEU@QRE|Vl7%p%wdN|ky zuGp?}u8lBx!i6!AAJ;}|&S3#Jo4Tv)AU@I(bUYS%oQfho1T~R~(edBB)Jnz4SeV%D z7`q*3EUNy2d-Dw3jk;QSbp~pLf>}hFK}bPJ-%soOEiTak=eyx g&AD69N9lg9R}h|kO6eo{{?hj7r>l)uf@#+E22zEG9smFU literal 0 HcmV?d00001 diff --git a/apps/core/__pycache__/models.cpython-313.pyc b/apps/core/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b367e7a9934ceb7101ed63ce873b32eff4d6d542 GIT binary patch literal 5062 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 literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..09157ec7115e103a983584655eeacc4e611cc1ac GIT binary patch literal 163 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i8erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%etLd>WnxZzeqw1!hLL`Jd}dx| mNqoFsLFFwDo80`A(wtPgB37W0AS;VOjE~HWjEqIhKo$Vriztl% literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..be781c94da33110dd6c4758d8b0a7ac043494a86 GIT binary patch literal 425 zcmY+Aze@u#6vvah>kld?E-p@S5eoJK#Zi<(rxuD>q~j5D>0Rpe5?)fF{t<4j?*2;$ zPJ-a36nArXI{1di_x<4WeaS8id&tG(^T}#~5qhhNzwmy=?$n7VG)I8uc61 z+MoK90D@Wv)7GR7ZGzHPzx^=ojaRHl6=BI*7KE!}Dbo!wD^)?33W&`FNtN2Nd;r2& zklYRoS6UdN8NXwxhvzgd94Cs$ zak-z=H$=|e8Qg;JaOKheW>m8yBdstHR11fSZEQA-HK==;2xvB-5UO! zXvR#Iu$*3jS_y6qUC6>HMR~?7Wm+5BJTY#NwDHp0vU|Q($tD*U5IUawcw`P;`t&iz VU!I49?=Hf}ujuR@4PnnsegLcUeXjrj literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..12ea8e5d7ce176e4eee3fe76a391770509779c32 GIT binary patch literal 544 zcmY*WK}#D!6rS1LM2!v6prtLAracII*#$xHAc1JHP$4Z0(o5hm$!<0q)}8gu>_M-B zM{j~Bp~wCkk22t?CvSz+duOuo&^f&Kz4_kv=DjzwX0t)exxU+;{xtr#4PH)d0cP6< z+z>_>Jtki%(hhk;J`v`;Bg`GpUAM|ZC-MB+lMQ^TS9(QpDDrWdj4HaabP>$947ec! zf|L<-7)6&k=rI>-%)@$8^Xq#3ixB5L`^HN(sY*S0Ms@K%n({mmK{O0bMHFW;I7pLT zsl-tlXVFEQ7ljOxxfX;K@BAWte@QO`JNEE~WI5eEeH?Xy>il!u~RS8QGQH4}RNuiEn8WOQ2RVA0z+B>lqmR<99 z-G-bvRgo(7P$}0c>IrT+wx_12UV#f}q@HrAZeKsjr4cSCFw$G*nn?yEMj_gWHXUV^__<_S{2cwaR>d)>~jM z&+JLw0n-img}v?!dkoC4heEx3?A=L0RKsnBsI@yge_`XfRF**2C5Y%UMD++LWpyTf zSUhZ_kD|I#j?ZMf$8aR7tK%xfQTKfJxX_L2vGE8bQKIiyiMX($jYlDkG8d1PN#Mw( z?h&Dx99N)M_&kL^Pv3}ya*)+CaIO}~_wdy112?wF3DdL1HI=6tU@w}mY%H*4t})9F z%!2HXVnn6~}ZJTK}M_e3T~_%_oM#+*(iv zasuQKo+g_<1Ap`(BFZE@*T9IgfE|c*#70IPdUpB|x?`h9L9Hoy^=bCdUPkY+z-;xR zLUB!+UW;+fFkEwy83s=p2Buikp?2CZmYSy19_clV$2RzZW4p}ta9A_j8OYx=3_rjO ztQuwzfL&<@%r^`epk?7J@mmGjj$f#oe%&&i!UOQ;SvBwrU)rwkd4+1-3<_qW;TLM2 zw`@9wXEuZS^+K5Kqm5;*(2{xACTI^kafI)r?Ogx&8hn6l_>h2R&!2}uYEufp5W)Kh zJYriwb;}bEAKslT+RU-I(rnrmR|&^>qNLxS4*8gc6Wb0?Ow>&XsYKPWnHv}=pDcR7 z?3z1;nGk<4bAR^piCLa*k9*&`48p|>)7-S8aDkVpMdy-D;`ky>1t+|J~-GDDl0p%-JT!A53mrMR8#+sY1aW`|cN zURtk4HnMkC?r!G>wsIqzxsey;UmpE7_v_sH%%ko@f#E zPPjSV{#R;Iji1TG>9(cNxgVps6P??kLK>&`YmtNr#L;x%SEBMg;=tkwYuL2+!{AN_*|~ P+_i(wGw%%zh*|#yhP-rr literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..35bb56fc898b5386d1682db8a3a9c52f7feea9d8 GIT binary patch literal 5915 zcmcgwOHdoh86J%uNW46f0fWGJ5ExswK^SANVRya8VllABU=7&LE|u{}Ge~2PX0$yc zFv`nSsmd{xOR6}fIBss_6prMSJ*7t4gQ(s^T$K|yHcl$X{rLLt z|Ns6+_vlSiQ#}KJzx_l1bFqzK{y{qXf8I*t&0}c%o)H+q^%;X)^vPD9ZsdkO`~r6?DpfBBeDav_6I5Tqb`urP)iCR8Y!ab6ht#coB>4mBuxZ% zsX5Vod`93vI{5@geV{`K*7L_vz#t3%&W^HxX#q^62BsBIZG@9pKsYCGLoT6x)Lqjn z9jD0xU-TW{CDDI;ObElSoueMmtqXKJ(aV51FLYD=FPwtmg&rY7ecD^o%0A&@j1~HY zOH|tBTDTY^L?5{ha93)?R`djWfa@C8UpWDNRcIBije5cQ0np{zaRl|=AdQb$Ep36H zR$+)*biJk&PH?3B5Rp1uTN7aJ1~A79Bf?GU)mwnAiJ4EblVYH^+ zB=*NBgYQxgKCER^RXzG~YQ{%3En>k39}9PAK74Wt>aH*j8NmzpD*lR}g5@F39ElUP z7yurfiuk@BZFmQdekx3Y9w*k!Q!3_tVTwlPyLkG?@#6b(<$DLNrs*kqa_w&wW@v@^ ztQMQ#RdUWepwaY0RFGNN>BLhPc6sG|j*9GKm|sq6-z+%DjT z@l;AN%?0CyqGPW`AI`RCQT-3HKf8rKl--+R9*GURS@& zE4WU1nNyKsVy~Gpa!S;Ny-7{dU*NhFQY1@}VWd&YTZX6`tD+>!xGtZQD--_x3GB(| zRe3V%LyfR0u9KBzDX&@BugIz;B{c;%CvUEf_=-XqRA$%Gjh(Y zu373~W>J-Y8oL9hn_S!S%Qh8n0f#lQEF$SF?m<72G{tX_3*KWSXl6k_+b( zifQB#uxG653gY)C29lCVh%AI0(*_{oDh8Q_P@H<7uNnhn6NIR)$^)6TBvR!<>l}2a zCqNNh2j}t5Fm9?)Wh$`RDvV4?W(J&T=)54UD1&_5$gT0J#Y05%>N0O-RFjAQ60a+( z;9;_8FzR(2K;qen7UBhmxKIWJibpnyx*U*0^kJmpkoiK*iAT7^0ZB^(?^Y%obweCo z2Em0h4-I~9dLqu7`CQIG7BJ{d2*WpT6+#JNoL>brFHDUO4D&EXCa37+*{Rv__*=8P zlr&65q-CQq zp)5lQ%uqadl4{lHRMTZCt7>ceN>d56_|zd~1*$HTM_P>j2rrZ!*e_CnqKNB75nd?s zngZ=cQT#G5X=E$toe{-l6`7W%>WXf_K%J_qP7=!sgfnD7N`(|9udKKsPK)p?c{fEK zG7?yTQZq@)96n=^uo@O~$XJ0&fN+Q!5U$fkaZb zM{r<9n0r`3#4{3dn)PV~@>Q}7Wa3bz4es18%Q)!ds6aEuS|20w}|!f|27NYcrY7Mn-Lb4YeG_5|YB zFk%p1R!qv}%vjnm)+9}IXo(%Z+{cE7hOWaINNFC-tsx)CrC;PQHU9?r@*C!LbLiFm zJ&&u!_v*o(p9!2T`@2j2?ww%w>tOqOe>r@m6u$CJ_?pcP>;!u@J}LK(7JEm_y$_1L z5A4UE+iBHCD>k%yS8eX)PB6SaS?(Gtbq#&fHEeS?b`FJXwiLs+Z0>_9%$8j2ird^o z^-y22>l2&1yAwRO-d662mO7%FBik*14gV!CqR zPOxMBR=M+Psq^Y)-_}C0^PbJccYD^HrmNfUM)XD7(Uv+N_H#f1dbLI5rJ zjs9|ExD*-QYO$xY#fWZm#!m3sSIgzWJEg%p+Y7d^2n&<01X&mp=)wh9*uCK^^Ft+m z$R7LLUdq{{Ujpn`u5kkExkq7ffL;CSae3h5(!j_6WSIM|ryc+)p0o!*gBj8Zsve?5 ztQ@&rjNC3qW{Qy+`_ZEPOt$r3{D)x-*EEqx9<=w&={+MGXl&9A4+DN_vLiFl>NQC} zXt{TS7dKYQm&Qt$#Pi=0p8h5jJY1-yys$F}Uw-4CfTmaK1 zTvVgytVR@DV+%yh3qPYY{2XYAY)qE>MoN7nTO(hG0P)oIkRUG3QN%nTzE||@oj(Hs z5vdA@i>(k4*Qx@d{dJW{VY{W+F==!6tE}6P_X`_s<(})Mp6lQA+_1Tk_eg-Aja%j3 z8>QYGTYX=%_LI+xyj+620HRq1}As{U;PX=66`;ZE?t zyHFJJw(z9b^VH^kQf0@fCGDU%ZbB-2XmcNZU&>EbTVS8KLcU+mAad2G1wQJ>;kS;- z!NXGxk)N4y+uH!=r(oxR49L$VJ#7riNqQpFL)bY#$O+|aq!20okDLSl4pO1(f{%F# szV_TMm+Os>aYepm&VS1^{GGY{FW*mHF5mC!%UoZP>-#rDdQ{N=02`2m(f|Me literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..cedc8f414ff3564d006e0a34c5e52f3f03711b9d GIT binary patch literal 4655 zcmdT|Pi)gx7`L5&PMXk?6k1>^rIga3DPc%xtAr3)Ynry9>0+k^sp82^UQ!p=&Yqp2 zGO;0T+Hu-t*PY?C9e3PL+fF0h!PS$dA??JisGD}(d$vXfMP3NgK^iz^&{QNabrrWJGq3K`be`Id0a6e8aTQH|L)XpulVp z1&5hIW`q&_CmAuY94H)V0zEG;`p%_#8dY z=8*~CzASsal{|!KKe(FRJl^ZA=Ii0C1@C? zeg|qeT2d9LnLxj*L6r34z=|qscd%cpt11rb8X)NvG$H0pJ?SH<7frW-Ca*&>g+2on z1*&E2uNx5E8#p&E*J?nOSD>0Sz`5~~DjP=9NCr@hFhexoLnMi0a1=pRHeuO@LNx0l zv}!d~DcOk~4v-8_Gn1S>+a6E)ocb|}VJ`?NWyc0yDFfFU$Fyb(!3u)LsxuaITkc@L zq1RCfg1x=R-6mny3iXI2p(im9Y`Os82mnNhx(Z1>3c!bTS*2=_Rxbb*1sSHQXi(Eh zN63{O`Kgip#$95jyh2u%VGU|!sFgMl&3SQerYL1WQ4|W`Mt(}j;ppxR1sa&ol*`wt z)`(`^IC5P_P^oHja03U+YqD0=F;`x}yzMWN8{^PIAukD&5)Msf#L3K*fY0RV7MB1S zSe%(6`(zdtax;?|X(m7K?itH#P}0f6-U<)BxMF4sNclOTIh_{;3CtIBIXv>>%*^DZ zAc}itpM7yoLAYKJ#O$6)5xoYR+5Is943iv)5|B+3DJyjo8UUcvM1URg&)^TzMRM+p z^r~#EmSr`)fb=z3GL7_-q8YlLF0IODnh0*B_XI@x`8S9(35`q5&`8xbP>A5#4;o9x z=frDYFi(!eA53otnec@zK5p^nH~I4qd29SyWBl50d}ce~>kmH2ZAY1K!s1VD@~5^2 zMz@Zh-ii-e@w1!pvkynVlYUtI>DG_8ETPyCik5J@A>6ivN<*kTzGM8uFe*F6J=-l3 z0{1NB4@b5mdl3f?gt*NxAF+9k6cV@05pao9DY#oC*8c>87E|Kv*D^MBl zvqj3wEfR!Xn~#R~@H&9kyIM*RTWIcs_>x{fWjkhUGd}iE`riL@@YnD!VJmyHk-ceU z414BxX7~?g{IB2~%Lc!SSbd|7zR|xK+Okpp1>Rh4 A^8f$< literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ae77f9216d952418e4ecf66c86e026c22c198ce0 GIT binary patch literal 1121 zcmZuv&ubGw6rSBpHrZ{0HMLm8VhBZ8ZMvo6p+O3zMQN*5yNH*-Fxj0ZQ#QMCW@1ea zA_yMEORwI&`LEbZ%U(V8mKw#YZ#E50!G)cD-}|-ioA(WiMH|8M`Rm<-?>a(16&OF+ z3uCwj#u4(6r|qC^jceOF*UM-QRgkCOLY`6A_RMk7<@cd6vboXBIN37=96hnytjURF zaU>G69S1ZN{$&k9)&&?gz&Jv6#5E6b-P5??>8{2z_3U0@vS4JxD2A7*=k})m7ju!9 zeW`h-ms{1n{Hg(cEYIS0({`p3>)CjsGsOaCEH>k~M?xHvu57I&h2`bt70ecEPbnmGDkJ8spF#}GB8g?MLmku6`=u+@(zbIYRZ;l08(4W5oyyTlb+dqM#JD6+J$Z8aT>umu`!0s zZb-o{VEm>_LX{2Z8pivK3mLMAMzIP96b6CWeMSXWx=ZZ!P*VO_HRc)Rur^v`gcBQB zj5Romv5>Gm9}_7#Yjhu8N!Pa>An(_3%5l?D5D&hhjtT87b$VPWF+G$ap1|=54NPR+8yNZx^TX4DjjWlI z{QPl#{`BU;>Gk`krMZ(*<+xP&h0x;r%mAUC?6d4SLNmMB^IXQV2ZmWPKW_6os3 literal 0 HcmV?d00001 diff --git a/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2cf833faac6f16a17dbfb9b751fd3e602a072261 GIT binary patch literal 174 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iaerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%etLd>WnxZzeqw1!hLL`5W_nR# xNoIatv3`7fW?p7Ve7s&k5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iAerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%ekoX8e0*kJW=VX!UP0w84x8Nk bl+v73yCPPgQ6Q^|L5z>gjEsy$%s>_ZYg8qA literal 0 HcmV?d00001 diff --git a/apps/users/__pycache__/admin.cpython-313.pyc b/apps/users/__pycache__/admin.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad9d3ce1e9b52458b1e5ad958ef547fabd9d2929 GIT binary patch literal 330 zcmey&%ge<81by?=vciD$V-N=hn4yf%K|sb-hG2$ZMsEf$#v(=qhG3>(W^X1h<|1YV zh8V^m4wx1M9mI`b!&ouQri>7_0z*2BChJQON0aduYhp@nX5LGX9FWHnTAW(sr^$AU zFC{B6FFju`IX|zYC^M-Dx%nxnImJb+AP!4$W=ZNTj-u4`%;J*NqLmDvfuanz z%=JM=6zgXs7H6a+=I93$=j$hDB$ntW78Dfgmx9%St=B84yv1Py zG`%z@)vkyaXb#9d#qvPn12ZEd<6Q=Yy9^Q^*cceu+PNCJt}_T!~;msS~Ox|iWkc!9G&gfayFWpR|G68uL23~ox|Bxz$>L0xmh|vH5 literal 0 HcmV?d00001 diff --git a/apps/users/__pycache__/models.cpython-313.pyc b/apps/users/__pycache__/models.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1616f7faf69c106a348a6d2bb21d458e3d685eae GIT binary patch literal 3994 zcmaJEO>h(Ebyr$RE3NFcB>xCwU?E^Cek3Ru=~MK7C{SsE_y*#QkIbQGo~y z7sdjkK@wzmU@SB$5|QD-vG8bwL>L|#lSZQ?%5ZV4Wwe#Z4Clw%NEJ(;!5sd9mF*?P(NrEZx8-7Vu@uml?xDiU3@WGs|z z12q)k0OP)^S^C2Kly@@^`f4xRX+R!uIgaouM|>(z{Hl)#s;COx+$>K5YM`4VK{W_4 zq=txC^rym9YP#rF3^YuRPt`S|Vsw%Of7^co@PNy56Y$)-z-WGz&+_N^tZ$gV#M}Ol z8#*S0z1$oaxvxgd`mLIoIWFs;fDqZc;6xTeyXmTLfLB1`IF}N}sc-H!m?%(jzysfK zsUQt78xj$OsNXK%qY_xx&}Pe~Z4{{B=ywh3*9!TR0566$O<+=iU^Y~l9{EAqLA6IJ=#qd~xygck?j`D*ZQgMYuW zBs>+98)Edc@bAJ8g|EcFic8@Y@hC_wN!y@++uwr(cEE$a2_AWNFO^q)eKntfUH9jK zcxvL|CK^88(FKtZP=%dH0RIHXsc1TyUKZBFi_Pia+0Z zZ+#MYDqeaiC)Qhqv<|qyUf#pOySQ0GC{|pGY3PAn1 zVO^E)tKkNp6($;S?(rg2eX767qWkj*dwA+)b4Ly6sh_0!Z}Ix>TLkKR>=ye;3w4ft zq=WT6kme?WO^4P{@VNL<6ER#}^Jy&KOG3yldDBx*DsTQmQzvk~b9a zG&v0NljI1%g=1kuXY%9hMLdt|Fa6I)xY4sv5;6>bVppp{^dJ(YLL%Tuj)uXSi7G#Z3rak+F zU}IMSY3C0B7P)`PT`%H2)k8yT@hjEnm6uInIs8;S-yD{Z=6x@vu660)igfVd=$dqL z*EsR|vh z1tHM>->49{{3aR=Kr3TH$iJ%*A_3R-mn!a#ro zK^802o^_1Ng|XPx(?boap#dL>XckG!>babMeDS4P@>^q z%YaB)Krh;0&#K+2wJxE`Ov0zO!S9gYDidqS;2H4&KWTd_f1=Kxi%np<>{)6%*dmc@ zZ?Z*)+5v+{r`k!nie0HB6)<16G9WlPk_u31j+iB#%xkxec`6zO)7A5qK|{5UK!X+A zyi+l#Sja=L6^#2VH@6l+9$J5-q~F&p!!Eir1h2F8X8;Q$6ULp2NsK~TIp23qfewu$ z7&LLEOq6Nko8fa46gPzQK(=O+MA{iGPw{#EJVvhbwxUEzn>?`NyO{-io%tw^QC;SITC zT|TlZA9>6_I`wtSx9_aTR~FxUcHsQlfeTfkv$6e53@@Gfv}Gd!q{Op$qS~HYi+{4w z-djzad>L*3La!#TuXPT;^81pJS3yorY_!Dw(;>A57vFo;%_R=3$9q@fy^rrKA6h%v z|J}783$;7MON(P0v99&liPhMN$NF;g>+-jgKME_c4;Du^;>q1&$sc;Y?^%hDFOL1Y z^}wg4#hY&gK5*9i{rJ;UT~s8Htp+3z4C}MHT`Z@k%JwwD7^R1HDA+*K5dAahJbY%c znVMiE((e6uyeXVu_oj#UV`wgwwhHAE4MKp!mkABxjW1oKA^e80%)Hm~nCEv#0~H6o?t6v&1{7hoI2bvv z{5;P;=j2UJ+2jT`xeJ@z^-Zp4lRLV}U3$(*&$*WR|JK*yL4KD1JoB2v=j*{Fe|Bl` K4F?Zq_5T6(lcNIw literal 0 HcmV?d00001 diff --git a/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b1eb08b14ba40211a0ec5218f4172a057a16c478 GIT binary patch literal 3753 zcmaJEOHdohbu=>)lK4sZ3kWf`@keVjf;HZa{7J2C>|MZi)@oqA3FX<5rX|`Q&B#3? zW0Xqbs#NVUmCL5cCGj;kAIT}?v_4A8K~(#&Ta^TI`im>pG$dA;Dq>YTYnX-smggd~U9@X9 zN~4-|TskQsWk^EmQdfRpN9gW)NeT8eRt{ytTO7ETky*S#%GC6U>v$|uaV>0n#a$*z z8tMRVec=C2S`rpf8HK%$y^kyIR)07+fNBumC+b{r_q_53Up^bKqsSfC?oRv(fg>tmyh;q z$zM1Cr|-w1^XP(z-8jgf*ncjp)ln8rph+~2J{GyS_yO7}L3;^(63}M*fb&zx<4<%2 z<(3BXQ~Pz~69?cv-H+Re@stcvzMNB`t5WC+9VuVUTw}4jJ3YF^$HzRht!mc%Wp;XE zV)A3dw0%sCng>56LOtJb%nCLt<}<^_P8rD51vq0p#525F6;n=myZ+@g_DI<=ee7xe zvgyMQ@icy>Et{TZ+O`I?72QAT;g-2nZK!(|HVI z>YY23j~W)U&5oyau@&?zj4LLwxBkoJ^oqS^S{B8g$D-VQ-z=5txjmk*EMqX9at{LE zi>9O5ZdoIa?*<_;tqO58kN8-x_Xr6c_gD->nnj;H#dUn3%$SE6efjAjOm_v67ySZ%RkxLwG)diph0K}2!ArKcSQ@2+2>gGJuR3p3MV zO9weqn0M?Hi&&3Lr|hz>qU-pS6lfF@dbwzLCZp?{T6o$oApjZ(&kA82cp9@ zpV%l8f%x)n`tCXfA? z9DQ-LePXJ0VrqS1TatbjGG$n*@(tyUB%Qpe0FazkHj^W3Puru})@b&nwc$0s#?8@E zBT?Q=rq)XBwBAbVFXuOkjR(bM+G-^5X7c#jnfB26*3kKvV;dJ63s!RoHxi}IJ zdt{OHM9+n8b9D=HQ3iEsivvk4cf?M%(v$0Bf{gN$>>bYIIguH3wE5TRYkw z%(MnGFQ+yRe?Rp1p~m7b8l=)#{WXw(A>ZLrhwcig_du#pKHeG`UqAHfa&u&{k@#v4 z^5yiabB+1m0OxGzI_J47FK|r-1@pyLs(e{he#MtL^}hNazI?5M4!#KJVE{c0AQC_% zfNq2Z<&7{TeombZgOpkI7GLJW5BXWz;6U=5@MbdoBGT5TTH4gQ^@=tgEHxfKYig^F zM7@)Vi>=X%>th@9jR#h9G>Ggj%i`ab{<_q@mTz9mx34WWuPrt(ZE9X4;cq4f*FI?v zPqv09*HzFp6SBFZzm#urpMI`g}oNcGZo2l`3D%VWq{*k)UNL+tassxw~$2q9Yw5(ct<{e+L63);Y5ZVlIhM6!aAD*1!hDg+#9g zWACT*0e~U+JpNSQRzjiB+lUkzd@Y@REu~&d-9Jd@{uLPuMSdS|C&rqIv42Yd2uc3~ DU~T`2 literal 0 HcmV?d00001 diff --git a/apps/users/migrations/__pycache__/__init__.cpython-313.pyc b/apps/users/migrations/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d0cbd8bbecf75c84cc7ec80d9dc30d70195877d2 GIT binary patch literal 166 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iWerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%ekoX8Zf1H>Vo7FxUa@|Bd}dx| mNqoFsLFFwDo80`A(wtPgB37WOAX|$;jE~HWjEqIhKo$Uc>?!d8 literal 0 HcmV?d00001 diff --git a/env.sample b/env.sample index 3a63c12..1dd3033 100644 --- a/env.sample +++ b/env.sample @@ -13,4 +13,14 @@ REDIS_BASE_URL=redis://127.0.0.1:6379/1 MATTERMOST_TOKEN=3fktq5yzw7yp7m4ty7d3nx8gph MATTERMOST_TOKEN_ID=h5h7unnmeigxdjk4yhtywxjkow -MATTERMOST_BASE_PUBLICE_URL=https://chat.addwin.ir \ No newline at end of file +MATTERMOST_BASE_PUBLICE_URL=https://chat.addwin.ir +MATTERMOST_BASE_PUBLIC_URL=https://chat.addwin.ir +MATTERMOST_TEAM_ID= +MATTERMOST_SERVICE_USERNAME=chat-service +MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid +CHAT_LONG_POLL_TIMEOUT_SECONDS=25 +CHAT_LONG_POLL_INTERVAL_SECONDS=1 +MINIO_ENDPOINT=localhost:9000 +MINIO_ACCESS_KEY=minioadmin +MINIO_SECRET_KEY=minioadmin +MINIO_BUCKET_CHAT=chat diff --git a/main/__pycache__/__init__.cpython-313.pyc b/main/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..fbaf534d8b18124c1ef92c2ae44483a2e0482c98 GIT binary patch literal 149 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h}erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6ZenJhetdjpUS>&ryk0@&Ee@O9{FKt1 YRJ$Tppdlbjib0Hz%#4hTMa)1J0ABbb1ONa4 literal 0 HcmV?d00001 diff --git a/main/__pycache__/settings.cpython-313.pyc b/main/__pycache__/settings.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1e0345afa3c8e93723c97367f34cbcddd286be9b GIT binary patch literal 7201 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 literal 0 HcmV?d00001 diff --git a/main/asgi.py b/main/asgi.py index 04b4e6d..31b565c 100644 --- a/main/asgi.py +++ b/main/asgi.py @@ -1,16 +1,27 @@ """ -ASGI config for main project. +ASGI config for chat project. It exposes the ASGI callable as a module-level variable named ``application``. - -For more information on this file, see -https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/ """ 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') -application = get_asgi_application() +django_asgi_application = get_asgi_application() + + +async def application(scope, receive, send): + if scope["type"] == "http": + await django_asgi_application(scope, receive, send) + return + + if scope["type"] == "websocket": + await websocket_application(scope, receive, send) + return + + raise ValueError(f"Unsupported ASGI scope type: {scope['type']}") diff --git a/main/other_settings/__pycache__/__init__.cpython-313.pyc b/main/other_settings/__pycache__/__init__.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b79ae16b09a404bc0fa80aab9597a5094400564e GIT binary patch literal 164 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~ieerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6ZenJhett9e0*kJ nW=VX!UP0w84x8Nkl+v73yCPPgnIJohL5z>gjEsy$%s>_Z8vH3V literal 0 HcmV?d00001 diff --git a/main/other_settings/__pycache__/logging.cpython-313.pyc b/main/other_settings/__pycache__/logging.cpython-313.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a67518bf6a7bd7312d7f7e6102d78c590cc0ae07 GIT binary patch literal 2149 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 Z!z^=rwR@L9w;N>{{@xE>0?%8X{0A!-aFzf7 literal 0 HcmV?d00001 -- 2.45.3 From dcb18736081d179936f456aeea62a5f9eeec1f1e Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sat, 13 Jun 2026 13:43:29 +0330 Subject: [PATCH 2/9] 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 -- 2.45.3 From 38640083414ba6c1b914e500cf76fdd04923b968 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Mon, 15 Jun 2026 13:06:31 +0330 Subject: [PATCH 3/9] update requirements --- requirements.in | 4 +++- requirements.txt | 32 +++++++++++++++++++++++++++++++- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/requirements.in b/requirements.in index fb41f7e..109e9de 100644 --- a/requirements.in +++ b/requirements.in @@ -23,4 +23,6 @@ django_admin_logs mattermostdriver channels channels-redis -minio \ No newline at end of file +minio +pytest +pytest-django \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index eed64ab..a4ea010 100644 --- a/requirements.txt +++ b/requirements.txt @@ -8,6 +8,10 @@ amqp==5.3.1 # via kombu anyio==4.13.0 # via httpx +argon2-cffi==25.1.0 + # via minio +argon2-cffi-bindings==25.1.0 + # via argon2-cffi asgiref==3.11.1 # via # django @@ -26,7 +30,15 @@ certifi==2026.4.22 # httpx # requests cffi==2.0.0 - # via cryptography + # via + # argon2-cffi-bindings + # cryptography +channels==4.3.2 + # via + # -r requirements.in + # channels-redis +channels-redis==4.3.0 + # via -r requirements.in charset-normalizer==3.4.7 # via requests click==8.3.3 @@ -99,6 +111,8 @@ idna==3.13 # requests inflection==0.5.1 # via drf-spectacular +iniconfig==2.3.0 + # via pytest jalali-core==1.0.0 # via jdatetime jdatetime==5.2.0 @@ -113,6 +127,10 @@ kombu==5.6.2 # via celery mattermostdriver==7.3.2 # via -r requirements.in +minio==7.2.20 + # via -r requirements.in +msgpack==1.1.2 + # via channels-redis oauthlib==3.3.1 # via django-oauth-toolkit packaging==26.2 @@ -121,6 +139,8 @@ packaging==26.2 # kombu pillow==12.2.0 # via -r requirements.in +pluggy==1.6.0 + # via pytest prompt-toolkit==3.0.52 # via click-repl psycopg[binary,pool]==3.3.4 @@ -131,6 +151,16 @@ psycopg-pool==3.3.1 # via psycopg pycparser==3.0 # via cffi +pycryptodome==3.23.0 + # via minio +pygments==2.20.0 + # via pytest +pytest==9.0.3 + # via + # -r requirements.in + # pytest-django +pytest-django==4.12.0 + # via -r requirements.in python-dateutil==2.9.0.post0 # via celery python-decouple==3.8 -- 2.45.3 From aa821ac468a358fc1f7267d45e41fd1902f09c3c Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Mon, 15 Jun 2026 13:21:21 +0330 Subject: [PATCH 4/9] restruct the apps --- .../api/__pycache__/__init__.cpython-313.pyc | Bin 158 -> 0 bytes apps/chat/api/__pycache__/urls.cpython-313.pyc | Bin 1046 -> 0 bytes apps/chat/api/urls.py | 16 ---------------- apps/chat/api/views/__init__.py | 0 .../views/__pycache__/__init__.cpython-313.pyc | Bin 164 -> 0 bytes .../__pycache__/conversations.cpython-313.pyc | Bin 4321 -> 0 bytes .../views/__pycache__/messages.cpython-313.pyc | Bin 3089 -> 0 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 3016 -> 0 bytes apps/chat/urls.py | 15 +++++++++++++-- apps/chat/{api => views}/__init__.py | 0 apps/chat/{api => }/views/conversations.py | 0 apps/chat/{api => }/views/messages.py | 0 apps/chat/{api => }/views/read_state.py | 0 13 files changed, 13 insertions(+), 18 deletions(-) delete mode 100644 apps/chat/api/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/api/__pycache__/urls.cpython-313.pyc delete mode 100644 apps/chat/api/urls.py delete mode 100644 apps/chat/api/views/__init__.py delete mode 100644 apps/chat/api/views/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/api/views/__pycache__/conversations.cpython-313.pyc delete mode 100644 apps/chat/api/views/__pycache__/messages.cpython-313.pyc delete mode 100644 apps/chat/api/views/__pycache__/read_state.cpython-313.pyc rename apps/chat/{api => views}/__init__.py (100%) rename apps/chat/{api => }/views/conversations.py (100%) rename apps/chat/{api => }/views/messages.py (100%) rename apps/chat/{api => }/views/read_state.py (100%) diff --git a/apps/chat/api/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6a8d32fd6cc4544699d91fafe2f730380011d6ea..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 158 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iYerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMjFqV$AD@|*SrQ+wS5SG2!zMRB br8Fniu80+A8pyU{5aS~=BO_xGGmr%UyzwRx diff --git a/apps/chat/api/__pycache__/urls.cpython-313.pyc b/apps/chat/api/__pycache__/urls.cpython-313.pyc deleted file mode 100644 index c46a4d27433942442f5059131669c8993066531c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1046 zcmaiz&ui0A9Kc_i*ECty&QXz}Wz2!Lp|oMh4%5<2hEP%!x8im%P~Mu*rnE_UFRd65 z@#e{!caQTw@VKKFp+64AfOzoaZ8F5G@4c>dheF>W@B8EP{rSj6JiY|z!6#btYXpFw zOqm|;p~^){0N?|tfQnEChJb|)CP&H({i^ zUu+6Z2w?~L4J{Jh*!SF?jS2D{&)vs1^6ewXei^PkCpNz7d+ZROd1$Qq#3lsQIY?6^ z_8Z85)U#cm@X+|7jjW1)nJJKJ)J^n2Wp}Q|ORxr9)V70LU0R0dg>Kie?lZaSSVjFZ zBEbwf9@e7OgY}7h0yCMz?MXAVLY$*hJ-9g+r}Zs8P&mruAT<}W`8?CEC)!hOIaIAC za_e5ci(3SXbeG|6)aj5sOOi(&C*PwxqUx2c5)3JaqYO^XgQp#1i6QIr5Y}mxu$|%< zOC~cihDeo_3su?;eNXCi$tQ3@eIVS?*$WcAfk?wbE&Unxns$vo#IfTN&*MARS*fYy z=xnBZ=T#^d+Md;I*+rbBndmPC$b0%Z7X?B14pM)iC`A6qK-m5PO1~u#Ro<=jliE1B zHB4@elJ`F62C(!E%6;W-KeIQ^l!lqoC}Z>w&;XuryLS3uoGlKs#Zh+e?9BikbL*Xt tugB@aFkKj>i)Swe@PJ#>r|V;Fcc|@-w1;2f19)_/chats/", UserConversationListView.as_view(), name="user-chat-list"), - path("chats//messages/", MessageView.as_view(), name="chat-messages"), - path("chats//read/", ReadStateView.as_view(), name="chat-read"), - path("chats//events/", ChatEventsView.as_view(), name="chat-events"), -] diff --git a/apps/chat/api/views/__init__.py b/apps/chat/api/views/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc b/apps/chat/api/views/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index e8241c33827ad5f1d7620663bb6a1e9894cc35f6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 164 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~ieerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMjFqWhmYG^!tREkrnU`4-AFo$X hd5gm)H$SB`C)KWq6=){N&SDVbBQql-V-Yiu1pwFND0Bb- diff --git a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc deleted file mode 100644 index e9bbfe9f671b6e20c0e11af040ef09040de5d635..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4321 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 diff --git a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc deleted file mode 100644 index a2e5ead992400a712bac68c4359b0ebe378ba221..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 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 diff --git a/apps/chat/urls.py b/apps/chat/urls.py index f332565..f024578 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -1,5 +1,16 @@ -from django.urls import path, include +from django.urls import path + +from apps.chat.views.conversations import ( + ConversationCreateView, + UserConversationListView, +) +from apps.chat.views.messages import MessageView +from apps.chat.views.read_state import ChatEventsView, ReadStateView urlpatterns = [ - path("api/", include("apps.chat.api.urls")), + path("api/chats/", ConversationCreateView.as_view(), name="chat-create"), + path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), + path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), + path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), ] diff --git a/apps/chat/api/__init__.py b/apps/chat/views/__init__.py similarity index 100% rename from apps/chat/api/__init__.py rename to apps/chat/views/__init__.py diff --git a/apps/chat/api/views/conversations.py b/apps/chat/views/conversations.py similarity index 100% rename from apps/chat/api/views/conversations.py rename to apps/chat/views/conversations.py diff --git a/apps/chat/api/views/messages.py b/apps/chat/views/messages.py similarity index 100% rename from apps/chat/api/views/messages.py rename to apps/chat/views/messages.py diff --git a/apps/chat/api/views/read_state.py b/apps/chat/views/read_state.py similarity index 100% rename from apps/chat/api/views/read_state.py rename to apps/chat/views/read_state.py -- 2.45.3 From c8b4e67567bd3150c93cd5bccd66fa617bea9218 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Mon, 15 Jun 2026 15:22:17 +0330 Subject: [PATCH 5/9] debug --- .gitignore | 4 ++- .../chat/__pycache__/__init__.cpython-313.pyc | Bin 154 -> 0 bytes apps/chat/__pycache__/admin.cpython-313.pyc | Bin 198 -> 0 bytes apps/chat/__pycache__/apps.cpython-313.pyc | Bin 519 -> 0 bytes apps/chat/__pycache__/urls.cpython-313.pyc | Bin 307 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 161 -> 0 bytes .../events/__pycache__/event.cpython-313.pyc | Bin 664 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 172 -> 0 bytes .../__pycache__/longpoll.cpython-313.pyc | Bin 1461 -> 0 bytes .../__pycache__/push.cpython-313.pyc | Bin 644 -> 0 bytes .../__pycache__/websocket.cpython-313.pyc | Bin 1228 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 167 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 178 -> 0 bytes .../__pycache__/client.cpython-313.pyc | Bin 8951 -> 0 bytes .../__pycache__/exceptions.cpython-313.pyc | Bin 487 -> 0 bytes apps/chat/integrations/mattermost/client.py | 23 +++++++++++++++--- .../__pycache__/__init__.cpython-313.pyc | Bin 165 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 533 -> 0 bytes .../__pycache__/conversation.cpython-313.pyc | Bin 1901 -> 0 bytes .../__pycache__/mapping.cpython-313.pyc | Bin 1456 -> 0 bytes .../__pycache__/participant.cpython-313.pyc | Bin 1770 -> 0 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 1897 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 166 -> 0 bytes .../__pycache__/conversations.cpython-313.pyc | Bin 2721 -> 0 bytes .../__pycache__/messages.cpython-313.pyc | Bin 1523 -> 0 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 1062 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 163 -> 0 bytes .../__pycache__/account.cpython-313.pyc | Bin 630 -> 0 bytes .../__pycache__/conversation.cpython-313.pyc | Bin 3513 -> 0 bytes .../__pycache__/message.cpython-313.pyc | Bin 5439 -> 0 bytes .../__pycache__/read_state.cpython-313.pyc | Bin 2426 -> 0 bytes .../__pycache__/storage.cpython-313.pyc | Bin 2290 -> 0 bytes apps/chat/services/message.py | 8 +++++- .../core/__pycache__/__init__.cpython-313.pyc | Bin 154 -> 0 bytes apps/core/__pycache__/admin.cpython-313.pyc | Bin 696 -> 0 bytes apps/core/__pycache__/apps.cpython-313.pyc | Bin 519 -> 0 bytes apps/core/__pycache__/models.cpython-313.pyc | Bin 5062 -> 0 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 1713 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 165 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 163 -> 0 bytes .../__pycache__/admin.cpython-313.pyc | Bin 425 -> 0 bytes .../__pycache__/apps.cpython-313.pyc | Bin 544 -> 0 bytes .../__pycache__/models.cpython-313.pyc | Bin 3096 -> 0 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 5915 -> 0 bytes .../__pycache__/0002_initial.cpython-313.pyc | Bin 4655 -> 0 bytes ...n_authorization_grant_type.cpython-313.pyc | Bin 1121 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 174 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 155 -> 0 bytes apps/users/__pycache__/admin.cpython-313.pyc | Bin 330 -> 0 bytes apps/users/__pycache__/apps.cpython-313.pyc | Bin 522 -> 0 bytes apps/users/__pycache__/models.cpython-313.pyc | Bin 3994 -> 0 bytes .../__pycache__/0001_initial.cpython-313.pyc | Bin 3753 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 166 -> 0 bytes main/__pycache__/__init__.cpython-313.pyc | Bin 149 -> 0 bytes main/__pycache__/settings.cpython-313.pyc | Bin 7477 -> 0 bytes main/__pycache__/urls.cpython-313.pyc | Bin 2090 -> 0 bytes .../__pycache__/__init__.cpython-313.pyc | Bin 164 -> 0 bytes .../__pycache__/logging.cpython-313.pyc | Bin 2149 -> 0 bytes utils/__pycache__/exceptions.cpython-313.pyc | Bin 3903 -> 0 bytes 59 files changed, 30 insertions(+), 5 deletions(-) delete mode 100644 apps/chat/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/__pycache__/admin.cpython-313.pyc delete mode 100644 apps/chat/__pycache__/apps.cpython-313.pyc delete mode 100644 apps/chat/__pycache__/urls.cpython-313.pyc delete mode 100644 apps/chat/events/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/events/__pycache__/event.cpython-313.pyc delete mode 100644 apps/chat/events/publishers/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc delete mode 100644 apps/chat/events/publishers/__pycache__/push.cpython-313.pyc delete mode 100644 apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc delete mode 100644 apps/chat/integrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/integrations/mattermost/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc delete mode 100644 apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc delete mode 100644 apps/chat/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/models/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/models/__pycache__/conversation.cpython-313.pyc delete mode 100644 apps/chat/models/__pycache__/mapping.cpython-313.pyc delete mode 100644 apps/chat/models/__pycache__/participant.cpython-313.pyc delete mode 100644 apps/chat/models/__pycache__/read_state.cpython-313.pyc delete mode 100644 apps/chat/serializers/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/serializers/__pycache__/conversations.cpython-313.pyc delete mode 100644 apps/chat/serializers/__pycache__/messages.cpython-313.pyc delete mode 100644 apps/chat/serializers/__pycache__/read_state.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/account.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/conversation.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/message.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/read_state.cpython-313.pyc delete mode 100644 apps/chat/services/__pycache__/storage.cpython-313.pyc delete mode 100644 apps/core/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/core/__pycache__/admin.cpython-313.pyc delete mode 100644 apps/core/__pycache__/apps.cpython-313.pyc delete mode 100644 apps/core/__pycache__/models.cpython-313.pyc delete mode 100644 apps/core/migrations/__pycache__/0001_initial.cpython-313.pyc delete mode 100644 apps/core/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/__pycache__/models.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc delete mode 100644 apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/users/__pycache__/__init__.cpython-313.pyc delete mode 100644 apps/users/__pycache__/admin.cpython-313.pyc delete mode 100644 apps/users/__pycache__/apps.cpython-313.pyc delete mode 100644 apps/users/__pycache__/models.cpython-313.pyc delete mode 100644 apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc delete mode 100644 apps/users/migrations/__pycache__/__init__.cpython-313.pyc delete mode 100644 main/__pycache__/__init__.cpython-313.pyc delete mode 100644 main/__pycache__/settings.cpython-313.pyc delete mode 100644 main/__pycache__/urls.cpython-313.pyc delete mode 100644 main/other_settings/__pycache__/__init__.cpython-313.pyc delete mode 100644 main/other_settings/__pycache__/logging.cpython-313.pyc delete mode 100644 utils/__pycache__/exceptions.cpython-313.pyc diff --git a/.gitignore b/.gitignore index 3c2dd46..ebd5341 100644 --- a/.gitignore +++ b/.gitignore @@ -5,5 +5,7 @@ media /delme.py /log/accounts.log /log/errors.log -__pycache__ +**/__pycache__ +**/*.pyc +**/*.pyo .claude \ No newline at end of file diff --git a/apps/chat/__pycache__/__init__.cpython-313.pyc b/apps/chat/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 9c11b9024ddf8ea2abe5e8893d5578a6afde19c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iQerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgcTp3nU`4-AFo$Xd5gm)H$SB` ZC)KWq6=)L3reYA|BQql-V-Yiu1pqaNB#SA6lGRRIHzoSe%iPn4=$1 zl%JKFTvDtbo|#vipRb>skyxUiSWr+5Wr57pE2zB1VUwGmQks)$SHuA{h7pL1L5vT~ OjEs!;7!-=wfE)m2Ju!p; diff --git a/apps/chat/__pycache__/apps.cpython-313.pyc b/apps/chat/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index f270aaf6023ac3e51b179b5e9994c578440544e0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 519 zcmYLFJxjzu5Zz63AD$kHsHiBYg_0(z4irT20}F*iSj0wH)=MszBbUV4O`(;QwHE#e ze~*nUSXpT$oc7LMPTXSV&AvCoycsr|4T7)!xz&Hf{BDZBR~duhCW0rz2&1RugaTb7 zi)4c^Ynd?nlD6zp2Ug^`l@C+6R8Q>Z`C*oJVSwHM3>0iXD%rf6B?i&mL$jfiQ zoOu+CN7=-1PhKY%?v7rUwqKkDnEvD2uB|7>zRegghcDaB5~Xkkk*@EScK2WwOLKEM XhvCbnl)jPm@2W+29+rLxrcu`~#b}2P diff --git a/apps/chat/__pycache__/urls.cpython-313.pyc b/apps/chat/__pycache__/urls.cpython-313.pyc deleted file mode 100644 index dee0d90f72713f365e38964e3572a8c6826b9f65..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 307 zcmey&%ge<81Z}~ZSy@2(F^B^LY*5B$6Ch(MLoh=yqc?*WV-celQxTH_Lky!5Loibe z6Od#!WrV0TWrEQ9j77}pESjt@L0nCyTPy{MB^kHaGxL&jN>fs+SP}~|^{a#u3kr($ zk~0!Z^niT5(xRMVKTVcf+$mX!dFlCJei1WJe-R6ixWx^W2bxlnT9jA3lHoJR;9I8p zp~b01#rheE#ThAyIr;%b`B|ySCB^#TnR&(e`T8KU^+C2kSRfnp3My}L*yQG?l;)(` z6@feta(b~MkodsN$jEq~LG>dW11o2T*hKLQlDd}#^)9pMe_&?fXKLgw;sB}w0Q;~_ Ao&W#< diff --git a/apps/chat/events/__pycache__/__init__.cpython-313.pyc b/apps/chat/events/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index fb5fe1e52d6df90a593614451dd6be21590a59a9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 161 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h@erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq2#Bng^7OkI&4@EQycTE2zB1 fVUwGmQks)$SHuc55M*I7i1Cq`k&&^88OQ_hktzJ-^eVL|Y;w~-vYTAgIKp{u^Hs=n&3?#B1G2rlt4IRA(n~2rF}$VdY3#Pk$p%a=h*5r$GucKGdJ+A3=60prCjkO=Kx5Y>GaLZ zq_1n8-IL}uf(sH8Nh2byh)VkriJjPutjCy%Jw$ekOw757xh)!C%rht&$l+`ljP%|_ z0PvZJM4?`NMRR2)(}QHr70XgRs7h$T2t<(zX<|EzPQ%o)5?H~PdCAisKfccKM$uMB!qJg^OWb|ds&`~L_zo=E1)bxGfv2>3OXdrq!705 zNwt(7_Gpb69`2#}PJa9QtI68mTfJTH-dep|AB=xF*9wMc<;USuhO)0%3_b>P{wB#AY&>+I)f&o-%5reCLr%KNa~iierR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq2#Bng^6DC{4=AEY1LFjE~RE o%PfhH*DI*J#bJ}1pHiBWYFESxG#g}hF^KVznURsPh#ANN0C&eL>Hq)$ diff --git a/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/longpoll.cpython-313.pyc deleted file mode 100644 index 7c86df23edcaf9138fc33d4e6c83a6569a665433..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1461 zcmZWp&2Jk;6rcU_+VKY_CZT+UF2zMSYHivkl%N)g4@;Ie)@0oX()GNl@#LdrGij7()iV5O#2$|Z; z$V|+_X@Y2?EtT}A0xpy!{z97sn39&waMN>L(hO|Rh5dY;FlM!hN!*}%6_|ZQLZ~U+ zBKkL-H(_TJT|`te5tU7eDyDoKJfA_`AX67;CM&>W>Z43+|}z&H2?a@a)6z(X>n*%5YSQ*KC0|MeQ#^l zWjU2a>UClES>KgC|dW8=S*ltxB@OHsn=R=jFYs%07+{fX@0uvid>s4;Qy57?S-dC0mdn z#T$4nP)y+gJOb&Rfb^exJqx{Mkc8J2 zdQzY-z4Ji&GI$oq@B~%?hBCs4q(?4Zw5C|J7gcErjn^#VLe1hXLTy{D=lRe~XCnxG zdiKoR!jk%$e}lwxfzj0!j_B4fe>!!q(sxFqNsG)BmsyoAuvzKcn)H2Ho2E(gBC~0> z*_b}-OijAE?S)fQ%r>9hXt;Ancbc6I*AaV$1Yh9Sc(SJikoQm1jg_jiwN}av!dc&drgxT}NcgH;WjCU~WC+|b=-bvoIapSXofEUOX{2$DH Uf}Jmf_zh2QB45ORfuQsG2RAyL^#A|> diff --git a/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc b/apps/chat/events/publishers/__pycache__/websocket.cpython-313.pyc deleted file mode 100644 index 89ddeee6bdf54583588d2fb00a749e3f697d9ba1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1228 zcmZWo%}*0S6rb(x_KOx!L#=#-4N;Ot*^MAK@Wurcjhlu8lF7Cm%HpzHXSSMp(u?6J z2O^x%lkw)Cg3$!B0b^o3c&ifQ)%W&8L?`Kczj<%wz2Elt=A>B66IffzmGx&SLcZW8 zSxO8}W&msxlbF&1nU}bvAxG?kHFONdb0k|8i&X>xlg!c z5}q<8E}NEjf57~^?V4kYHO9YXpg^E9>=2|m z^V{5AtjD~=ZdtH0{7ycB*(S?U7zHyO0~gv>rF(zhi8H-qD&@+{YI38Rx|dGysh^H3 zj*%*mhf3J-pS9eR1R!5X@TpH~K(_ZZnjllw70C1_`%^zBB^qg@Nh-=>pn?!SeNsTD zZ$uhYltA%2Ef!>2p75#N2y%kC4aN}`;zJZqRB{}p2{K_7yTS3F3-V3wwObVLaHJQ= z!somX5Jyf>*6KK()xbW2IjcN}(c+nrNQ$@?^urz+tl*YW6^sjG%@S)3%Q5b9Z=KbB zVJz9M@H_))7*?w#A{5Fjj8>w)Fdnm-2q!{sb$~~d2nPQj6g|$NIHCsTDLE=vK9sL+ z77s5D?^O3zc2{=p>6nZklZmfn>Q}lb<$n+`A+6t6N+vV_ diff --git a/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/integrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 067b0fd5281282c7ff9365e6f7bb7878933eb589..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 167 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iGerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq4|BlA2zWSdy8aSF9f&pP83g m5+AQuP>P{wB#AY&>+I)f&o-%5reCLr%KNa|LgerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq4|BlA2zWSdy8aSFE3#SW=Q& xl$&2%q8}fhnU`4-AFo$Xd5gm)H$SB`C)KWq6=(y<1;rr7M`lJw#v*1Q3jo1fE`b05 diff --git a/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/client.cpython-313.pyc deleted file mode 100644 index 7b0176a121276e9ed3e82a5ab439e04fa03c8db4..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 8951 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 diff --git a/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc b/apps/chat/integrations/mattermost/__pycache__/exceptions.cpython-313.pyc deleted file mode 100644 index efcff159104ca7f4ef3fdb483bf62cd912f7782a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 487 zcmYLFu};G<5VeyQO4X`jK&m=LY++~?x*;S61_r8%2vtIrP9}95S4kYsuAqDXGfO{$ zUy9h6*pLD{cGJQQzIS);o&8+4+f8EPZtO3{CV$vs7xgVDdj_mYmmHIj^hii2WZ+ga zq;to2qn*NLx-H7C0c$cLNJD~7NYM=)tc5Pt=QY1xbgr4!98;Q0zm7$v_6V3xK$ig5Hl4|SrUv;E_kF>Fcz7T zGKgZP1D5BiLL$?Aj%K0ERFM7|4ER&T^G&y(uh4$p=CGEPz3hd&*G{pu>koaWIJn$t mD!!#vjJ82V None: + if not self._team_id: + return + try: + self._driver.teams.add_user_to_team( + self._team_id, options={"team_id": self._team_id, "user_id": mm_user_id} + ) + except Exception: + pass # already a member or team not configured — not fatal + def get_or_create_user(self, user_id: UUID) -> str: """ Ensure a Mattermost account exists for the given external UUID. @@ -77,7 +88,9 @@ class MattermostClient: try: user = self._driver.users.get_user_by_username(username) - return user["id"] + mm_user_id = user["id"] + self._ensure_team_member(mm_user_id) + return mm_user_id except ResourceNotFound: pass except Exception as exc: @@ -91,13 +104,17 @@ class MattermostClient: "password": self._random_password(), } ) - return user["id"] + mm_user_id = user["id"] + self._ensure_team_member(mm_user_id) + return mm_user_id except Exception as exc: # Guard against a concurrent request having created the account # between our lookup and our create attempt. try: user = self._driver.users.get_user_by_username(username) - return user["id"] + mm_user_id = user["id"] + self._ensure_team_member(mm_user_id) + return mm_user_id except Exception: pass raise MattermostError(f"User creation failed: {exc}") from exc diff --git a/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc b/apps/chat/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 3d079a82c4a8f073ca3617d589c72295cdeb4c4d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 165 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i0erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgq53_UX)mpnV(mzA0MBYmst`Y kuUAlci^C>2KczG$)vkyYXeh|iVi4maGb1Bo5i^hl06xPhUH||9 diff --git a/apps/chat/models/__pycache__/__init__.cpython-313.pyc b/apps/chat/models/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 6c62aefcc917780bd4ca2549f3b4f6c70b96e8de..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 533 zcmZWm%Sr<=6iwz?tCeoVf(qTvruhMt5jQeJr8D0JyZxby@3Bm>2T zD|cdlz?-Skfo5?yH|OTulVsKDv=OZLFqpk!gg#C2zpOv93W2OsA1`Ef4XTUY! z16&7feFDyMkWQHz%=tJgfH&f8j_Tf4<9r>e_S45(a~7>;Zh+7~>CgwM1u26axM@-mdlRi+vD(0c&)d{r~^~ diff --git a/apps/chat/models/__pycache__/conversation.cpython-313.pyc b/apps/chat/models/__pycache__/conversation.cpython-313.pyc deleted file mode 100644 index 09d1e62ad403aae7d565efc7d5c1cd1b91f62d9b..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1901 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 diff --git a/apps/chat/serializers/__pycache__/messages.cpython-313.pyc b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc deleted file mode 100644 index 5a92a570d2e1fd1280d27b39ddc2ab3756359f89..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1523 zcmb`H&2Jk;6u@V^>#yB-W7kaxZPZ4oT)L5nQ6Y#(2mvucO+o~n1TI0Vt#=%6yY{+o z)+*&>sS^J|rJgyJ6FKI{kt6FK9F5?_skcfAci!yUX#=UaFt*>mdGF1e_jZ2c<5I~W zIDTGw`|bsekU!OAGO}ak*hA$pVT935vO$3^kU6qQSmqL8+8({FPZqpGyB?@0%n4h=PP=-pUYe~*J>uU*GV6EHEA^HiaL6l9M&_IC&~I`f^B!-x{UBQ3!T193 z`+Xm{Ck;0U!Us;b9|Y1&^*e#vy=UX*m;p*Cej?kpB@2$zbvxW~q~SQ7(CY_EpK_f0 zeK#0;rX8o{gNOpZ%e$fDfI2`~+}*v^SoeA0$)dnEI5@s1&9$Vm)IQZoTXUj^JuZuD zZ5I+p(uORqV@K({s1nXNj)>gIZ#r%ifq$nTaebHZfBXf3SE)30ur?tbzuU>4jLF0@qHa~gPfXkEQXUIIg zEx)7t|00uRn6^q2CeLM?=`4%>;_KMQ^@`X`LhSh&CJ`p)n8VWlG1NUD=P0oPSo=lW zkd=S)Y~s2dnvZ@m_n%zdfT3cva(c^P*)AAN+{w$RUq@dP((-?TvVnR zCEid%-3+PqA&{kp8}S{#ld>g^CU7)(NEKXCk!qjxoaU8wWg=8XqJ;u+u-+V5*M`=$ zXI5?S`6(O+7vu8#V?1sQt2Yj2mqR}{9K@1QKV`gMr9U51kzIRxQjgJRwN0s?e zWocMhI`DqkjVqgvHqsK*?%Q@&TEMZb7T{fc5W>CmiWx8e6H;oETF=%m3m>M}_9M8C zxu|O59TYD#O6ecu`bkcwpVB9rqxqZh{LK@hW+zsTZqX;^Xl^;4TmFlvSyIm5C$mk; diff --git a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc deleted file mode 100644 index 411c7f168a6b2080c6e28e9514b182e6e50fe5ba..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1062 zcmbVLPiqrV5PvV5znf%hss@vaXp4n4t=mg);z7k=#VA>V9s-YNvTe5A?#6i=#hwZU zzd@m2!JA*D$sw}f$y0Aa;@x?>iCIMye2~e^yqS43zu)YnSX2p~Pj@!=+mw*6(wLvr zOqq01IVC1B=~J>pf!-w7$SN@tRbnb_+Dy(97XRByq=OPhCNVPgkI3X+TF+d_G@S-{ zVS&vZ`^W;(S(GItF*7EwP&p-S0@NgsFexbQWHX#49!{{&?U@787@_@oE3H^W)=WTd zPtlXX@-q$xO^bKUpN(9|IX{wyt_E4gTz+UV7O0FJdfkB|^#WsW2Hct5EHSoggYX^O zwOo%ekdOqqR_n>5$F}8kgUkSjWOg^mJ?wFq`TCylrSB?Z!sou-Vchq@em(Flfi*H% zV}d^t)QuLV3!}$Huge|d1$g^b#}~#++ZCQ?bb8!3c)u?qErzE7I96EQy4!yblI2jy z@x%=jAIN8|GS=3H+S*9FeO!-QCv_;z6aGxh5nlfXF@L8ngVbHZ!oQsubvdL?4XL{v z(uUw!G467h8KT$F^N^KB=#j5NUJA($6i@_99nXD>&?5Sti(EiadJ7Lc&#|~0@uVW` zY~V^HLG~4vj*wZF=9RgSuZRs42x|G(xLg~SYoqe!@$)#S2yyB9xTFtD`lwVp*@+6t z8Vx-aXkg(^>;f*_I}Z+`JyI95MGS^7%Jo=u5oGU0yLlg0@B%;YsFiODrSuC~pQaOZ f{cL4iG0rQ-l%O$H6}oX&8&|i^t6Sd*8ez2`!8zc? diff --git a/apps/chat/services/__pycache__/__init__.cpython-313.pyc b/apps/chat/services/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d2d6c02f5d370698dd3aa87de5e8e63dc3fa3177..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 163 zcmey&%ge<81S{+`vq1D?5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i8erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIPMgauSvmYJMdtREkrnU`4-AFo$X hd5gm)H$SB`C)KWq6=)>L%3=`XBQql-V-Yiu1pu-qC_n%J diff --git a/apps/chat/services/__pycache__/account.cpython-313.pyc b/apps/chat/services/__pycache__/account.cpython-313.pyc deleted file mode 100644 index d1830d01be743e0a35cab77bc8fdc3bf64c96643..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 630 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 diff --git a/apps/chat/services/__pycache__/storage.cpython-313.pyc b/apps/chat/services/__pycache__/storage.cpython-313.pyc deleted file mode 100644 index 1cde293b6f118e28bbe043514809767ad62b0776..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2290 zcmZuy&2JM&6rc6(`YRjfi{iuy!33ykwb-IbB%l!lQlsXBgnF}7m62AP^~BlW?7Fk- zDoHOrRF%_G1rDgKidy&7aLbWD!!!r1A#f=@a0{do*S^`!+JG|B&b)cw`{w;-_S@T| z2*&UE)ba%pp?}!MAHo5#KLf-)BqN!dM#Nh=!ofZ;9hl+?pJY6KT9^`v=+eS;a4JMX zNz{vmkSr#U92^aJ-blE}r9v;*W;Vr9u~IP#BS3_0>^O#5v>Ef7VH%e18F+Iumyck7 z58Xs8J_*Pi;pIRA5kcl}in4H191&Q&Wq}0cAPE&isW6SnjzzR0mNEI-(D921xX`&b zyfX$5-~K7!-b4AOEPs$Rf?t=>DAR#qatil>N1{R-LU|~hH{`(Rfi30^^}@?S(@P*9 z^6iwvwZ&G&dtGNyAQ>mXp*ibI~YP2rf{`(~z?h zN-leqc6!^(6BAikR;ROH`W#TmW#<_u#nY&!>)5u{TX>B|ZLAaQxO5O`@JhQpFISX_ zX|S32?D9Mn^d-%q;*#T(Z7Q&3oxhODq(U@Q#E#}Tgs>{n=nbt@!C68qLRcwiNYzUQ zHXR@qD*7$#(1^OA*;uWRQc9qLjZ2F(jLkyXGE9ebGMk{SFX0tzGml9a^m0bAF|jjC zn!QxeN}21#TE@C#XTC5@+p;n&xr|mW+a3kFtSRk`?KNq-yhg*S3IK3amB!z0-Lx-Y zptY+oeT#N`68F#5PmgXpJ3V97@UxEiZuda_*rlEB@wznrysP(qZ|&mt=uX%9YH&9` z(2$Po#s}}O)>EH6oZpFGd27??>3bOwd!tq1w)CHbdXF=`v4@L0@hoFK8yLD9-wIZP zje((G&eeu%tF`5N-`T40Q*0k{K{UK*l(4C-U>byTst{8@fDDtOFnlh|zIz8|3MX^^ z{m^VgsIJ20-n4g}AT)Zg&H)Tfz6Cyo_rYfh`DU}`Sk!mM5K@{ztw6(%GvBB}3O7!!k3L?qKJP?SN#3 zav8XgB|xzQz)+pFGETMA5Hz=oqf-!&@^I!fvSO@YCbns`SYe1|8^X?y@=JJ?#2FPW zR~*$^V1OVjZ5l8v8oILPV0&_o^Z_RoCJ81GSSyheE?LkV4bW$-WAZ*@a4K!{oT#kX zY<9#9du)(nOrxz*E?HVZWlm_~9T2@CW1;K^FaeGRPuB`tS04`!KN=jaI-6@7Yq!67 zc%&-qc68qv`Eji(JWrf_oJjwjNN=s~B+hMgRHbJf{f++7>df=L!QbM4^#9ubOMC5e z7Zpd(R@)k3X;a#e9*2)V3LkGsotrZoGYu)eE5$aaewe!B>_~vd&PLzqy*3m{zC^)D zbT5W_;yI39PbxTXwqvcNQ)wEf)zeleEG4bQQWT!aJZJpdhY~4Qj+Fd StorageService: + if self._storage_override is None: + self._storage_override = StorageService() + return self._storage_override + def send( self, conversation_id, diff --git a/apps/core/__pycache__/__init__.cpython-313.pyc b/apps/core/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 461e6fe948f9f694112658e22a255effe501c69f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 154 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iQerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%esX?Ms(yTYW?p7Ve7s&kMLI^14(mlnK2qJiBv}hMmAux=))6K-q&iZB|$sG`;*RAfFajhoU?V2zMfTyZ2sUF6 z{jAyd%|@+%QuTCx`+@O3@VW6nv3$l&SS70GB{$bHu4%o!k#d!wD7BDDxHgh7t)n;p)^f}DswhBHz-w_X^~K- zHH!B$%@w8S#vO!hsvPv&=6X6|MJD$WS-9)b{htY}7oID0QWL&#q?%gQ&oq_L z+_aHr)(FSyt##8|?tJ@EJ&DJ7*HZU)YGA{qM+iBG?m6sU1%$jK$M?>5dZ#sk3~H_?%?hMVUn3>;etc(g9}a&p^-}#ns%mlc9wB^tnLZU zR4^0`OwIi_hSFekk(scXTfMVZ@ajEYRZ&&bUavziE^jv{*O>o1@gA)?nQkJvA&fA( zPySG#-^e=oL74ZMFn>q~ep3ft7KE)QCqinMwySDam7_dM8oNO{7t=3DZpe@TWduA% z!Dk)>%!d{Wpq;hCw(aaHkj;#z0L#z5rV~+SDoT%|6P3zBM>~18T^sc~mqmKEB&teB z2{zr4gK)tva$brP$+_)tjtzC;_y^8UYf;RdWzI)AXj9~+EEU@QRE|Vl7%p%wdN|ky zuGp?}u8lBx!i6!AAJ;}|&S3#Jo4Tv)AU@I(bUYS%oQfho1T~R~(edBB)Jnz4SeV%D z7`q*3EUNy2d-Dw3jk;QSbp~pLf>}hFK}bPJ-%soOEiTak=eyx g&AD69N9lg9R}h|kO6eo{{?hj7r>l)uf@#+E22zEG9smFU diff --git a/apps/core/__pycache__/models.cpython-313.pyc b/apps/core/__pycache__/models.cpython-313.pyc deleted file mode 100644 index b367e7a9934ceb7101ed63ce873b32eff4d6d542..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5062 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 diff --git a/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 09157ec7115e103a983584655eeacc4e611cc1ac..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 163 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~i8erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%etLd>WnxZzeqw1!hLL`Jd}dx| mNqoFsLFFwDo80`A(wtPgB37W0AS;VOjE~HWjEqIhKo$Vriztl% diff --git a/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index be781c94da33110dd6c4758d8b0a7ac043494a86..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 425 zcmY+Aze@u#6vvah>kld?E-p@S5eoJK#Zi<(rxuD>q~j5D>0Rpe5?)fF{t<4j?*2;$ zPJ-a36nArXI{1di_x<4WeaS8id&tG(^T}#~5qhhNzwmy=?$n7VG)I8uc61 z+MoK90D@Wv)7GR7ZGzHPzx^=ojaRHl6=BI*7KE!}Dbo!wD^)?33W&`FNtN2Nd;r2& zklYRoS6UdN8NXwxhvzgd94Cs$ zak-z=H$=|e8Qg;JaOKheW>m8yBdstHR11fSZEQA-HK==;2xvB-5UO! zXvR#Iu$*3jS_y6qUC6>HMR~?7Wm+5BJTY#NwDHp0vU|Q($tD*U5IUawcw`P;`t&iz VU!I49?=Hf}ujuR@4PnnsegLcUeXjrj diff --git a/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc b/apps/gooyal_oauth2/__pycache__/apps.cpython-313.pyc deleted file mode 100644 index 12ea8e5d7ce176e4eee3fe76a391770509779c32..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 544 zcmY*WK}#D!6rS1LM2!v6prtLAracII*#$xHAc1JHP$4Z0(o5hm$!<0q)}8gu>_M-B zM{j~Bp~wCkk22t?CvSz+duOuo&^f&Kz4_kv=DjzwX0t)exxU+;{xtr#4PH)d0cP6< z+z>_>Jtki%(hhk;J`v`;Bg`GpUAM|ZC-MB+lMQ^TS9(QpDDrWdj4HaabP>$947ec! zf|L<-7)6&k=rI>-%)@$8^Xq#3ixB5L`^HN(sY*S0Ms@K%n({mmK{O0bMHFW;I7pLT zsl-tlXVFEQ7ljOxxfX;K@BAWte@QO`JNEE~WI5eEeH?Xy>il!u~RS8QGQH4}RNuiEn8WOQ2RVA0z+B>lqmR<99 z-G-bvRgo(7P$}0c>IrT+wx_12UV#f}q@HrAZeKsjr4cSCFw$G*nn?yEMj_gWHXUV^__<_S{2cwaR>d)>~jM z&+JLw0n-img}v?!dkoC4heEx3?A=L0RKsnBsI@yge_`XfRF**2C5Y%UMD++LWpyTf zSUhZ_kD|I#j?ZMf$8aR7tK%xfQTKfJxX_L2vGE8bQKIiyiMX($jYlDkG8d1PN#Mw( z?h&Dx99N)M_&kL^Pv3}ya*)+CaIO}~_wdy112?wF3DdL1HI=6tU@w}mY%H*4t})9F z%!2HXVnn6~}ZJTK}M_e3T~_%_oM#+*(iv zasuQKo+g_<1Ap`(BFZE@*T9IgfE|c*#70IPdUpB|x?`h9L9Hoy^=bCdUPkY+z-;xR zLUB!+UW;+fFkEwy83s=p2Buikp?2CZmYSy19_clV$2RzZW4p}ta9A_j8OYx=3_rjO ztQuwzfL&<@%r^`epk?7J@mmGjj$f#oe%&&i!UOQ;SvBwrU)rwkd4+1-3<_qW;TLM2 zw`@9wXEuZS^+K5Kqm5;*(2{xACTI^kafI)r?Ogx&8hn6l_>h2R&!2}uYEufp5W)Kh zJYriwb;}bEAKslT+RU-I(rnrmR|&^>qNLxS4*8gc6Wb0?Ow>&XsYKPWnHv}=pDcR7 z?3z1;nGk<4bAR^piCLa*k9*&`48p|>)7-S8aDkVpMdy-D;`ky>1t+|J~-GDDl0p%-JT!A53mrMR8#+sY1aW`|cN zURtk4HnMkC?r!G>wsIqzxsey;UmpE7_v_sH%%ko@f#E zPPjSV{#R;Iji1TG>9(cNxgVps6P??kLK>&`YmtNr#L;x%SEBMg;=tkwYuL2+!{AN_*|~ P+_i(wGw%%zh*|#yhP-rr diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index 35bb56fc898b5386d1682db8a3a9c52f7feea9d8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5915 zcmcgwOHdoh86J%uNW46f0fWGJ5ExswK^SANVRya8VllABU=7&LE|u{}Ge~2PX0$yc zFv`nSsmd{xOR6}fIBss_6prMSJ*7t4gQ(s^T$K|yHcl$X{rLLt z|Ns6+_vlSiQ#}KJzx_l1bFqzK{y{qXf8I*t&0}c%o)H+q^%;X)^vPD9ZsdkO`~r6?DpfBBeDav_6I5Tqb`urP)iCR8Y!ab6ht#coB>4mBuxZ% zsX5Vod`93vI{5@geV{`K*7L_vz#t3%&W^HxX#q^62BsBIZG@9pKsYCGLoT6x)Lqjn z9jD0xU-TW{CDDI;ObElSoueMmtqXKJ(aV51FLYD=FPwtmg&rY7ecD^o%0A&@j1~HY zOH|tBTDTY^L?5{ha93)?R`djWfa@C8UpWDNRcIBije5cQ0np{zaRl|=AdQb$Ep36H zR$+)*biJk&PH?3B5Rp1uTN7aJ1~A79Bf?GU)mwnAiJ4EblVYH^+ zB=*NBgYQxgKCER^RXzG~YQ{%3En>k39}9PAK74Wt>aH*j8NmzpD*lR}g5@F39ElUP z7yurfiuk@BZFmQdekx3Y9w*k!Q!3_tVTwlPyLkG?@#6b(<$DLNrs*kqa_w&wW@v@^ ztQMQ#RdUWepwaY0RFGNN>BLhPc6sG|j*9GKm|sq6-z+%DjT z@l;AN%?0CyqGPW`AI`RCQT-3HKf8rKl--+R9*GURS@& zE4WU1nNyKsVy~Gpa!S;Ny-7{dU*NhFQY1@}VWd&YTZX6`tD+>!xGtZQD--_x3GB(| zRe3V%LyfR0u9KBzDX&@BugIz;B{c;%CvUEf_=-XqRA$%Gjh(Y zu373~W>J-Y8oL9hn_S!S%Qh8n0f#lQEF$SF?m<72G{tX_3*KWSXl6k_+b( zifQB#uxG653gY)C29lCVh%AI0(*_{oDh8Q_P@H<7uNnhn6NIR)$^)6TBvR!<>l}2a zCqNNh2j}t5Fm9?)Wh$`RDvV4?W(J&T=)54UD1&_5$gT0J#Y05%>N0O-RFjAQ60a+( z;9;_8FzR(2K;qen7UBhmxKIWJibpnyx*U*0^kJmpkoiK*iAT7^0ZB^(?^Y%obweCo z2Em0h4-I~9dLqu7`CQIG7BJ{d2*WpT6+#JNoL>brFHDUO4D&EXCa37+*{Rv__*=8P zlr&65q-CQq zp)5lQ%uqadl4{lHRMTZCt7>ceN>d56_|zd~1*$HTM_P>j2rrZ!*e_CnqKNB75nd?s zngZ=cQT#G5X=E$toe{-l6`7W%>WXf_K%J_qP7=!sgfnD7N`(|9udKKsPK)p?c{fEK zG7?yTQZq@)96n=^uo@O~$XJ0&fN+Q!5U$fkaZb zM{r<9n0r`3#4{3dn)PV~@>Q}7Wa3bz4es18%Q)!ds6aEuS|20w}|!f|27NYcrY7Mn-Lb4YeG_5|YB zFk%p1R!qv}%vjnm)+9}IXo(%Z+{cE7hOWaINNFC-tsx)CrC;PQHU9?r@*C!LbLiFm zJ&&u!_v*o(p9!2T`@2j2?ww%w>tOqOe>r@m6u$CJ_?pcP>;!u@J}LK(7JEm_y$_1L z5A4UE+iBHCD>k%yS8eX)PB6SaS?(Gtbq#&fHEeS?b`FJXwiLs+Z0>_9%$8j2ird^o z^-y22>l2&1yAwRO-d662mO7%FBik*14gV!CqR zPOxMBR=M+Psq^Y)-_}C0^PbJccYD^HrmNfUM)XD7(Uv+N_H#f1dbLI5rJ zjs9|ExD*-QYO$xY#fWZm#!m3sSIgzWJEg%p+Y7d^2n&<01X&mp=)wh9*uCK^^Ft+m z$R7LLUdq{{Ujpn`u5kkExkq7ffL;CSae3h5(!j_6WSIM|ryc+)p0o!*gBj8Zsve?5 ztQ@&rjNC3qW{Qy+`_ZEPOt$r3{D)x-*EEqx9<=w&={+MGXl&9A4+DN_vLiFl>NQC} zXt{TS7dKYQm&Qt$#Pi=0p8h5jJY1-yys$F}Uw-4CfTmaK1 zTvVgytVR@DV+%yh3qPYY{2XYAY)qE>MoN7nTO(hG0P)oIkRUG3QN%nTzE||@oj(Hs z5vdA@i>(k4*Qx@d{dJW{VY{W+F==!6tE}6P_X`_s<(})Mp6lQA+_1Tk_eg-Aja%j3 z8>QYGTYX=%_LI+xyj+620HRq1}As{U;PX=66`;ZE?t zyHFJJw(z9b^VH^kQf0@fCGDU%ZbB-2XmcNZU&>EbTVS8KLcU+mAad2G1wQJ>;kS;- z!NXGxk)N4y+uH!=r(oxR49L$VJ#7riNqQpFL)bY#$O+|aq!20okDLSl4pO1(f{%F# szV_TMm+Os>aYepm&VS1^{GGY{FW*mHF5mC!%UoZP>-#rDdQ{N=02`2m(f|Me diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0002_initial.cpython-313.pyc deleted file mode 100644 index cedc8f414ff3564d006e0a34c5e52f3f03711b9d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4655 zcmdT|Pi)gx7`L5&PMXk?6k1>^rIga3DPc%xtAr3)Ynry9>0+k^sp82^UQ!p=&Yqp2 zGO;0T+Hu-t*PY?C9e3PL+fF0h!PS$dA??JisGD}(d$vXfMP3NgK^iz^&{QNabrrWJGq3K`be`Id0a6e8aTQH|L)XpulVp z1&5hIW`q&_CmAuY94H)V0zEG;`p%_#8dY z=8*~CzASsal{|!KKe(FRJl^ZA=Ii0C1@C? zeg|qeT2d9LnLxj*L6r34z=|qscd%cpt11rb8X)NvG$H0pJ?SH<7frW-Ca*&>g+2on z1*&E2uNx5E8#p&E*J?nOSD>0Sz`5~~DjP=9NCr@hFhexoLnMi0a1=pRHeuO@LNx0l zv}!d~DcOk~4v-8_Gn1S>+a6E)ocb|}VJ`?NWyc0yDFfFU$Fyb(!3u)LsxuaITkc@L zq1RCfg1x=R-6mny3iXI2p(im9Y`Os82mnNhx(Z1>3c!bTS*2=_Rxbb*1sSHQXi(Eh zN63{O`Kgip#$95jyh2u%VGU|!sFgMl&3SQerYL1WQ4|W`Mt(}j;ppxR1sa&ol*`wt z)`(`^IC5P_P^oHja03U+YqD0=F;`x}yzMWN8{^PIAukD&5)Msf#L3K*fY0RV7MB1S zSe%(6`(zdtax;?|X(m7K?itH#P}0f6-U<)BxMF4sNclOTIh_{;3CtIBIXv>>%*^DZ zAc}itpM7yoLAYKJ#O$6)5xoYR+5Is943iv)5|B+3DJyjo8UUcvM1URg&)^TzMRM+p z^r~#EmSr`)fb=z3GL7_-q8YlLF0IODnh0*B_XI@x`8S9(35`q5&`8xbP>A5#4;o9x z=frDYFi(!eA53otnec@zK5p^nH~I4qd29SyWBl50d}ce~>kmH2ZAY1K!s1VD@~5^2 zMz@Zh-ii-e@w1!pvkynVlYUtI>DG_8ETPyCik5J@A>6ivN<*kTzGM8uFe*F6J=-l3 z0{1NB4@b5mdl3f?gt*NxAF+9k6cV@05pao9DY#oC*8c>87E|Kv*D^MBl zvqj3wEfR!Xn~#R~@H&9kyIM*RTWIcs_>x{fWjkhUGd}iE`riL@@YnD!VJmyHk-ceU z414BxX7~?g{IB2~%Lc!SSbd|7zR|xK+Okpp1>Rh4 A^8f$< diff --git a/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/0003_alter_application_authorization_grant_type.cpython-313.pyc deleted file mode 100644 index ae77f9216d952418e4ecf66c86e026c22c198ce0..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1121 zcmZuv&ubGw6rSBpHrZ{0HMLm8VhBZ8ZMvo6p+O3zMQN*5yNH*-Fxj0ZQ#QMCW@1ea zA_yMEORwI&`LEbZ%U(V8mKw#YZ#E50!G)cD-}|-ioA(WiMH|8M`Rm<-?>a(16&OF+ z3uCwj#u4(6r|qC^jceOF*UM-QRgkCOLY`6A_RMk7<@cd6vboXBIN37=96hnytjURF zaU>G69S1ZN{$&k9)&&?gz&Jv6#5E6b-P5??>8{2z_3U0@vS4JxD2A7*=k})m7ju!9 zeW`h-ms{1n{Hg(cEYIS0({`p3>)CjsGsOaCEH>k~M?xHvu57I&h2`bt70ecEPbnmGDkJ8spF#}GB8g?MLmku6`=u+@(zbIYRZ;l08(4W5oyyTlb+dqM#JD6+J$Z8aT>umu`!0s zZb-o{VEm>_LX{2Z8pivK3mLMAMzIP96b6CWeMSXWx=ZZ!P*VO_HRc)Rur^v`gcBQB zj5Romv5>Gm9}_7#Yjhu8N!Pa>An(_3%5l?D5D&hhjtT87b$VPWF+G$ap1|=54NPR+8yNZx^TX4DjjWlI z{QPl#{`BU;>Gk`krMZ(*<+xP&h0x;r%mAUC?6d4SLNmMB^IXQV2ZmWPKW_6os3 diff --git a/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc b/apps/gooyal_oauth2/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index 2cf833faac6f16a17dbfb9b751fd3e602a072261..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 174 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iaerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%etLd>WnxZzeqw1!hLL`5W_nR# xNoIatv3`7fW?p7Ve7s&k5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iAerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%ekoX8e0*kJW=VX!UP0w84x8Nk bl+v73yCPPgQ6Q^|L5z>gjEsy$%s>_ZYg8qA diff --git a/apps/users/__pycache__/admin.cpython-313.pyc b/apps/users/__pycache__/admin.cpython-313.pyc deleted file mode 100644 index ad9d3ce1e9b52458b1e5ad958ef547fabd9d2929..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 330 zcmey&%ge<81by?=vciD$V-N=hn4yf%K|sb-hG2$ZMsEf$#v(=qhG3>(W^X1h<|1YV zh8V^m4wx1M9mI`b!&ouQri>7_0z*2BChJQON0aduYhp@nX5LGX9FWHnTAW(sr^$AU zFC{B6FFju`IX|zYC^M-Dx%nxnImJb+AP!4$W=ZNTj-u4`%;J*NqLmDvfuanz z%=JM=6zgXs7H6a+=I93$=j$hDB$ntW78Dfgmx9%St=B84yv1Py zG`%z@)vkyaXb#9d#qvPn12ZEd<6Q=Yy9^Q^*cceu+PNCJt}_T!~;msS~Ox|iWkc!9G&gfayFWpR|G68uL23~ox|Bxz$>L0xmh|vH5 diff --git a/apps/users/__pycache__/models.cpython-313.pyc b/apps/users/__pycache__/models.cpython-313.pyc deleted file mode 100644 index 1616f7faf69c106a348a6d2bb21d458e3d685eae..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3994 zcmaJEO>h(Ebyr$RE3NFcB>xCwU?E^Cek3Ru=~MK7C{SsE_y*#QkIbQGo~y z7sdjkK@wzmU@SB$5|QD-vG8bwL>L|#lSZQ?%5ZV4Wwe#Z4Clw%NEJ(;!5sd9mF*?P(NrEZx8-7Vu@uml?xDiU3@WGs|z z12q)k0OP)^S^C2Kly@@^`f4xRX+R!uIgaouM|>(z{Hl)#s;COx+$>K5YM`4VK{W_4 zq=txC^rym9YP#rF3^YuRPt`S|Vsw%Of7^co@PNy56Y$)-z-WGz&+_N^tZ$gV#M}Ol z8#*S0z1$oaxvxgd`mLIoIWFs;fDqZc;6xTeyXmTLfLB1`IF}N}sc-H!m?%(jzysfK zsUQt78xj$OsNXK%qY_xx&}Pe~Z4{{B=ywh3*9!TR0566$O<+=iU^Y~l9{EAqLA6IJ=#qd~xygck?j`D*ZQgMYuW zBs>+98)Edc@bAJ8g|EcFic8@Y@hC_wN!y@++uwr(cEE$a2_AWNFO^q)eKntfUH9jK zcxvL|CK^88(FKtZP=%dH0RIHXsc1TyUKZBFi_Pia+0Z zZ+#MYDqeaiC)Qhqv<|qyUf#pOySQ0GC{|pGY3PAn1 zVO^E)tKkNp6($;S?(rg2eX767qWkj*dwA+)b4Ly6sh_0!Z}Ix>TLkKR>=ye;3w4ft zq=WT6kme?WO^4P{@VNL<6ER#}^Jy&KOG3yldDBx*DsTQmQzvk~b9a zG&v0NljI1%g=1kuXY%9hMLdt|Fa6I)xY4sv5;6>bVppp{^dJ(YLL%Tuj)uXSi7G#Z3rak+F zU}IMSY3C0B7P)`PT`%H2)k8yT@hjEnm6uInIs8;S-yD{Z=6x@vu660)igfVd=$dqL z*EsR|vh z1tHM>->49{{3aR=Kr3TH$iJ%*A_3R-mn!a#ro zK^802o^_1Ng|XPx(?boap#dL>XckG!>babMeDS4P@>^q z%YaB)Krh;0&#K+2wJxE`Ov0zO!S9gYDidqS;2H4&KWTd_f1=Kxi%np<>{)6%*dmc@ zZ?Z*)+5v+{r`k!nie0HB6)<16G9WlPk_u31j+iB#%xkxec`6zO)7A5qK|{5UK!X+A zyi+l#Sja=L6^#2VH@6l+9$J5-q~F&p!!Eir1h2F8X8;Q$6ULp2NsK~TIp23qfewu$ z7&LLEOq6Nko8fa46gPzQK(=O+MA{iGPw{#EJVvhbwxUEzn>?`NyO{-io%tw^QC;SITC zT|TlZA9>6_I`wtSx9_aTR~FxUcHsQlfeTfkv$6e53@@Gfv}Gd!q{Op$qS~HYi+{4w z-djzad>L*3La!#TuXPT;^81pJS3yorY_!Dw(;>A57vFo;%_R=3$9q@fy^rrKA6h%v z|J}783$;7MON(P0v99&liPhMN$NF;g>+-jgKME_c4;Du^;>q1&$sc;Y?^%hDFOL1Y z^}wg4#hY&gK5*9i{rJ;UT~s8Htp+3z4C}MHT`Z@k%JwwD7^R1HDA+*K5dAahJbY%c znVMiE((e6uyeXVu_oj#UV`wgwwhHAE4MKp!mkABxjW1oKA^e80%)Hm~nCEv#0~H6o?t6v&1{7hoI2bvv z{5;P;=j2UJ+2jT`xeJ@z^-Zp4lRLV}U3$(*&$*WR|JK*yL4KD1JoB2v=j*{Fe|Bl` K4F?Zq_5T6(lcNIw diff --git a/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc b/apps/users/migrations/__pycache__/0001_initial.cpython-313.pyc deleted file mode 100644 index b1eb08b14ba40211a0ec5218f4172a057a16c478..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 3753 zcmaJEOHdohbu=>)lK4sZ3kWf`@keVjf;HZa{7J2C>|MZi)@oqA3FX<5rX|`Q&B#3? zW0Xqbs#NVUmCL5cCGj;kAIT}?v_4A8K~(#&Ta^TI`im>pG$dA;Dq>YTYnX-smggd~U9@X9 zN~4-|TskQsWk^EmQdfRpN9gW)NeT8eRt{ytTO7ETky*S#%GC6U>v$|uaV>0n#a$*z z8tMRVec=C2S`rpf8HK%$y^kyIR)07+fNBumC+b{r_q_53Up^bKqsSfC?oRv(fg>tmyh;q z$zM1Cr|-w1^XP(z-8jgf*ncjp)ln8rph+~2J{GyS_yO7}L3;^(63}M*fb&zx<4<%2 z<(3BXQ~Pz~69?cv-H+Re@stcvzMNB`t5WC+9VuVUTw}4jJ3YF^$HzRht!mc%Wp;XE zV)A3dw0%sCng>56LOtJb%nCLt<}<^_P8rD51vq0p#525F6;n=myZ+@g_DI<=ee7xe zvgyMQ@icy>Et{TZ+O`I?72QAT;g-2nZK!(|HVI z>YY23j~W)U&5oyau@&?zj4LLwxBkoJ^oqS^S{B8g$D-VQ-z=5txjmk*EMqX9at{LE zi>9O5ZdoIa?*<_;tqO58kN8-x_Xr6c_gD->nnj;H#dUn3%$SE6efjAjOm_v67ySZ%RkxLwG)diph0K}2!ArKcSQ@2+2>gGJuR3p3MV zO9weqn0M?Hi&&3Lr|hz>qU-pS6lfF@dbwzLCZp?{T6o$oApjZ(&kA82cp9@ zpV%l8f%x)n`tCXfA? z9DQ-LePXJ0VrqS1TatbjGG$n*@(tyUB%Qpe0FazkHj^W3Puru})@b&nwc$0s#?8@E zBT?Q=rq)XBwBAbVFXuOkjR(bM+G-^5X7c#jnfB26*3kKvV;dJ63s!RoHxi}IJ zdt{OHM9+n8b9D=HQ3iEsivvk4cf?M%(v$0Bf{gN$>>bYIIguH3wE5TRYkw z%(MnGFQ+yRe?Rp1p~m7b8l=)#{WXw(A>ZLrhwcig_du#pKHeG`UqAHfa&u&{k@#v4 z^5yiabB+1m0OxGzI_J47FK|r-1@pyLs(e{he#MtL^}hNazI?5M4!#KJVE{c0AQC_% zfNq2Z<&7{TeombZgOpkI7GLJW5BXWz;6U=5@MbdoBGT5TTH4gQ^@=tgEHxfKYig^F zM7@)Vi>=X%>th@9jR#h9G>Ggj%i`ab{<_q@mTz9mx34WWuPrt(ZE9X4;cq4f*FI?v zPqv09*HzFp6SBFZzm#urpMI`g}oNcGZo2l`3D%VWq{*k)UNL+tassxw~$2q9Yw5(ct<{e+L63);Y5ZVlIhM6!aAD*1!hDg+#9g zWACT*0e~U+JpNSQRzjiB+lUkzd@Y@REu~&d-9Jd@{uLPuMSdS|C&rqIv42Yd2uc3~ DU~T`2 diff --git a/apps/users/migrations/__pycache__/__init__.cpython-313.pyc b/apps/users/migrations/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index d0cbd8bbecf75c84cc7ec80d9dc30d70195877d2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 166 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~iWerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6VnIQ%ekoX8Zf1H>Vo7FxUa@|Bd}dx| mNqoFsLFFwDo80`A(wtPgB37WOAX|$;jE~HWjEqIhKo$Uc>?!d8 diff --git a/main/__pycache__/__init__.cpython-313.pyc b/main/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index fbaf534d8b18124c1ef92c2ae44483a2e0482c98..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 149 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~h}erR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6ZenJhetdjpUS>&ryk0@&Ee@O9{FKt1 YRJ$Tppdlbjib0Hz%#4hTMa)1J0ABbb1ONa4 diff --git a/main/__pycache__/settings.cpython-313.pyc b/main/__pycache__/settings.cpython-313.pyc deleted file mode 100644 index 0f64b4fea1b4481e28aba19d7724dcd876734357..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 7477 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 diff --git a/main/other_settings/__pycache__/__init__.cpython-313.pyc b/main/other_settings/__pycache__/__init__.cpython-313.pyc deleted file mode 100644 index b79ae16b09a404bc0fa80aab9597a5094400564e..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 164 zcmey&%ge<81by?=vOx4>5CH>>P{wB#AY&>+I)f&o-%5reCLr%KNa~ieerR!OQL%nT zVsS=FVvc@5QGQlxa!Ij%cxGO4e!hNkMq-J6ZenJhett9e0*kJ nW=VX!UP0w84x8Nkl+v73yCPPgnIJohL5z>gjEsy$%s>_Z8vH3V diff --git a/main/other_settings/__pycache__/logging.cpython-313.pyc b/main/other_settings/__pycache__/logging.cpython-313.pyc deleted file mode 100644 index a67518bf6a7bd7312d7f7e6102d78c590cc0ae07..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2149 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 Z!z^=rwR@L9w;N>{{@xE>0?%8X{0A!-aFzf7 -- 2.45.3 From 49d6bd6cd2160a353676429770cc995411ce22b9 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 12 Jul 2026 11:27:31 +0330 Subject: [PATCH 6/9] add new gooyal account app migrations. --- .gitignore | 3 ++- .../0004_alter_application_client_secret.py | 20 +++++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) create mode 100644 apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py diff --git a/.gitignore b/.gitignore index ebd5341..926e870 100644 --- a/.gitignore +++ b/.gitignore @@ -8,4 +8,5 @@ media **/__pycache__ **/*.pyc **/*.pyo -.claude \ No newline at end of file +.claude +/log \ No newline at end of file diff --git a/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py b/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py new file mode 100644 index 0000000..5d75cbc --- /dev/null +++ b/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py @@ -0,0 +1,20 @@ +# Generated by Django 5.2.13 on 2026-07-12 07:43 + +import oauth2_provider.generators +import oauth2_provider.models +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('gooyal_oauth2', '0003_alter_application_authorization_grant_type'), + ] + + operations = [ + migrations.AlterField( + model_name='application', + name='client_secret', + field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Client secret for authentication', max_length=255), + ), + ] -- 2.45.3 From ba236c2b3ec14823adbdbee503ef04cb0816f941 Mon Sep 17 00:00:00 2001 From: Haydar Ghasemi Date: Sun, 12 Jul 2026 15:51:54 +0330 Subject: [PATCH 7/9] FIX(chat): remove migration remove migration --- .../0004_alter_application_client_secret.py | 20 ------------------- 1 file changed, 20 deletions(-) delete mode 100644 apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py diff --git a/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py b/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py deleted file mode 100644 index 5d75cbc..0000000 --- a/apps/gooyal_oauth2/migrations/0004_alter_application_client_secret.py +++ /dev/null @@ -1,20 +0,0 @@ -# Generated by Django 5.2.13 on 2026-07-12 07:43 - -import oauth2_provider.generators -import oauth2_provider.models -from django.db import migrations - - -class Migration(migrations.Migration): - - dependencies = [ - ('gooyal_oauth2', '0003_alter_application_authorization_grant_type'), - ] - - operations = [ - migrations.AlterField( - model_name='application', - name='client_secret', - field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Client secret for authentication', max_length=255), - ), - ] -- 2.45.3 From 0cf77aa21cdd8c7368ddd566deeca7b9f0fd01ab Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 19 Jul 2026 12:11:32 +0330 Subject: [PATCH 8/9] REFACTOR(chat): rename all internal id fields to uuid Renames PK/reference fields across chat models, services, serializers, views, urls, websocket routing, and events (id->uuid, user_id->user_uuid, chat_id->chat_uuid, sender_id->sender_uuid, user_1_id/user_2_id-> user_1_uuid/user_2_uuid) for consistency with the uuid-named PK convention already used in core/users/gooyal_oauth2. Mattermost's own opaque identifiers (post_id, channel_id, mattermost_user_id) are left unchanged since they are not UUIDs. Co-Authored-By: Claude Sonnet 5 --- apps/chat/events/event.py | 4 +- apps/chat/events/publishers/longpoll.py | 4 +- apps/chat/events/publishers/websocket.py | 4 +- ...e_participant_per_conversation_and_more.py | 96 +++++++++++++++++++ apps/chat/models/conversation.py | 4 +- apps/chat/models/mapping.py | 6 +- apps/chat/models/participant.py | 12 +-- apps/chat/models/read_state.py | 12 +-- apps/chat/serializers/conversations.py | 12 +-- apps/chat/serializers/messages.py | 4 +- apps/chat/serializers/read_state.py | 6 +- apps/chat/services/account.py | 2 +- apps/chat/services/conversation.py | 30 +++--- apps/chat/services/message.py | 28 +++--- apps/chat/services/read_state.py | 14 +-- apps/chat/services/realtime.py | 4 +- apps/chat/tests/test_conversations.py | 18 ++-- apps/chat/tests/test_messages.py | 18 ++-- apps/chat/tests/test_read_state.py | 30 +++--- apps/chat/urls.py | 8 +- apps/chat/views/conversations.py | 18 ++-- apps/chat/views/messages.py | 10 +- apps/chat/views/read_state.py | 14 +-- apps/chat/websocket/consumers.py | 4 +- apps/chat/websocket/routing.py | 2 +- 25 files changed, 230 insertions(+), 134 deletions(-) create mode 100644 apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py diff --git a/apps/chat/events/event.py b/apps/chat/events/event.py index 5d4d448..85871c5 100644 --- a/apps/chat/events/event.py +++ b/apps/chat/events/event.py @@ -4,8 +4,8 @@ from uuid import UUID @dataclass class MessageSentEvent: - chat_id: UUID + chat_uuid: UUID post_id: str - sender_id: UUID + sender_uuid: UUID message_type: str payload: dict diff --git a/apps/chat/events/publishers/longpoll.py b/apps/chat/events/publishers/longpoll.py index 101487f..0afd568 100644 --- a/apps/chat/events/publishers/longpoll.py +++ b/apps/chat/events/publishers/longpoll.py @@ -10,11 +10,11 @@ _KEY_TTL = 300 class LongPollPublisher: def publish(self, event: MessageSentEvent) -> None: redis = get_redis_connection("default") - key = f"chat_events:{event.chat_id}" + key = f"chat_events:{event.chat_uuid}" payload = json.dumps( { "post_id": event.post_id, - "sender_id": str(event.sender_id), + "sender_uuid": str(event.sender_uuid), "message_type": event.message_type, **{k: v for k, v in event.payload.items() if v is not None}, } diff --git a/apps/chat/events/publishers/websocket.py b/apps/chat/events/publishers/websocket.py index 22e2f0f..934d8f1 100644 --- a/apps/chat/events/publishers/websocket.py +++ b/apps/chat/events/publishers/websocket.py @@ -7,13 +7,13 @@ from apps.chat.events.event import MessageSentEvent class WebSocketPublisher: def publish(self, event: MessageSentEvent) -> None: channel_layer = get_channel_layer() - group_name = f"chat_{event.chat_id}" + group_name = f"chat_{event.chat_uuid}" async_to_sync(channel_layer.group_send)( group_name, { "type": "chat.message", "post_id": event.post_id, - "sender_id": str(event.sender_id), + "sender_uuid": str(event.sender_uuid), "message_type": event.message_type, **event.payload, }, diff --git a/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py b/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py new file mode 100644 index 0000000..ae7a301 --- /dev/null +++ b/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py @@ -0,0 +1,96 @@ +# Generated by Django 5.2.13 on 2026-07-19 08:24 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0001_initial'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='conversationparticipant', + name='unique_participant_per_conversation', + ), + migrations.RemoveConstraint( + model_name='conversationreadstate', + name='unique_read_state_per_user', + ), + migrations.RemoveIndex( + model_name='conversationparticipant', + name='chat_conver_user_id_697713_idx', + ), + migrations.RemoveIndex( + model_name='conversationparticipant', + name='chat_conver_convers_59b4dd_idx', + ), + migrations.RemoveIndex( + model_name='conversationreadstate', + name='chat_conver_user_id_e7609f_idx', + ), + migrations.RemoveIndex( + model_name='conversationreadstate', + name='chat_conver_convers_9555aa_idx', + ), + migrations.RenameField( + model_name='conversation', + old_name='id', + new_name='uuid', + ), + migrations.RenameField( + model_name='conversationparticipant', + old_name='user_id', + new_name='user_uuid', + ), + migrations.RenameField( + model_name='conversationparticipant', + old_name='id', + new_name='uuid', + ), + migrations.RenameField( + model_name='conversationreadstate', + old_name='user_id', + new_name='user_uuid', + ), + migrations.RenameField( + model_name='conversationreadstate', + old_name='id', + new_name='uuid', + ), + migrations.RenameField( + model_name='mattermostaccountmapping', + old_name='user_id', + new_name='user_uuid', + ), + migrations.RenameField( + model_name='mattermostaccountmapping', + old_name='id', + new_name='uuid', + ), + migrations.AddIndex( + model_name='conversationparticipant', + index=models.Index(fields=['user_uuid'], name='chat_conver_user_uu_020064_idx'), + ), + migrations.AddIndex( + model_name='conversationparticipant', + index=models.Index(fields=['conversation', 'user_uuid'], name='chat_conver_convers_5e51b5_idx'), + ), + migrations.AddIndex( + model_name='conversationreadstate', + index=models.Index(fields=['user_uuid'], name='chat_conver_user_uu_8e7f04_idx'), + ), + migrations.AddIndex( + model_name='conversationreadstate', + index=models.Index(fields=['conversation', 'user_uuid'], name='chat_conver_convers_d7112b_idx'), + ), + migrations.AddConstraint( + model_name='conversationparticipant', + constraint=models.UniqueConstraint(fields=('conversation', 'user_uuid'), name='unique_participant_per_conversation'), + ), + migrations.AddConstraint( + model_name='conversationreadstate', + constraint=models.UniqueConstraint(fields=('conversation', 'user_uuid'), name='unique_read_state_per_user'), + ), + ] diff --git a/apps/chat/models/conversation.py b/apps/chat/models/conversation.py index d0f5314..c27ae4e 100644 --- a/apps/chat/models/conversation.py +++ b/apps/chat/models/conversation.py @@ -9,7 +9,7 @@ class ConversationType(models.TextChoices): class Conversation(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Internal only — never exposed through any public API or serializer mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True) type = models.CharField( @@ -27,4 +27,4 @@ class Conversation(models.Model): ] def __str__(self): - return str(self.id) + return str(self.uuid) diff --git a/apps/chat/models/mapping.py b/apps/chat/models/mapping.py index 184a86a..4254b97 100644 --- a/apps/chat/models/mapping.py +++ b/apps/chat/models/mapping.py @@ -4,8 +4,8 @@ from django.db import models class MattermostAccountMapping(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) - user_id = models.UUIDField(unique=True, db_index=True) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + user_uuid = models.UUIDField(unique=True, db_index=True) mattermost_user_id = models.CharField(max_length=255, unique=True, db_index=True) created_at = models.DateTimeField(auto_now_add=True) @@ -15,4 +15,4 @@ class MattermostAccountMapping(models.Model): ] def __str__(self): - return f"{self.user_id} -> {self.mattermost_user_id}" + return f"{self.user_uuid} -> {self.mattermost_user_id}" diff --git a/apps/chat/models/participant.py b/apps/chat/models/participant.py index 528c30d..be150dc 100644 --- a/apps/chat/models/participant.py +++ b/apps/chat/models/participant.py @@ -6,26 +6,26 @@ from apps.chat.models.conversation import Conversation class ConversationParticipant(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) conversation = models.ForeignKey( Conversation, related_name="participants", on_delete=models.CASCADE, ) - user_id = models.UUIDField() + user_uuid = models.UUIDField() joined_at = models.DateTimeField(auto_now_add=True) class Meta: constraints = [ models.UniqueConstraint( - fields=["conversation", "user_id"], + fields=["conversation", "user_uuid"], name="unique_participant_per_conversation", ) ] indexes = [ - models.Index(fields=["user_id"]), - models.Index(fields=["conversation", "user_id"]), + models.Index(fields=["user_uuid"]), + models.Index(fields=["conversation", "user_uuid"]), ] def __str__(self): - return f"{self.user_id} in {self.conversation_id}" + return f"{self.user_uuid} in {self.conversation_id}" diff --git a/apps/chat/models/read_state.py b/apps/chat/models/read_state.py index 259c75b..e28a356 100644 --- a/apps/chat/models/read_state.py +++ b/apps/chat/models/read_state.py @@ -6,27 +6,27 @@ from apps.chat.models.conversation import Conversation class ConversationReadState(models.Model): - id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) conversation = models.ForeignKey( Conversation, related_name="read_states", on_delete=models.CASCADE, ) - user_id = models.UUIDField() + user_uuid = models.UUIDField() last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True) updated_at = models.DateTimeField(auto_now=True) class Meta: constraints = [ models.UniqueConstraint( - fields=["conversation", "user_id"], + fields=["conversation", "user_uuid"], name="unique_read_state_per_user", ) ] indexes = [ - models.Index(fields=["user_id"]), - models.Index(fields=["conversation", "user_id"]), + models.Index(fields=["user_uuid"]), + models.Index(fields=["conversation", "user_uuid"]), ] def __str__(self): - return f"{self.user_id} read state for {self.conversation_id}" + return f"{self.user_uuid} read state for {self.conversation_id}" diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py index 4eaf918..10aa723 100644 --- a/apps/chat/serializers/conversations.py +++ b/apps/chat/serializers/conversations.py @@ -5,8 +5,8 @@ from apps.chat.models import Conversation, ConversationParticipant class CreateConversationSerializer(serializers.Serializer): - user_1_id = serializers.UUIDField() - user_2_id = serializers.UUIDField() + user_1_uuid = serializers.UUIDField() + user_2_uuid = serializers.UUIDField() class ConversationSerializer(serializers.ModelSerializer): @@ -14,19 +14,19 @@ class ConversationSerializer(serializers.ModelSerializer): class Meta: model = Conversation - fields = ["id", "type", "created_at", "participants"] + fields = ["uuid", "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) + ConversationParticipant.objects.filter(conversation_id=obj.uuid) .order_by("joined_at") - .values_list("user_id", flat=True) + .values_list("user_uuid", flat=True) ) class ConversationListSerializer(serializers.Serializer): - id = serializers.UUIDField() + uuid = serializers.UUIDField() type = serializers.CharField() created_at = serializers.DateTimeField() participants = serializers.ListField(child=serializers.UUIDField()) diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index fba8f8c..67bc47b 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -2,7 +2,7 @@ from rest_framework import serializers class SendMessageSerializer(serializers.Serializer): - sender_id = serializers.UUIDField() + sender_uuid = 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) @@ -10,7 +10,7 @@ class SendMessageSerializer(serializers.Serializer): class MessageSerializer(serializers.Serializer): post_id = serializers.CharField() - sender_id = serializers.UUIDField(allow_null=True) + sender_uuid = serializers.UUIDField(allow_null=True) message_type = serializers.CharField(default="text") text = serializers.CharField(allow_null=True, required=False) url = serializers.CharField(allow_null=True, required=False) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py index 9c3ecc6..c8c9137 100644 --- a/apps/chat/serializers/read_state.py +++ b/apps/chat/serializers/read_state.py @@ -2,11 +2,11 @@ from rest_framework import serializers class MarkReadSerializer(serializers.Serializer): - user_id = serializers.UUIDField() + user_uuid = serializers.UUIDField() post_id = serializers.CharField() class ReadStateSerializer(serializers.Serializer): - conversation_id = serializers.UUIDField() - user_id = serializers.UUIDField() + conversation_uuid = serializers.UUIDField() + user_uuid = serializers.UUIDField() has_unread = serializers.BooleanField() diff --git a/apps/chat/services/account.py b/apps/chat/services/account.py index ea4e885..163951f 100644 --- a/apps/chat/services/account.py +++ b/apps/chat/services/account.py @@ -2,6 +2,6 @@ from uuid import UUID class AccountService: - def validate_user(self, user_id: UUID) -> bool: + def validate_user(self, user_uuid: UUID) -> bool: # Future: call external Account Service HTTP API return True diff --git a/apps/chat/services/conversation.py b/apps/chat/services/conversation.py index 8a0fe75..5164d72 100644 --- a/apps/chat/services/conversation.py +++ b/apps/chat/services/conversation.py @@ -20,21 +20,21 @@ class ConversationService: self._account = account_service or AccountService() self._mm = mattermost_client or MattermostClient() - def create(self, user_1_id: UUID, user_2_id: UUID) -> Conversation: - if not self._account.validate_user(user_1_id): - raise ValueError(f"User {user_1_id} is not valid") - if not self._account.validate_user(user_2_id): - raise ValueError(f"User {user_2_id} is not valid") + def create(self, user_1_uuid: UUID, user_2_uuid: UUID) -> Conversation: + if not self._account.validate_user(user_1_uuid): + raise ValueError(f"User {user_1_uuid} is not valid") + if not self._account.validate_user(user_2_uuid): + raise ValueError(f"User {user_2_uuid} is not valid") - mm_user_1 = self._mm.get_or_create_user(user_1_id) - mm_user_2 = self._mm.get_or_create_user(user_2_id) + mm_user_1 = self._mm.get_or_create_user(user_1_uuid) + mm_user_2 = self._mm.get_or_create_user(user_2_uuid) MattermostAccountMapping.objects.get_or_create( - user_id=user_1_id, + user_uuid=user_1_uuid, defaults={"mattermost_user_id": mm_user_1}, ) MattermostAccountMapping.objects.get_or_create( - user_id=user_2_id, + user_uuid=user_2_uuid, defaults={"mattermost_user_id": mm_user_2}, ) @@ -43,23 +43,23 @@ class ConversationService: with transaction.atomic(): conversation = Conversation.objects.create(mattermost_channel_id=channel_id) ConversationParticipant.objects.create( - conversation=conversation, user_id=user_1_id + conversation=conversation, user_uuid=user_1_uuid ) ConversationParticipant.objects.create( - conversation=conversation, user_id=user_2_id + conversation=conversation, user_uuid=user_2_uuid ) return conversation def list_for_user( - self, user_id: UUID, page: int, page_size: int + self, user_uuid: UUID, page: int, page_size: int ) -> list[Conversation]: offset = page * page_size - conversation_ids = ConversationParticipant.objects.filter( - user_id=user_id + conversation_uuids = ConversationParticipant.objects.filter( + user_uuid=user_uuid ).values_list("conversation_id", flat=True) return list( - Conversation.objects.filter(id__in=conversation_ids).order_by( + Conversation.objects.filter(uuid__in=conversation_uuids).order_by( "-created_at" )[offset : offset + page_size] ) diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py index 947c477..e18218e 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -36,18 +36,18 @@ class MessageService: def send( self, - conversation_id, - sender_id: UUID, + conversation_uuid, + sender_uuid: UUID, message_type: str, text: str | None = None, file=None, ) -> dict: - if not self._account.validate_user(sender_id): - raise ValueError(f"Sender {sender_id} is not valid") + if not self._account.validate_user(sender_uuid): + raise ValueError(f"Sender {sender_uuid} is not valid") file_url: str | None = None if file is not None: - filename = getattr(file, "name", f"{sender_id}") + filename = getattr(file, "name", f"{sender_uuid}") file_url = self._storage.upload_file(file, filename) if message_type == "text": @@ -55,13 +55,13 @@ class MessageService: else: mm_message = file_url or text or "" - conversation = Conversation.objects.get(id=conversation_id) + conversation = Conversation.objects.get(uuid=conversation_uuid) post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message) event = MessageSentEvent( - chat_id=conversation_id, + chat_uuid=conversation_uuid, post_id=post_id, - sender_id=sender_id, + sender_uuid=sender_uuid, message_type=message_type, payload={"text": text, "file_url": file_url}, ) @@ -70,7 +70,7 @@ class MessageService: return { "post_id": post_id, - "sender_id": sender_id, + "sender_uuid": sender_uuid, "message_type": message_type, "text": text, "url": file_url, @@ -79,12 +79,12 @@ class MessageService: def list_messages( self, - conversation_id, + conversation_uuid, page: int, per_page: int, since: int | None = None, ) -> list[dict]: - conversation = Conversation.objects.get(id=conversation_id) + conversation = Conversation.objects.get(uuid=conversation_uuid) raw_posts = self._mm.get_posts( conversation.mattermost_channel_id, page=page, @@ -94,7 +94,7 @@ class MessageService: mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")} mappings = { - m.mattermost_user_id: m.user_id + m.mattermost_user_id: m.user_uuid for m in MattermostAccountMapping.objects.filter( mattermost_user_id__in=mm_user_ids ) @@ -105,7 +105,7 @@ class MessageService: @staticmethod def _normalize_post(post: dict, mappings: dict) -> dict: mm_uid = post.get("user_id") - sender_id = mappings.get(mm_uid) + sender_uuid = mappings.get(mm_uid) msg = post.get("message", "") is_url = msg.startswith("http") created_ms = post.get("create_at") @@ -116,7 +116,7 @@ class MessageService: ) return { "post_id": post.get("id"), - "sender_id": sender_id, + "sender_uuid": sender_uuid, "message_type": "file" if is_url else "text", "text": None if is_url else msg, "url": msg if is_url else None, diff --git a/apps/chat/services/read_state.py b/apps/chat/services/read_state.py index 679b9c9..8cb2a86 100644 --- a/apps/chat/services/read_state.py +++ b/apps/chat/services/read_state.py @@ -13,30 +13,30 @@ class ReadStateService: self._mm = mattermost_client or MattermostClient() def mark_read( - self, conversation_id, user_id: UUID, post_id: str + self, conversation_uuid, user_uuid: UUID, post_id: str ) -> ConversationReadState: read_state, _ = ConversationReadState.objects.update_or_create( - conversation_id=conversation_id, - user_id=user_id, + conversation_id=conversation_uuid, + user_uuid=user_uuid, defaults={"last_read_mattermost_post_id": post_id}, ) return read_state - def has_unread(self, conversation_id, user_id: UUID) -> bool: + def has_unread(self, conversation_uuid, user_uuid: UUID) -> bool: try: read_state = ConversationReadState.objects.get( - conversation_id=conversation_id, user_id=user_id + conversation_id=conversation_uuid, user_uuid=user_uuid ) except ConversationReadState.DoesNotExist: return True last_read = read_state.last_read_mattermost_post_id - cache_key = f"latest_post:{conversation_id}" + cache_key = f"latest_post:{conversation_uuid}" latest_post_id = cache.get(cache_key) if latest_post_id is None: - conversation = Conversation.objects.get(id=conversation_id) + conversation = Conversation.objects.get(uuid=conversation_uuid) latest_post_id = self._mm.get_latest_post_id( conversation.mattermost_channel_id ) diff --git a/apps/chat/services/realtime.py b/apps/chat/services/realtime.py index 190fe10..1d30ea1 100644 --- a/apps/chat/services/realtime.py +++ b/apps/chat/services/realtime.py @@ -3,9 +3,9 @@ from channels.layers import get_channel_layer class RealtimeService: - def publish_message(self, chat_id, payload: dict) -> None: + def publish_message(self, chat_uuid, payload: dict) -> None: channel_layer = get_channel_layer() - group_name = f"chat_{chat_id}" + group_name = f"chat_{chat_uuid}" async_to_sync(channel_layer.group_send)( group_name, {"type": "chat.message", **payload}, diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py index ec81a64..278a14e 100644 --- a/apps/chat/tests/test_conversations.py +++ b/apps/chat/tests/test_conversations.py @@ -26,13 +26,13 @@ def test_create_conversation_success(): svc = ConversationService(mattermost_client=_make_mm_client()) conv = svc.create(user_1, user_2) - assert Conversation.objects.filter(id=conv.id).exists() + assert Conversation.objects.filter(uuid=conv.uuid).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 + participant_uuids = set(participants.values_list("user_uuid", flat=True)) + assert user_1 in participant_uuids + assert user_2 in participant_uuids @pytest.mark.django_db @@ -44,8 +44,8 @@ def test_create_conversation_creates_mattermost_mapping(): 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) + mapping_1 = MattermostAccountMapping.objects.get(user_uuid=user_1) + mapping_2 = MattermostAccountMapping.objects.get(user_uuid=user_2) assert mapping_1.mattermost_user_id == "mm-user-aaa" assert mapping_2.mattermost_user_id == "mm-user-bbb" @@ -58,16 +58,16 @@ def test_list_for_user_returns_only_user_conversations(): conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target") ConversationParticipant.objects.create( - conversation=conv_with_user, user_id=target_user + conversation=conv_with_user, user_uuid=target_user ) conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other") ConversationParticipant.objects.create( - conversation=conv_without_user, user_id=other_user + conversation=conv_without_user, user_uuid=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 + assert result[0].uuid == conv_with_user.uuid diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index afb7fca..294d606 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -30,19 +30,19 @@ def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None): @pytest.mark.django_db def test_send_text_message(): conv = Conversation.objects.create(mattermost_channel_id="ch-send-text") - sender_id = uuid.uuid4() + sender_uuid = 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") + result = svc.send(conv.uuid, sender_uuid, "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.sender_uuid == sender_uuid assert event.message_type == "text" assert result["post_id"] == "post-abc" @@ -52,7 +52,7 @@ def test_send_text_message(): @pytest.mark.django_db def test_send_image_message(): conv = Conversation.objects.create(mattermost_channel_id="ch-send-image") - sender_id = uuid.uuid4() + sender_uuid = uuid.uuid4() file_url = "http://minio.local/chat/abc123/photo.jpg" svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) @@ -60,7 +60,7 @@ def test_send_image_message(): fake_file = Mock() fake_file.name = "photo.jpg" - result = svc.send(conv.id, sender_id, "image", file=fake_file) + result = svc.send(conv.uuid, sender_uuid, "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) @@ -74,7 +74,7 @@ def test_list_messages_normalized(): mm_user_id = "mm-user-xyz" MattermostAccountMapping.objects.create( - user_id=sender_uuid, mattermost_user_id=mm_user_id + user_uuid=sender_uuid, mattermost_user_id=mm_user_id ) raw_posts = [ @@ -97,20 +97,20 @@ def test_list_messages_normalized(): 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) + messages = svc.list_messages(conv.uuid, 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["sender_uuid"] == 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["sender_uuid"] == 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 5eeb480..93c8b18 100644 --- a/apps/chat/tests/test_read_state.py +++ b/apps/chat/tests/test_read_state.py @@ -14,29 +14,29 @@ def _make_conv(channel_id="ch-read"): @pytest.mark.django_db def test_mark_read_creates_read_state(): conv = _make_conv() - user_id = uuid.uuid4() + user_uuid = uuid.uuid4() svc = ReadStateService(mattermost_client=Mock()) - read_state = svc.mark_read(conv.id, user_id, "post-111") + read_state = svc.mark_read(conv.uuid, user_uuid, "post-111") - assert read_state.conversation_id == conv.id - assert read_state.user_id == user_id + assert read_state.conversation_id == conv.uuid + assert read_state.user_uuid == user_uuid assert read_state.last_read_mattermost_post_id == "post-111" assert ConversationReadState.objects.filter( - conversation=conv, user_id=user_id + conversation=conv, user_uuid=user_uuid ).count() == 1 @pytest.mark.django_db def test_mark_read_updates_existing(): conv = _make_conv() - user_id = uuid.uuid4() + user_uuid = 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") + svc.mark_read(conv.uuid, user_uuid, "post-old") + svc.mark_read(conv.uuid, user_uuid, "post-new") - rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id) + rows = ConversationReadState.objects.filter(conversation=conv, user_uuid=user_uuid) assert rows.count() == 1 assert rows.first().last_read_mattermost_post_id == "post-new" @@ -47,10 +47,10 @@ 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() + user_uuid = uuid.uuid4() ConversationReadState.objects.create( conversation=conv, - user_id=user_id, + user_uuid=user_uuid, last_read_mattermost_post_id="post-old", ) @@ -58,7 +58,7 @@ def test_has_unread_true(mock_cache): mm_client.get_latest_post_id.return_value = "post-new" svc = ReadStateService(mattermost_client=mm_client) - result = svc.has_unread(conv.id, user_id) + result = svc.has_unread(conv.uuid, user_uuid) assert result is True mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id) @@ -70,10 +70,10 @@ 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() + user_uuid = uuid.uuid4() ConversationReadState.objects.create( conversation=conv, - user_id=user_id, + user_uuid=user_uuid, last_read_mattermost_post_id="post-current", ) @@ -81,6 +81,6 @@ def test_has_unread_false(mock_cache): mm_client.get_latest_post_id.return_value = "post-current" svc = ReadStateService(mattermost_client=mm_client) - result = svc.has_unread(conv.id, user_id) + result = svc.has_unread(conv.uuid, user_uuid) assert result is False diff --git a/apps/chat/urls.py b/apps/chat/urls.py index f024578..b2e654c 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -9,8 +9,8 @@ from apps.chat.views.read_state import ChatEventsView, ReadStateView urlpatterns = [ path("api/chats/", ConversationCreateView.as_view(), name="chat-create"), - path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), - path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), - path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), - path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), + path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), + path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), + path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), ] diff --git a/apps/chat/views/conversations.py b/apps/chat/views/conversations.py index 0a5cd8c..7b57084 100644 --- a/apps/chat/views/conversations.py +++ b/apps/chat/views/conversations.py @@ -29,8 +29,8 @@ class ConversationCreateView(APIView): serializer.is_valid(raise_exception=True) conversation = ConversationService().create( - serializer.validated_data["user_1_id"], - serializer.validated_data["user_2_id"], + serializer.validated_data["user_1_uuid"], + serializer.validated_data["user_2_uuid"], ) return Response( @@ -50,8 +50,8 @@ class UserConversationListView(APIView): ], responses={200: ConversationListSerializer(many=True)}, ) - def get(self, request, user_id): - uid = UUID(str(user_id)) + def get(self, request, user_uuid): + uid = UUID(str(user_uuid)) page = int(request.query_params.get("page", 0)) page_size = int(request.query_params.get("page_size", 20)) @@ -64,25 +64,25 @@ class UserConversationListView(APIView): result = [] for conv in conversations: participants = list( - ConversationParticipant.objects.filter(conversation_id=conv.id) + ConversationParticipant.objects.filter(conversation_id=conv.uuid) .order_by("joined_at") - .values_list("user_id", flat=True) + .values_list("user_uuid", flat=True) ) try: - has_unread = read_svc.has_unread(conv.id, uid) + has_unread = read_svc.has_unread(conv.uuid, uid) except Exception: has_unread = False try: - last_msgs = msg_svc.list_messages(conv.id, page=0, per_page=1) + last_msgs = msg_svc.list_messages(conv.uuid, page=0, per_page=1) last_message = last_msgs[0] if last_msgs else None except Exception: last_message = None result.append( { - "id": conv.id, + "uuid": conv.uuid, "type": conv.type, "created_at": conv.created_at, "participants": participants, diff --git a/apps/chat/views/messages.py b/apps/chat/views/messages.py index 7c0eb06..3f0d606 100644 --- a/apps/chat/views/messages.py +++ b/apps/chat/views/messages.py @@ -17,14 +17,14 @@ class MessageView(APIView): request=SendMessageSerializer, responses={201: MessageSerializer}, ) - def post(self, request, chat_id): + def post(self, request, chat_uuid): serializer = SendMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data result = MessageService().send( - conversation_id=UUID(str(chat_id)), - sender_id=data["sender_id"], + conversation_uuid=UUID(str(chat_uuid)), + sender_uuid=data["sender_uuid"], message_type=data["message_type"], text=data.get("text"), file=data.get("file"), @@ -45,14 +45,14 @@ class MessageView(APIView): ], responses={200: MessageSerializer(many=True)}, ) - def get(self, request, chat_id): + def get(self, request, chat_uuid): page = int(request.query_params.get("page", 0)) per_page = int(request.query_params.get("per_page", 20)) since_raw = request.query_params.get("since") since = int(since_raw) if since_raw else None messages = MessageService().list_messages( - conversation_id=UUID(str(chat_id)), + conversation_uuid=UUID(str(chat_uuid)), page=page, per_page=per_page, since=since, diff --git a/apps/chat/views/read_state.py b/apps/chat/views/read_state.py index f63ef01..b477941 100644 --- a/apps/chat/views/read_state.py +++ b/apps/chat/views/read_state.py @@ -20,22 +20,22 @@ class ReadStateView(APIView): request=MarkReadSerializer, responses={200: ReadStateSerializer}, ) - def post(self, request, chat_id): + def post(self, request, chat_uuid): serializer = MarkReadSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data read_state = ReadStateService().mark_read( - conversation_id=UUID(str(chat_id)), - user_id=data["user_id"], + conversation_uuid=UUID(str(chat_uuid)), + user_uuid=data["user_uuid"], post_id=data["post_id"], ) return Response( ReadStateSerializer( { - "conversation_id": read_state.conversation_id, - "user_id": read_state.user_id, + "conversation_uuid": read_state.conversation_id, + "user_uuid": read_state.user_uuid, "has_unread": False, } ).data @@ -54,11 +54,11 @@ class ChatEventsView(APIView): "arrives; returns an empty list on timeout." ), ) - def get(self, request, chat_id): + def get(self, request, chat_uuid): timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25) redis = get_redis_connection("default") - key = f"chat_events:{chat_id}" + key = f"chat_events:{chat_uuid}" result = redis.blpop(key, timeout=timeout) diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py index d8866f8..9298984 100644 --- a/apps/chat/websocket/consumers.py +++ b/apps/chat/websocket/consumers.py @@ -5,8 +5,8 @@ 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}" + self.chat_uuid = self.scope["url_route"]["kwargs"]["chat_uuid"] + self.group_name = f"chat_{self.chat_uuid}" await self.channel_layer.group_add(self.group_name, self.channel_name) await self.accept() diff --git a/apps/chat/websocket/routing.py b/apps/chat/websocket/routing.py index 1f87234..3bd2b88 100644 --- a/apps/chat/websocket/routing.py +++ b/apps/chat/websocket/routing.py @@ -3,5 +3,5 @@ from django.urls import path from apps.chat.websocket.consumers import ChatConsumer websocket_urlpatterns = [ - path("ws/chat//", ChatConsumer.as_asgi()), + path("ws/chat//", ChatConsumer.as_asgi()), ] -- 2.45.3 From 9dd70f37d3663804b63456136cdc4543836e0033 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 19 Jul 2026 12:54:17 +0330 Subject: [PATCH 9/9] add close and reopen feature --- apps/chat/exceptions.py | 2 + ...ed_by_uuid_conversation_status_and_more.py | 27 +++++ apps/chat/models/__init__.py | 3 +- apps/chat/models/conversation.py | 13 ++ apps/chat/serializers/conversations.py | 8 +- apps/chat/services/conversation.py | 26 ++++ apps/chat/services/message.py | 8 +- apps/chat/tests/test_conversation_close.py | 112 ++++++++++++++++++ apps/chat/urls.py | 4 + apps/chat/views/conversations.py | 44 +++++++ apps/chat/views/messages.py | 20 ++-- 11 files changed, 255 insertions(+), 12 deletions(-) create mode 100644 apps/chat/exceptions.py create mode 100644 apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py create mode 100644 apps/chat/tests/test_conversation_close.py diff --git a/apps/chat/exceptions.py b/apps/chat/exceptions.py new file mode 100644 index 0000000..53b452c --- /dev/null +++ b/apps/chat/exceptions.py @@ -0,0 +1,2 @@ +class ConversationClosedError(Exception): + """Raised when attempting to send a message to a closed conversation.""" diff --git a/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py b/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py new file mode 100644 index 0000000..41abdd4 --- /dev/null +++ b/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.13 on 2026-07-19 09:05 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('chat', '0002_remove_conversationparticipant_unique_participant_per_conversation_and_more'), + ] + + operations = [ + migrations.AddField( + model_name='conversation', + name='closed_by_uuid', + field=models.UUIDField(blank=True, null=True), + ), + migrations.AddField( + model_name='conversation', + name='status', + field=models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', max_length=20), + ), + migrations.AddIndex( + model_name='conversation', + index=models.Index(fields=['status'], name='chat_conver_status_9f0685_idx'), + ), + ] diff --git a/apps/chat/models/__init__.py b/apps/chat/models/__init__.py index d9080a4..873fa0d 100644 --- a/apps/chat/models/__init__.py +++ b/apps/chat/models/__init__.py @@ -1,4 +1,4 @@ -from apps.chat.models.conversation import Conversation, ConversationType +from apps.chat.models.conversation import Conversation, ConversationStatus, ConversationType from apps.chat.models.mapping import MattermostAccountMapping from apps.chat.models.participant import ConversationParticipant from apps.chat.models.read_state import ConversationReadState @@ -6,6 +6,7 @@ from apps.chat.models.read_state import ConversationReadState __all__ = [ "Conversation", "ConversationType", + "ConversationStatus", "ConversationParticipant", "MattermostAccountMapping", "ConversationReadState", diff --git a/apps/chat/models/conversation.py b/apps/chat/models/conversation.py index c27ae4e..2f67bc1 100644 --- a/apps/chat/models/conversation.py +++ b/apps/chat/models/conversation.py @@ -8,6 +8,11 @@ class ConversationType(models.TextChoices): GROUP = "group", "Group" +class ConversationStatus(models.TextChoices): + OPEN = "open", "Open" + CLOSED = "closed", "Closed" + + class Conversation(models.Model): uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) # Internal only — never exposed through any public API or serializer @@ -17,6 +22,13 @@ class Conversation(models.Model): choices=ConversationType.choices, default=ConversationType.DIRECT, ) + status = models.CharField( + max_length=20, + choices=ConversationStatus.choices, + default=ConversationStatus.OPEN, + ) + # Set only while status is CLOSED; identifies who is allowed to reopen it. + closed_by_uuid = models.UUIDField(null=True, blank=True) created_at = models.DateTimeField(auto_now_add=True) updated_at = models.DateTimeField(auto_now=True) @@ -24,6 +36,7 @@ class Conversation(models.Model): indexes = [ models.Index(fields=["type"]), models.Index(fields=["created_at"]), + models.Index(fields=["status"]), ] def __str__(self): diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py index 10aa723..d757ecf 100644 --- a/apps/chat/serializers/conversations.py +++ b/apps/chat/serializers/conversations.py @@ -9,12 +9,16 @@ class CreateConversationSerializer(serializers.Serializer): user_2_uuid = serializers.UUIDField() +class ConversationUserActionSerializer(serializers.Serializer): + user_uuid = serializers.UUIDField() + + class ConversationSerializer(serializers.ModelSerializer): participants = serializers.SerializerMethodField() class Meta: model = Conversation - fields = ["uuid", "type", "created_at", "participants"] + fields = ["uuid", "type", "status", "closed_by_uuid", "created_at", "participants"] @extend_schema_field(serializers.ListField(child=serializers.UUIDField())) def get_participants(self, obj): @@ -28,6 +32,8 @@ class ConversationSerializer(serializers.ModelSerializer): class ConversationListSerializer(serializers.Serializer): uuid = serializers.UUIDField() type = serializers.CharField() + status = serializers.CharField() + closed_by_uuid = serializers.UUIDField(allow_null=True) created_at = serializers.DateTimeField() participants = serializers.ListField(child=serializers.UUIDField()) has_unread = serializers.BooleanField() diff --git a/apps/chat/services/conversation.py b/apps/chat/services/conversation.py index 5164d72..b11fe9b 100644 --- a/apps/chat/services/conversation.py +++ b/apps/chat/services/conversation.py @@ -6,6 +6,7 @@ from apps.chat.integrations.mattermost.client import MattermostClient from apps.chat.models import ( Conversation, ConversationParticipant, + ConversationStatus, MattermostAccountMapping, ) from apps.chat.services.account import AccountService @@ -63,3 +64,28 @@ class ConversationService: "-created_at" )[offset : offset + page_size] ) + + def close(self, conversation_uuid: UUID, user_uuid: UUID) -> Conversation: + conversation = Conversation.objects.get(uuid=conversation_uuid) + if conversation.status == ConversationStatus.CLOSED: + return conversation + + conversation.status = ConversationStatus.CLOSED + conversation.closed_by_uuid = user_uuid + conversation.save(update_fields=["status", "closed_by_uuid", "updated_at"]) + return conversation + + def reopen(self, conversation_uuid: UUID, user_uuid: UUID) -> Conversation: + conversation = Conversation.objects.get(uuid=conversation_uuid) + if conversation.status == ConversationStatus.OPEN: + return conversation + + if conversation.closed_by_uuid != user_uuid: + raise PermissionError( + "Only the user who closed this conversation can reopen it" + ) + + conversation.status = ConversationStatus.OPEN + conversation.closed_by_uuid = None + conversation.save(update_fields=["status", "closed_by_uuid", "updated_at"]) + return conversation diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py index e18218e..e2c7236 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -5,8 +5,9 @@ from apps.chat.events.event import MessageSentEvent from apps.chat.events.publishers.longpoll import LongPollPublisher from apps.chat.events.publishers.push import PushPublisher from apps.chat.events.publishers.websocket import WebSocketPublisher +from apps.chat.exceptions import ConversationClosedError from apps.chat.integrations.mattermost.client import MattermostClient -from apps.chat.models import Conversation, MattermostAccountMapping +from apps.chat.models import Conversation, ConversationStatus, MattermostAccountMapping from apps.chat.services.account import AccountService from apps.chat.services.storage import StorageService @@ -42,6 +43,10 @@ class MessageService: text: str | None = None, file=None, ) -> dict: + conversation = Conversation.objects.get(uuid=conversation_uuid) + if conversation.status == ConversationStatus.CLOSED: + raise ConversationClosedError(f"Conversation {conversation_uuid} is closed") + if not self._account.validate_user(sender_uuid): raise ValueError(f"Sender {sender_uuid} is not valid") @@ -55,7 +60,6 @@ class MessageService: else: mm_message = file_url or text or "" - conversation = Conversation.objects.get(uuid=conversation_uuid) post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message) event = MessageSentEvent( diff --git a/apps/chat/tests/test_conversation_close.py b/apps/chat/tests/test_conversation_close.py new file mode 100644 index 0000000..4092c29 --- /dev/null +++ b/apps/chat/tests/test_conversation_close.py @@ -0,0 +1,112 @@ +import uuid +from unittest.mock import Mock + +import pytest + +from apps.chat.exceptions import ConversationClosedError +from apps.chat.models import Conversation, ConversationStatus +from apps.chat.services.conversation import ConversationService +from apps.chat.services.message import MessageService + + +def _make_conv(status=ConversationStatus.OPEN, closed_by_uuid=None, channel_id="ch-close"): + return Conversation.objects.create( + mattermost_channel_id=channel_id, + status=status, + closed_by_uuid=closed_by_uuid, + ) + + +def _svc(): + return ConversationService(mattermost_client=Mock()) + + +@pytest.mark.django_db +def test_close_sets_status_and_closed_by(): + conv = _make_conv() + closer = uuid.uuid4() + + result = _svc().close(conv.uuid, closer) + + assert result.status == ConversationStatus.CLOSED + assert result.closed_by_uuid == closer + + conv.refresh_from_db() + assert conv.status == ConversationStatus.CLOSED + assert conv.closed_by_uuid == closer + + +@pytest.mark.django_db +def test_close_is_idempotent(): + closer = uuid.uuid4() + other = uuid.uuid4() + conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer) + + result = _svc().close(conv.uuid, other) + + assert result.status == ConversationStatus.CLOSED + assert result.closed_by_uuid == closer # unchanged — original closer preserved + + +@pytest.mark.django_db +def test_reopen_by_closer_succeeds(): + closer = uuid.uuid4() + conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer) + + result = _svc().reopen(conv.uuid, closer) + + assert result.status == ConversationStatus.OPEN + assert result.closed_by_uuid is None + + +@pytest.mark.django_db +def test_reopen_by_other_user_raises_permission_error(): + closer = uuid.uuid4() + other = uuid.uuid4() + conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer) + + with pytest.raises(PermissionError): + _svc().reopen(conv.uuid, other) + + conv.refresh_from_db() + assert conv.status == ConversationStatus.CLOSED + + +@pytest.mark.django_db +def test_reopen_is_idempotent_when_already_open(): + conv = _make_conv(status=ConversationStatus.OPEN) + someone = uuid.uuid4() + + result = _svc().reopen(conv.uuid, someone) + + assert result.status == ConversationStatus.OPEN + + +@pytest.mark.django_db +def test_send_message_to_closed_conversation_raises(): + closer = uuid.uuid4() + conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer) + sender_uuid = uuid.uuid4() + + svc = MessageService(mattermost_client=Mock(), storage_service=Mock(), publishers=[]) + + with pytest.raises(ConversationClosedError): + svc.send(conv.uuid, sender_uuid, "text", text="hello") + + +@pytest.mark.django_db +def test_send_message_after_reopen_succeeds(): + closer = uuid.uuid4() + conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer) + sender_uuid = uuid.uuid4() + + _svc().reopen(conv.uuid, closer) + + mm_client = Mock() + mm_client.post_message.return_value = "post-1" + svc = MessageService(mattermost_client=mm_client, storage_service=Mock(), publishers=[]) + + result = svc.send(conv.uuid, sender_uuid, "text", text="hello again") + + assert result["post_id"] == "post-1" + mm_client.post_message.assert_called_once_with("ch-close", "hello again") diff --git a/apps/chat/urls.py b/apps/chat/urls.py index b2e654c..1571a28 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -1,7 +1,9 @@ from django.urls import path from apps.chat.views.conversations import ( + ConversationCloseView, ConversationCreateView, + ConversationReopenView, UserConversationListView, ) from apps.chat.views.messages import MessageView @@ -13,4 +15,6 @@ urlpatterns = [ path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), + path("api/chats//close/", ConversationCloseView.as_view(), name="chat-close"), + path("api/chats//reopen/", ConversationReopenView.as_view(), name="chat-reopen"), ] diff --git a/apps/chat/views/conversations.py b/apps/chat/views/conversations.py index 7b57084..f1dc8e1 100644 --- a/apps/chat/views/conversations.py +++ b/apps/chat/views/conversations.py @@ -9,6 +9,7 @@ from apps.chat.models import ConversationParticipant from apps.chat.serializers.conversations import ( ConversationListSerializer, ConversationSerializer, + ConversationUserActionSerializer, CreateConversationSerializer, ) from apps.chat.services.conversation import ConversationService @@ -84,6 +85,8 @@ class UserConversationListView(APIView): { "uuid": conv.uuid, "type": conv.type, + "status": conv.status, + "closed_by_uuid": conv.closed_by_uuid, "created_at": conv.created_at, "participants": participants, "has_unread": has_unread, @@ -92,3 +95,44 @@ class UserConversationListView(APIView): ) return Response(ConversationListSerializer(result, many=True).data) + + +class ConversationCloseView(APIView): + authentication_classes = [] + permission_classes = [] + + @extend_schema( + request=ConversationUserActionSerializer, + responses={200: ConversationSerializer}, + ) + def post(self, request, chat_uuid): + serializer = ConversationUserActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + conversation = ConversationService().close( + UUID(str(chat_uuid)), serializer.validated_data["user_uuid"] + ) + + return Response(ConversationSerializer(conversation).data) + + +class ConversationReopenView(APIView): + authentication_classes = [] + permission_classes = [] + + @extend_schema( + request=ConversationUserActionSerializer, + responses={200: ConversationSerializer, 403: None}, + ) + def post(self, request, chat_uuid): + serializer = ConversationUserActionSerializer(data=request.data) + serializer.is_valid(raise_exception=True) + + try: + conversation = ConversationService().reopen( + UUID(str(chat_uuid)), serializer.validated_data["user_uuid"] + ) + except PermissionError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN) + + return Response(ConversationSerializer(conversation).data) diff --git a/apps/chat/views/messages.py b/apps/chat/views/messages.py index 3f0d606..4ea3da3 100644 --- a/apps/chat/views/messages.py +++ b/apps/chat/views/messages.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from apps.chat.exceptions import ConversationClosedError from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer from apps.chat.services.message import MessageService @@ -15,20 +16,23 @@ class MessageView(APIView): @extend_schema( request=SendMessageSerializer, - responses={201: MessageSerializer}, + responses={201: MessageSerializer, 403: None}, ) def post(self, request, chat_uuid): serializer = SendMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - result = MessageService().send( - conversation_uuid=UUID(str(chat_uuid)), - sender_uuid=data["sender_uuid"], - message_type=data["message_type"], - text=data.get("text"), - file=data.get("file"), - ) + try: + result = MessageService().send( + conversation_uuid=UUID(str(chat_uuid)), + sender_uuid=data["sender_uuid"], + message_type=data["message_type"], + text=data.get("text"), + file=data.get("file"), + ) + except ConversationClosedError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN) return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED) -- 2.45.3