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)