From 5dcfec3e80ba3ebad6fc887fc5f9849a80b481b0 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Sun, 12 Jul 2026 16:30:53 +0330 Subject: [PATCH 1/3] filter system message on starting the chat --- apps/chat/integrations/mattermost/client.py | 30 ++++++-- apps/chat/tests/test_mattermost_client.py | 77 +++++++++++++++++++++ 2 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 apps/chat/tests/test_mattermost_client.py diff --git a/apps/chat/integrations/mattermost/client.py b/apps/chat/integrations/mattermost/client.py index 659a5ed..bafb5ed 100644 --- a/apps/chat/integrations/mattermost/client.py +++ b/apps/chat/integrations/mattermost/client.py @@ -156,6 +156,15 @@ class MattermostClient: return channel_id + @staticmethod + def _is_system_post(post: dict) -> bool: + """ + Mattermost auto-generates posts like "user-x added to the channel" + (type e.g. "system_add_to_channel") whenever channel membership + changes. Real user-sent posts have an empty type. + """ + return post.get("type", "").startswith("system_") + def post_message(self, channel_id: str, message: str) -> str: """ Post a message to the given channel using the admin token. @@ -181,6 +190,8 @@ class MattermostClient: ) -> list[dict]: """ Fetch posts from a channel and return them in chronological order. + System posts (channel membership changes, etc.) are excluded — only + real user-sent posts are returned. `since` is a Unix timestamp in **milliseconds**. When provided, `page` is ignored and all posts after that timestamp are returned. @@ -200,19 +211,30 @@ class MattermostClient: 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] + return [ + posts_map[pid] + for pid in reversed(order) + if pid in posts_map and not self._is_system_post(posts_map[pid]) + ] 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. + Return the ID of the most recent real (non-system) post in the + channel, or None if there are none yet. 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} + channel_id, params={"page": 0, "per_page": 20} ) 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 + posts_map: dict = data.get("posts", {}) + + for pid in order: # newest-first + post = posts_map.get(pid) + if post and not self._is_system_post(post): + return pid + return None diff --git a/apps/chat/tests/test_mattermost_client.py b/apps/chat/tests/test_mattermost_client.py new file mode 100644 index 0000000..91a1924 --- /dev/null +++ b/apps/chat/tests/test_mattermost_client.py @@ -0,0 +1,77 @@ +from unittest.mock import Mock + +from apps.chat.integrations.mattermost.client import MattermostClient + + +def _make_client(driver_response: dict) -> MattermostClient: + """Build a MattermostClient without hitting the network (no __init__).""" + client = MattermostClient.__new__(MattermostClient) + client._driver = Mock() + client._driver.posts.get_posts_for_channel.return_value = driver_response + return client + + +def _post(post_id, msg="hi", post_type="", user_id="u1"): + return { + "id": post_id, + "type": post_type, + "message": msg, + "user_id": user_id, + "create_at": 1700000000000, + } + + +def test_get_posts_excludes_system_posts(): + # Mattermost order is newest-first. + posts_map = { + "p1": _post("p1", "real message"), + "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), + } + client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) + + result = client.get_posts("chan-1") + + assert [p["id"] for p in result] == ["p1"] + + +def test_get_posts_keeps_chronological_order_with_no_system_posts(): + posts_map = { + "p1": _post("p1", "first"), + "p2": _post("p2", "second"), + } + client = _make_client({"order": ["p2", "p1"], "posts": posts_map}) + + result = client.get_posts("chan-1") + + assert [p["id"] for p in result] == ["p1", "p2"] + + +def test_get_latest_post_id_skips_leading_system_post(): + # sys1 is newest (e.g. someone was just added), p1 is the latest real message. + posts_map = { + "sys1": _post("sys1", "user-y added to the channel", post_type="system_add_to_channel"), + "p1": _post("p1", "real message"), + } + client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") == "p1" + + +def test_get_latest_post_id_returns_none_when_only_system_posts_exist(): + posts_map = { + "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), + "sys2": _post("sys2", "user-y added to the channel", post_type="system_add_to_channel"), + } + client = _make_client({"order": ["sys2", "sys1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") is None + + +def test_get_latest_post_id_returns_newest_real_post_when_no_system_posts(): + posts_map = { + "p1": _post("p1", "first"), + "p2": _post("p2", "second"), + } + client = _make_client({"order": ["p2", "p1"], "posts": posts_map}) + + assert client.get_latest_post_id("chan-1") == "p2" From 9f318aa6cca707f355df5abe5a8a742c1c0714e0 Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 21 Jul 2026 14:45:10 +0330 Subject: [PATCH 2/3] filter messages on client side --- apps/chat/integrations/mattermost/client.py | 32 +++------------- apps/chat/serializers/messages.py | 1 + apps/chat/services/message.py | 2 + apps/chat/tests/test_mattermost_client.py | 42 +++++++-------------- apps/chat/tests/test_messages.py | 15 +++++++- 5 files changed, 37 insertions(+), 55 deletions(-) diff --git a/apps/chat/integrations/mattermost/client.py b/apps/chat/integrations/mattermost/client.py index bafb5ed..8f5724a 100644 --- a/apps/chat/integrations/mattermost/client.py +++ b/apps/chat/integrations/mattermost/client.py @@ -156,15 +156,6 @@ class MattermostClient: return channel_id - @staticmethod - def _is_system_post(post: dict) -> bool: - """ - Mattermost auto-generates posts like "user-x added to the channel" - (type e.g. "system_add_to_channel") whenever channel membership - changes. Real user-sent posts have an empty type. - """ - return post.get("type", "").startswith("system_") - def post_message(self, channel_id: str, message: str) -> str: """ Post a message to the given channel using the admin token. @@ -190,8 +181,8 @@ class MattermostClient: ) -> list[dict]: """ Fetch posts from a channel and return them in chronological order. - System posts (channel membership changes, etc.) are excluded — only - real user-sent posts are returned. + Includes Mattermost system posts (channel membership changes, etc.) + as-is, with their raw `type` intact — callers filter if they need to. `since` is a Unix timestamp in **milliseconds**. When provided, `page` is ignored and all posts after that timestamp are returned. @@ -211,30 +202,19 @@ class MattermostClient: 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 and not self._is_system_post(posts_map[pid]) - ] + 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 real (non-system) post in the - channel, or None if there are none yet. + 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": 20} + 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", []) - posts_map: dict = data.get("posts", {}) - - for pid in order: # newest-first - post = posts_map.get(pid) - if post and not self._is_system_post(post): - return pid - return None + return order[0] if order else None diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index fba8f8c..8c493be 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -12,6 +12,7 @@ class MessageSerializer(serializers.Serializer): post_id = serializers.CharField() sender_id = serializers.UUIDField(allow_null=True) message_type = serializers.CharField(default="text") + mattermost_message_type = serializers.CharField(default="", allow_blank=True) 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/services/message.py b/apps/chat/services/message.py index 947c477..cee7faa 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -72,6 +72,7 @@ class MessageService: "post_id": post_id, "sender_id": sender_id, "message_type": message_type, + "mattermost_message_type": "", "text": text, "url": file_url, "created_at": None, @@ -118,6 +119,7 @@ class MessageService: "post_id": post.get("id"), "sender_id": sender_id, "message_type": "file" if is_url else "text", + "mattermost_message_type": post.get("type", ""), "text": None if is_url else msg, "url": msg if is_url else None, "created_at": created_at, diff --git a/apps/chat/tests/test_mattermost_client.py b/apps/chat/tests/test_mattermost_client.py index 91a1924..23d8b52 100644 --- a/apps/chat/tests/test_mattermost_client.py +++ b/apps/chat/tests/test_mattermost_client.py @@ -21,20 +21,8 @@ def _post(post_id, msg="hi", post_type="", user_id="u1"): } -def test_get_posts_excludes_system_posts(): +def test_get_posts_keeps_chronological_order(): # Mattermost order is newest-first. - posts_map = { - "p1": _post("p1", "real message"), - "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), - } - client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) - - result = client.get_posts("chan-1") - - assert [p["id"] for p in result] == ["p1"] - - -def test_get_posts_keeps_chronological_order_with_no_system_posts(): posts_map = { "p1": _post("p1", "first"), "p2": _post("p2", "second"), @@ -46,28 +34,20 @@ def test_get_posts_keeps_chronological_order_with_no_system_posts(): assert [p["id"] for p in result] == ["p1", "p2"] -def test_get_latest_post_id_skips_leading_system_post(): - # sys1 is newest (e.g. someone was just added), p1 is the latest real message. +def test_get_posts_includes_system_posts_with_raw_type(): posts_map = { - "sys1": _post("sys1", "user-y added to the channel", post_type="system_add_to_channel"), "p1": _post("p1", "real message"), + "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), } client = _make_client({"order": ["sys1", "p1"], "posts": posts_map}) - assert client.get_latest_post_id("chan-1") == "p1" + result = client.get_posts("chan-1") + + assert [p["id"] for p in result] == ["p1", "sys1"] + assert result[1]["type"] == "system_add_to_channel" -def test_get_latest_post_id_returns_none_when_only_system_posts_exist(): - posts_map = { - "sys1": _post("sys1", "user-x added to the channel", post_type="system_add_to_channel"), - "sys2": _post("sys2", "user-y added to the channel", post_type="system_add_to_channel"), - } - client = _make_client({"order": ["sys2", "sys1"], "posts": posts_map}) - - assert client.get_latest_post_id("chan-1") is None - - -def test_get_latest_post_id_returns_newest_real_post_when_no_system_posts(): +def test_get_latest_post_id_returns_newest_post_id(): posts_map = { "p1": _post("p1", "first"), "p2": _post("p2", "second"), @@ -75,3 +55,9 @@ def test_get_latest_post_id_returns_newest_real_post_when_no_system_posts(): client = _make_client({"order": ["p2", "p1"], "posts": posts_map}) assert client.get_latest_post_id("chan-1") == "p2" + + +def test_get_latest_post_id_returns_none_when_empty(): + client = _make_client({"order": [], "posts": {}}) + + assert client.get_latest_post_id("chan-1") is None diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index afb7fca..3b56fb6 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -90,6 +90,13 @@ def test_list_messages_normalized(): "message": "http://minio.local/chat/file.pdf", "create_at": 1700000001000, }, + { + "id": "sys1", + "type": "system_add_to_channel", + "user_id": mm_user_id, + "message": "user-x added to the channel", + "create_at": 1700000002000, + }, ] mm_client = Mock() @@ -99,12 +106,13 @@ def test_list_messages_normalized(): messages = svc.list_messages(conv.id, page=0, per_page=20) - assert len(messages) == 2 + assert len(messages) == 3 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["mattermost_message_type"] == "" assert text_msg["text"] == "First message" assert text_msg["url"] is None @@ -112,5 +120,10 @@ def test_list_messages_normalized(): assert file_msg["post_id"] == "p2" assert file_msg["sender_id"] == sender_uuid assert file_msg["message_type"] == "file" + assert file_msg["mattermost_message_type"] == "" assert file_msg["url"] == "http://minio.local/chat/file.pdf" assert file_msg["text"] is None + + system_msg = messages[2] + assert system_msg["post_id"] == "sys1" + assert system_msg["mattermost_message_type"] == "system_add_to_channel" From 9a73badb3cf9ee0cbe6af695ccf9e07b8121313b Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Tue, 21 Jul 2026 15:31:57 +0330 Subject: [PATCH 3/3] add admin panel --- apps/chat/admin.py | 62 +++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/apps/chat/admin.py b/apps/chat/admin.py index a389924..40ecaaf 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -1,6 +1,66 @@ from django.contrib import admin -from apps.chat.models import ConversationReport +from apps.chat.models import ( + Conversation, + ConversationParticipant, + ConversationReadState, + ConversationReport, + MattermostAccountMapping, +) + + +class ConversationParticipantInline(admin.TabularInline): + model = ConversationParticipant + extra = 0 + fields = ("user_uuid", "joined_at") + readonly_fields = ("joined_at",) + + +@admin.register(Conversation) +class ConversationAdmin(admin.ModelAdmin): + list_display = ( + "uuid", + "type", + "status", + "mattermost_channel_id", + "closed_by_uuid", + "created_at", + ) + list_filter = ("type", "status", "created_at") + search_fields = ("uuid", "mattermost_channel_id") + readonly_fields = ("uuid", "created_at", "updated_at") + ordering = ("-created_at",) + inlines = [ConversationParticipantInline] + + +@admin.register(ConversationParticipant) +class ConversationParticipantAdmin(admin.ModelAdmin): + list_display = ("uuid", "conversation", "user_uuid", "joined_at") + search_fields = ("uuid", "conversation__uuid", "user_uuid") + readonly_fields = ("uuid", "joined_at") + ordering = ("-joined_at",) + + +@admin.register(ConversationReadState) +class ConversationReadStateAdmin(admin.ModelAdmin): + list_display = ( + "uuid", + "conversation", + "user_uuid", + "last_read_mattermost_post_id", + "updated_at", + ) + search_fields = ("uuid", "conversation__uuid", "user_uuid") + readonly_fields = ("uuid", "updated_at") + ordering = ("-updated_at",) + + +@admin.register(MattermostAccountMapping) +class MattermostAccountMappingAdmin(admin.ModelAdmin): + list_display = ("uuid", "user_uuid", "mattermost_user_id", "created_at") + search_fields = ("uuid", "user_uuid", "mattermost_user_id") + readonly_fields = ("uuid", "created_at") + ordering = ("-created_at",) @admin.register(ConversationReport)