feature/refactor_uuid #1

Merged
Ghasemi merged 9 commits from feature/refactor_uuid into master 2026-07-19 05:49:06 -04:00
11 changed files with 255 additions and 12 deletions
Showing only changes of commit 9dd70f37d3 - Show all commits

2
apps/chat/exceptions.py Normal file
View file

@ -0,0 +1,2 @@
class ConversationClosedError(Exception):
"""Raised when attempting to send a message to a closed conversation."""

View file

@ -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'),
),
]

View file

@ -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.mapping import MattermostAccountMapping
from apps.chat.models.participant import ConversationParticipant from apps.chat.models.participant import ConversationParticipant
from apps.chat.models.read_state import ConversationReadState from apps.chat.models.read_state import ConversationReadState
@ -6,6 +6,7 @@ from apps.chat.models.read_state import ConversationReadState
__all__ = [ __all__ = [
"Conversation", "Conversation",
"ConversationType", "ConversationType",
"ConversationStatus",
"ConversationParticipant", "ConversationParticipant",
"MattermostAccountMapping", "MattermostAccountMapping",
"ConversationReadState", "ConversationReadState",

View file

@ -8,6 +8,11 @@ class ConversationType(models.TextChoices):
GROUP = "group", "Group" GROUP = "group", "Group"
class ConversationStatus(models.TextChoices):
OPEN = "open", "Open"
CLOSED = "closed", "Closed"
class Conversation(models.Model): class Conversation(models.Model):
uuid = 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 # Internal only — never exposed through any public API or serializer
@ -17,6 +22,13 @@ class Conversation(models.Model):
choices=ConversationType.choices, choices=ConversationType.choices,
default=ConversationType.DIRECT, 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) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -24,6 +36,7 @@ class Conversation(models.Model):
indexes = [ indexes = [
models.Index(fields=["type"]), models.Index(fields=["type"]),
models.Index(fields=["created_at"]), models.Index(fields=["created_at"]),
models.Index(fields=["status"]),
] ]
def __str__(self): def __str__(self):

View file

@ -9,12 +9,16 @@ class CreateConversationSerializer(serializers.Serializer):
user_2_uuid = serializers.UUIDField() user_2_uuid = serializers.UUIDField()
class ConversationUserActionSerializer(serializers.Serializer):
user_uuid = serializers.UUIDField()
class ConversationSerializer(serializers.ModelSerializer): class ConversationSerializer(serializers.ModelSerializer):
participants = serializers.SerializerMethodField() participants = serializers.SerializerMethodField()
class Meta: class Meta:
model = Conversation 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())) @extend_schema_field(serializers.ListField(child=serializers.UUIDField()))
def get_participants(self, obj): def get_participants(self, obj):
@ -28,6 +32,8 @@ class ConversationSerializer(serializers.ModelSerializer):
class ConversationListSerializer(serializers.Serializer): class ConversationListSerializer(serializers.Serializer):
uuid = serializers.UUIDField() uuid = serializers.UUIDField()
type = serializers.CharField() type = serializers.CharField()
status = serializers.CharField()
closed_by_uuid = serializers.UUIDField(allow_null=True)
created_at = serializers.DateTimeField() created_at = serializers.DateTimeField()
participants = serializers.ListField(child=serializers.UUIDField()) participants = serializers.ListField(child=serializers.UUIDField())
has_unread = serializers.BooleanField() has_unread = serializers.BooleanField()

View file

@ -6,6 +6,7 @@ from apps.chat.integrations.mattermost.client import MattermostClient
from apps.chat.models import ( from apps.chat.models import (
Conversation, Conversation,
ConversationParticipant, ConversationParticipant,
ConversationStatus,
MattermostAccountMapping, MattermostAccountMapping,
) )
from apps.chat.services.account import AccountService from apps.chat.services.account import AccountService
@ -63,3 +64,28 @@ class ConversationService:
"-created_at" "-created_at"
)[offset : offset + page_size] )[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

View file

@ -5,8 +5,9 @@ from apps.chat.events.event import MessageSentEvent
from apps.chat.events.publishers.longpoll import LongPollPublisher from apps.chat.events.publishers.longpoll import LongPollPublisher
from apps.chat.events.publishers.push import PushPublisher from apps.chat.events.publishers.push import PushPublisher
from apps.chat.events.publishers.websocket import WebSocketPublisher 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.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.account import AccountService
from apps.chat.services.storage import StorageService from apps.chat.services.storage import StorageService
@ -42,6 +43,10 @@ class MessageService:
text: str | None = None, text: str | None = None,
file=None, file=None,
) -> dict: ) -> 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): if not self._account.validate_user(sender_uuid):
raise ValueError(f"Sender {sender_uuid} is not valid") raise ValueError(f"Sender {sender_uuid} is not valid")
@ -55,7 +60,6 @@ class MessageService:
else: else:
mm_message = file_url or text or "" 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) post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message)
event = MessageSentEvent( event = MessageSentEvent(

View 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")

View file

@ -1,7 +1,9 @@
from django.urls import path from django.urls import path
from apps.chat.views.conversations import ( from apps.chat.views.conversations import (
ConversationCloseView,
ConversationCreateView, ConversationCreateView,
ConversationReopenView,
UserConversationListView, UserConversationListView,
) )
from apps.chat.views.messages import MessageView 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>/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>/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>/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"),
] ]

View file

@ -9,6 +9,7 @@ from apps.chat.models import ConversationParticipant
from apps.chat.serializers.conversations import ( from apps.chat.serializers.conversations import (
ConversationListSerializer, ConversationListSerializer,
ConversationSerializer, ConversationSerializer,
ConversationUserActionSerializer,
CreateConversationSerializer, CreateConversationSerializer,
) )
from apps.chat.services.conversation import ConversationService from apps.chat.services.conversation import ConversationService
@ -84,6 +85,8 @@ class UserConversationListView(APIView):
{ {
"uuid": conv.uuid, "uuid": conv.uuid,
"type": conv.type, "type": conv.type,
"status": conv.status,
"closed_by_uuid": conv.closed_by_uuid,
"created_at": conv.created_at, "created_at": conv.created_at,
"participants": participants, "participants": participants,
"has_unread": has_unread, "has_unread": has_unread,
@ -92,3 +95,44 @@ class UserConversationListView(APIView):
) )
return Response(ConversationListSerializer(result, many=True).data) 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)

View file

@ -5,6 +5,7 @@ from rest_framework import status
from rest_framework.response import Response from rest_framework.response import Response
from rest_framework.views import APIView from rest_framework.views import APIView
from apps.chat.exceptions import ConversationClosedError
from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer
from apps.chat.services.message import MessageService from apps.chat.services.message import MessageService
@ -15,20 +16,23 @@ class MessageView(APIView):
@extend_schema( @extend_schema(
request=SendMessageSerializer, request=SendMessageSerializer,
responses={201: MessageSerializer}, responses={201: MessageSerializer, 403: None},
) )
def post(self, request, chat_uuid): def post(self, request, chat_uuid):
serializer = SendMessageSerializer(data=request.data) serializer = SendMessageSerializer(data=request.data)
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
data = serializer.validated_data data = serializer.validated_data
result = MessageService().send( try:
conversation_uuid=UUID(str(chat_uuid)), result = MessageService().send(
sender_uuid=data["sender_uuid"], conversation_uuid=UUID(str(chat_uuid)),
message_type=data["message_type"], sender_uuid=data["sender_uuid"],
text=data.get("text"), message_type=data["message_type"],
file=data.get("file"), 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) return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)