feature/refactor_uuid #1
11 changed files with 255 additions and 12 deletions
2
apps/chat/exceptions.py
Normal file
2
apps/chat/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class ConversationClosedError(Exception):
|
||||
"""Raised when attempting to send a message to a closed conversation."""
|
||||
|
|
@ -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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
112
apps/chat/tests/test_conversation_close.py
Normal file
112
apps/chat/tests/test_conversation_close.py
Normal file
|
|
@ -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")
|
||||
|
|
@ -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/<uuid:chat_uuid>/messages/", MessageView.as_view(), name="chat-messages"),
|
||||
path("api/chats/<uuid:chat_uuid>/read/", ReadStateView.as_view(), name="chat-read"),
|
||||
path("api/chats/<uuid:chat_uuid>/events/", ChatEventsView.as_view(), name="chat-events"),
|
||||
path("api/chats/<uuid:chat_uuid>/close/", ConversationCloseView.as_view(), name="chat-close"),
|
||||
path("api/chats/<uuid:chat_uuid>/reopen/", ConversationReopenView.as_view(), name="chat-reopen"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,13 +16,14 @@ 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
|
||||
|
||||
try:
|
||||
result = MessageService().send(
|
||||
conversation_uuid=UUID(str(chat_uuid)),
|
||||
sender_uuid=data["sender_uuid"],
|
||||
|
|
@ -29,6 +31,8 @@ class MessageView(APIView):
|
|||
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)
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue