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) diff --git a/apps/chat/integrations/mattermost/client.py b/apps/chat/integrations/mattermost/client.py index 659a5ed..8f5724a 100644 --- a/apps/chat/integrations/mattermost/client.py +++ b/apps/chat/integrations/mattermost/client.py @@ -181,6 +181,8 @@ class MattermostClient: ) -> list[dict]: """ Fetch posts from a channel and return them in chronological order. + 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. diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index 86c0fa4..29ab299 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -29,6 +29,7 @@ class MessageSerializer(serializers.Serializer): post_id = serializers.CharField() sender_uuid = 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 d206cbe..2a75337 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -78,6 +78,7 @@ class MessageService: "post_id": post_id, "sender_uuid": sender_uuid, "message_type": message_type, + "mattermost_message_type": "", "text": text, "url": download_url, "created_at": None, @@ -125,6 +126,7 @@ class MessageService: "post_id": post.get("id"), "sender_uuid": sender_uuid, "message_type": message_type, + "mattermost_message_type": post.get("type", ""), "text": None, "url": self._storage.get_download_url(object_key), "created_at": created_at, @@ -134,6 +136,7 @@ class MessageService: "post_id": post.get("id"), "sender_uuid": sender_uuid, "message_type": "text", + "mattermost_message_type": post.get("type", ""), "text": msg, "url": None, "created_at": created_at, diff --git a/apps/chat/tests/test_mattermost_client.py b/apps/chat/tests/test_mattermost_client.py new file mode 100644 index 0000000..23d8b52 --- /dev/null +++ b/apps/chat/tests/test_mattermost_client.py @@ -0,0 +1,63 @@ +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_keeps_chronological_order(): + # Mattermost order is newest-first. + 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_posts_includes_system_posts_with_raw_type(): + 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", "sys1"] + assert result[1]["type"] == "system_add_to_channel" + + +def test_get_latest_post_id_returns_newest_post_id(): + 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" + + +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 5f61b9b..d0126c4 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -91,6 +91,13 @@ def test_list_messages_normalized(): "message": f"voice:{object_key}", "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, + }, ] download_url = "http://minio.local/chat/voice/def456/clip.ogg?X-Amz-Signature=..." @@ -101,12 +108,13 @@ def test_list_messages_normalized(): messages = svc.list_messages(conv.uuid, 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_uuid"] == 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 @@ -114,11 +122,17 @@ def test_list_messages_normalized(): assert voice_msg["post_id"] == "p2" assert voice_msg["sender_uuid"] == sender_uuid assert voice_msg["message_type"] == "voice" + assert voice_msg["mattermost_message_type"] == "" assert voice_msg["url"] == download_url assert voice_msg["text"] is None storage.get_download_url.assert_called_once_with(object_key) + system_msg = messages[2] + assert system_msg["post_id"] == "sys1" + assert system_msg["sender_uuid"] == sender_uuid + assert system_msg["mattermost_message_type"] == "system_add_to_channel" + @pytest.mark.django_db def test_list_messages_treats_colon_in_plain_text_as_text():