Merge branch 'master' into feature/minio-integration

# Conflicts:
#	apps/chat/serializers/messages.py
#	apps/chat/services/message.py
#	apps/chat/tests/test_messages.py
#	apps/chat/views/messages.py
This commit is contained in:
Ali Asadi 2026-07-20 12:27:34 +03:30
commit e4cab3ab62
30 changed files with 482 additions and 163 deletions

View file

@ -4,8 +4,8 @@ from uuid import UUID
@dataclass
class MessageSentEvent:
chat_id: UUID
chat_uuid: UUID
post_id: str
sender_id: UUID
sender_uuid: UUID
message_type: str
payload: dict

View file

@ -10,11 +10,11 @@ _KEY_TTL = 300
class LongPollPublisher:
def publish(self, event: MessageSentEvent) -> None:
redis = get_redis_connection("default")
key = f"chat_events:{event.chat_id}"
key = f"chat_events:{event.chat_uuid}"
payload = json.dumps(
{
"post_id": event.post_id,
"sender_id": str(event.sender_id),
"sender_uuid": str(event.sender_uuid),
"message_type": event.message_type,
**{k: v for k, v in event.payload.items() if v is not None},
}

View file

@ -7,13 +7,13 @@ from apps.chat.events.event import MessageSentEvent
class WebSocketPublisher:
def publish(self, event: MessageSentEvent) -> None:
channel_layer = get_channel_layer()
group_name = f"chat_{event.chat_id}"
group_name = f"chat_{event.chat_uuid}"
async_to_sync(channel_layer.group_send)(
group_name,
{
"type": "chat.message",
"post_id": event.post_id,
"sender_id": str(event.sender_id),
"sender_uuid": str(event.sender_uuid),
"message_type": event.message_type,
**event.payload,
},

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,96 @@
# Generated by Django 5.2.13 on 2026-07-19 08:24
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0001_initial'),
]
operations = [
migrations.RemoveConstraint(
model_name='conversationparticipant',
name='unique_participant_per_conversation',
),
migrations.RemoveConstraint(
model_name='conversationreadstate',
name='unique_read_state_per_user',
),
migrations.RemoveIndex(
model_name='conversationparticipant',
name='chat_conver_user_id_697713_idx',
),
migrations.RemoveIndex(
model_name='conversationparticipant',
name='chat_conver_convers_59b4dd_idx',
),
migrations.RemoveIndex(
model_name='conversationreadstate',
name='chat_conver_user_id_e7609f_idx',
),
migrations.RemoveIndex(
model_name='conversationreadstate',
name='chat_conver_convers_9555aa_idx',
),
migrations.RenameField(
model_name='conversation',
old_name='id',
new_name='uuid',
),
migrations.RenameField(
model_name='conversationparticipant',
old_name='user_id',
new_name='user_uuid',
),
migrations.RenameField(
model_name='conversationparticipant',
old_name='id',
new_name='uuid',
),
migrations.RenameField(
model_name='conversationreadstate',
old_name='user_id',
new_name='user_uuid',
),
migrations.RenameField(
model_name='conversationreadstate',
old_name='id',
new_name='uuid',
),
migrations.RenameField(
model_name='mattermostaccountmapping',
old_name='user_id',
new_name='user_uuid',
),
migrations.RenameField(
model_name='mattermostaccountmapping',
old_name='id',
new_name='uuid',
),
migrations.AddIndex(
model_name='conversationparticipant',
index=models.Index(fields=['user_uuid'], name='chat_conver_user_uu_020064_idx'),
),
migrations.AddIndex(
model_name='conversationparticipant',
index=models.Index(fields=['conversation', 'user_uuid'], name='chat_conver_convers_5e51b5_idx'),
),
migrations.AddIndex(
model_name='conversationreadstate',
index=models.Index(fields=['user_uuid'], name='chat_conver_user_uu_8e7f04_idx'),
),
migrations.AddIndex(
model_name='conversationreadstate',
index=models.Index(fields=['conversation', 'user_uuid'], name='chat_conver_convers_d7112b_idx'),
),
migrations.AddConstraint(
model_name='conversationparticipant',
constraint=models.UniqueConstraint(fields=('conversation', 'user_uuid'), name='unique_participant_per_conversation'),
),
migrations.AddConstraint(
model_name='conversationreadstate',
constraint=models.UniqueConstraint(fields=('conversation', 'user_uuid'), name='unique_read_state_per_user'),
),
]

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.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",

View file

@ -8,8 +8,13 @@ class ConversationType(models.TextChoices):
GROUP = "group", "Group"
class ConversationStatus(models.TextChoices):
OPEN = "open", "Open"
CLOSED = "closed", "Closed"
class Conversation(models.Model):
id = 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
mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True)
type = models.CharField(
@ -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,7 +36,8 @@ class Conversation(models.Model):
indexes = [
models.Index(fields=["type"]),
models.Index(fields=["created_at"]),
models.Index(fields=["status"]),
]
def __str__(self):
return str(self.id)
return str(self.uuid)

View file

@ -4,8 +4,8 @@ from django.db import models
class MattermostAccountMapping(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user_id = models.UUIDField(unique=True, db_index=True)
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user_uuid = models.UUIDField(unique=True, db_index=True)
mattermost_user_id = models.CharField(max_length=255, unique=True, db_index=True)
created_at = models.DateTimeField(auto_now_add=True)
@ -15,4 +15,4 @@ class MattermostAccountMapping(models.Model):
]
def __str__(self):
return f"{self.user_id} -> {self.mattermost_user_id}"
return f"{self.user_uuid} -> {self.mattermost_user_id}"

View file

@ -6,26 +6,26 @@ from apps.chat.models.conversation import Conversation
class ConversationParticipant(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(
Conversation,
related_name="participants",
on_delete=models.CASCADE,
)
user_id = models.UUIDField()
user_uuid = models.UUIDField()
joined_at = models.DateTimeField(auto_now_add=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["conversation", "user_id"],
fields=["conversation", "user_uuid"],
name="unique_participant_per_conversation",
)
]
indexes = [
models.Index(fields=["user_id"]),
models.Index(fields=["conversation", "user_id"]),
models.Index(fields=["user_uuid"]),
models.Index(fields=["conversation", "user_uuid"]),
]
def __str__(self):
return f"{self.user_id} in {self.conversation_id}"
return f"{self.user_uuid} in {self.conversation_id}"

View file

@ -6,27 +6,27 @@ from apps.chat.models.conversation import Conversation
class ConversationReadState(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(
Conversation,
related_name="read_states",
on_delete=models.CASCADE,
)
user_id = models.UUIDField()
user_uuid = models.UUIDField()
last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True)
class Meta:
constraints = [
models.UniqueConstraint(
fields=["conversation", "user_id"],
fields=["conversation", "user_uuid"],
name="unique_read_state_per_user",
)
]
indexes = [
models.Index(fields=["user_id"]),
models.Index(fields=["conversation", "user_id"]),
models.Index(fields=["user_uuid"]),
models.Index(fields=["conversation", "user_uuid"]),
]
def __str__(self):
return f"{self.user_id} read state for {self.conversation_id}"
return f"{self.user_uuid} read state for {self.conversation_id}"

View file

@ -5,8 +5,12 @@ from apps.chat.models import Conversation, ConversationParticipant
class CreateConversationSerializer(serializers.Serializer):
user_1_id = serializers.UUIDField()
user_2_id = serializers.UUIDField()
user_1_uuid = serializers.UUIDField()
user_2_uuid = serializers.UUIDField()
class ConversationUserActionSerializer(serializers.Serializer):
user_uuid = serializers.UUIDField()
class ConversationSerializer(serializers.ModelSerializer):
@ -14,20 +18,22 @@ class ConversationSerializer(serializers.ModelSerializer):
class Meta:
model = Conversation
fields = ["id", "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):
return list(
ConversationParticipant.objects.filter(conversation_id=obj.id)
ConversationParticipant.objects.filter(conversation_id=obj.uuid)
.order_by("joined_at")
.values_list("user_id", flat=True)
.values_list("user_uuid", flat=True)
)
class ConversationListSerializer(serializers.Serializer):
id = serializers.UUIDField()
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()

View file

@ -4,7 +4,7 @@ MEDIA_MESSAGE_TYPES = {"image", "video", "voice"}
class SendMessageSerializer(serializers.Serializer):
sender_id = serializers.UUIDField()
sender_uuid = serializers.UUIDField()
message_type = serializers.ChoiceField(
choices=["text", "image", "video", "voice"], default="text"
)
@ -27,7 +27,7 @@ class SendMessageSerializer(serializers.Serializer):
class MessageSerializer(serializers.Serializer):
post_id = serializers.CharField()
sender_id = serializers.UUIDField(allow_null=True)
sender_uuid = serializers.UUIDField(allow_null=True)
message_type = serializers.CharField(default="text")
text = serializers.CharField(allow_null=True, required=False)
url = serializers.CharField(allow_null=True, required=False)

View file

@ -2,11 +2,11 @@ from rest_framework import serializers
class MarkReadSerializer(serializers.Serializer):
user_id = serializers.UUIDField()
user_uuid = serializers.UUIDField()
post_id = serializers.CharField()
class ReadStateSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField()
user_id = serializers.UUIDField()
conversation_uuid = serializers.UUIDField()
user_uuid = serializers.UUIDField()
has_unread = serializers.BooleanField()

View file

@ -2,6 +2,6 @@ from uuid import UUID
class AccountService:
def validate_user(self, user_id: UUID) -> bool:
def validate_user(self, user_uuid: UUID) -> bool:
# Future: call external Account Service HTTP API
return True

View file

@ -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
@ -20,21 +21,21 @@ class ConversationService:
self._account = account_service or AccountService()
self._mm = mattermost_client or MattermostClient()
def create(self, user_1_id: UUID, user_2_id: UUID) -> Conversation:
if not self._account.validate_user(user_1_id):
raise ValueError(f"User {user_1_id} is not valid")
if not self._account.validate_user(user_2_id):
raise ValueError(f"User {user_2_id} is not valid")
def create(self, user_1_uuid: UUID, user_2_uuid: UUID) -> Conversation:
if not self._account.validate_user(user_1_uuid):
raise ValueError(f"User {user_1_uuid} is not valid")
if not self._account.validate_user(user_2_uuid):
raise ValueError(f"User {user_2_uuid} is not valid")
mm_user_1 = self._mm.get_or_create_user(user_1_id)
mm_user_2 = self._mm.get_or_create_user(user_2_id)
mm_user_1 = self._mm.get_or_create_user(user_1_uuid)
mm_user_2 = self._mm.get_or_create_user(user_2_uuid)
MattermostAccountMapping.objects.get_or_create(
user_id=user_1_id,
user_uuid=user_1_uuid,
defaults={"mattermost_user_id": mm_user_1},
)
MattermostAccountMapping.objects.get_or_create(
user_id=user_2_id,
user_uuid=user_2_uuid,
defaults={"mattermost_user_id": mm_user_2},
)
@ -43,23 +44,48 @@ class ConversationService:
with transaction.atomic():
conversation = Conversation.objects.create(mattermost_channel_id=channel_id)
ConversationParticipant.objects.create(
conversation=conversation, user_id=user_1_id
conversation=conversation, user_uuid=user_1_uuid
)
ConversationParticipant.objects.create(
conversation=conversation, user_id=user_2_id
conversation=conversation, user_uuid=user_2_uuid
)
return conversation
def list_for_user(
self, user_id: UUID, page: int, page_size: int
self, user_uuid: UUID, page: int, page_size: int
) -> list[Conversation]:
offset = page * page_size
conversation_ids = ConversationParticipant.objects.filter(
user_id=user_id
conversation_uuids = ConversationParticipant.objects.filter(
user_uuid=user_uuid
).values_list("conversation_id", flat=True)
return list(
Conversation.objects.filter(id__in=conversation_ids).order_by(
Conversation.objects.filter(uuid__in=conversation_uuids).order_by(
"-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

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.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
@ -38,14 +39,18 @@ class MessageService:
def send(
self,
conversation_id,
sender_id: UUID,
conversation_uuid,
sender_uuid: UUID,
message_type: str,
text: str | None = None,
object_key: str | None = None,
) -> dict:
if not self._account.validate_user(sender_id):
raise ValueError(f"Sender {sender_id} is not valid")
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")
# Media was already uploaded straight to MinIO by the client; the
# Mattermost post body only ever carries "<type>:<object_key>", never
@ -57,13 +62,12 @@ class MessageService:
else:
mm_message = text or ""
conversation = Conversation.objects.get(id=conversation_id)
post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message)
event = MessageSentEvent(
chat_id=conversation_id,
chat_uuid=conversation_uuid,
post_id=post_id,
sender_id=sender_id,
sender_uuid=sender_uuid,
message_type=message_type,
payload={"text": text, "object_key": object_key, "url": download_url},
)
@ -72,7 +76,7 @@ class MessageService:
return {
"post_id": post_id,
"sender_id": sender_id,
"sender_uuid": sender_uuid,
"message_type": message_type,
"text": text,
"url": download_url,
@ -81,12 +85,12 @@ class MessageService:
def list_messages(
self,
conversation_id,
conversation_uuid,
page: int,
per_page: int,
since: int | None = None,
) -> list[dict]:
conversation = Conversation.objects.get(id=conversation_id)
conversation = Conversation.objects.get(uuid=conversation_uuid)
raw_posts = self._mm.get_posts(
conversation.mattermost_channel_id,
page=page,
@ -96,7 +100,7 @@ class MessageService:
mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")}
mappings = {
m.mattermost_user_id: m.user_id
m.mattermost_user_id: m.user_uuid
for m in MattermostAccountMapping.objects.filter(
mattermost_user_id__in=mm_user_ids
)
@ -106,7 +110,7 @@ class MessageService:
def _normalize_post(self, post: dict, mappings: dict) -> dict:
mm_uid = post.get("user_id")
sender_id = mappings.get(mm_uid)
sender_uuid = mappings.get(mm_uid)
msg = post.get("message", "")
created_ms = post.get("create_at")
created_at = (
@ -119,7 +123,7 @@ class MessageService:
if sep and message_type in MEDIA_MESSAGE_TYPES and object_key:
return {
"post_id": post.get("id"),
"sender_id": sender_id,
"sender_uuid": sender_uuid,
"message_type": message_type,
"text": None,
"url": self._storage.get_download_url(object_key),
@ -128,7 +132,7 @@ class MessageService:
return {
"post_id": post.get("id"),
"sender_id": sender_id,
"sender_uuid": sender_uuid,
"message_type": "text",
"text": msg,
"url": None,

View file

@ -13,30 +13,30 @@ class ReadStateService:
self._mm = mattermost_client or MattermostClient()
def mark_read(
self, conversation_id, user_id: UUID, post_id: str
self, conversation_uuid, user_uuid: UUID, post_id: str
) -> ConversationReadState:
read_state, _ = ConversationReadState.objects.update_or_create(
conversation_id=conversation_id,
user_id=user_id,
conversation_id=conversation_uuid,
user_uuid=user_uuid,
defaults={"last_read_mattermost_post_id": post_id},
)
return read_state
def has_unread(self, conversation_id, user_id: UUID) -> bool:
def has_unread(self, conversation_uuid, user_uuid: UUID) -> bool:
try:
read_state = ConversationReadState.objects.get(
conversation_id=conversation_id, user_id=user_id
conversation_id=conversation_uuid, user_uuid=user_uuid
)
except ConversationReadState.DoesNotExist:
return True
last_read = read_state.last_read_mattermost_post_id
cache_key = f"latest_post:{conversation_id}"
cache_key = f"latest_post:{conversation_uuid}"
latest_post_id = cache.get(cache_key)
if latest_post_id is None:
conversation = Conversation.objects.get(id=conversation_id)
conversation = Conversation.objects.get(uuid=conversation_uuid)
latest_post_id = self._mm.get_latest_post_id(
conversation.mattermost_channel_id
)

View file

@ -3,9 +3,9 @@ from channels.layers import get_channel_layer
class RealtimeService:
def publish_message(self, chat_id, payload: dict) -> None:
def publish_message(self, chat_uuid, payload: dict) -> None:
channel_layer = get_channel_layer()
group_name = f"chat_{chat_id}"
group_name = f"chat_{chat_uuid}"
async_to_sync(channel_layer.group_send)(
group_name,
{"type": "chat.message", **payload},

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

@ -26,13 +26,13 @@ def test_create_conversation_success():
svc = ConversationService(mattermost_client=_make_mm_client())
conv = svc.create(user_1, user_2)
assert Conversation.objects.filter(id=conv.id).exists()
assert Conversation.objects.filter(uuid=conv.uuid).exists()
participants = ConversationParticipant.objects.filter(conversation=conv)
assert participants.count() == 2
participant_ids = set(participants.values_list("user_id", flat=True))
assert user_1 in participant_ids
assert user_2 in participant_ids
participant_uuids = set(participants.values_list("user_uuid", flat=True))
assert user_1 in participant_uuids
assert user_2 in participant_uuids
@pytest.mark.django_db
@ -44,8 +44,8 @@ def test_create_conversation_creates_mattermost_mapping():
svc = ConversationService(mattermost_client=mm_client)
svc.create(user_1, user_2)
mapping_1 = MattermostAccountMapping.objects.get(user_id=user_1)
mapping_2 = MattermostAccountMapping.objects.get(user_id=user_2)
mapping_1 = MattermostAccountMapping.objects.get(user_uuid=user_1)
mapping_2 = MattermostAccountMapping.objects.get(user_uuid=user_2)
assert mapping_1.mattermost_user_id == "mm-user-aaa"
assert mapping_2.mattermost_user_id == "mm-user-bbb"
@ -58,16 +58,16 @@ def test_list_for_user_returns_only_user_conversations():
conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target")
ConversationParticipant.objects.create(
conversation=conv_with_user, user_id=target_user
conversation=conv_with_user, user_uuid=target_user
)
conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other")
ConversationParticipant.objects.create(
conversation=conv_without_user, user_id=other_user
conversation=conv_without_user, user_uuid=other_user
)
svc = ConversationService(mattermost_client=Mock())
result = svc.list_for_user(target_user, page=0, page_size=20)
assert len(result) == 1
assert result[0].id == conv_with_user.id
assert result[0].uuid == conv_with_user.uuid

View file

@ -29,12 +29,12 @@ def _make_service(*, mm_post_id="post-1", download_url=None, publishers=None):
@pytest.mark.django_db
def test_send_text_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-text")
sender_id = uuid.uuid4()
sender_uuid = uuid.uuid4()
ws_publisher = Mock()
svc, mm_client, storage = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
result = svc.send(conv.id, sender_id, "text", text="Hello world")
result = svc.send(conv.uuid, sender_uuid, "text", text="Hello world")
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
storage.get_download_url.assert_not_called()
@ -42,7 +42,7 @@ def test_send_text_message():
event = ws_publisher.publish.call_args[0][0]
assert event.post_id == "post-abc"
assert event.sender_id == sender_id
assert event.sender_uuid == sender_uuid
assert event.message_type == "text"
assert result["post_id"] == "post-abc"
@ -53,13 +53,13 @@ def test_send_text_message():
@pytest.mark.django_db
def test_send_image_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
sender_id = uuid.uuid4()
sender_uuid = uuid.uuid4()
object_key = "images/abc123/photo.jpg"
download_url = "http://minio.local/chat/images/abc123/photo.jpg?X-Amz-Signature=..."
svc, mm_client, storage = _make_service(download_url=download_url, publishers=[])
result = svc.send(conv.id, sender_id, "image", object_key=object_key)
result = svc.send(conv.uuid, sender_uuid, "image", object_key=object_key)
storage.get_download_url.assert_called_once_with(object_key)
mm_client.post_message.assert_called_once_with("ch-send-image", f"image:{object_key}")
@ -74,7 +74,7 @@ def test_list_messages_normalized():
mm_user_id = "mm-user-xyz"
MattermostAccountMapping.objects.create(
user_id=sender_uuid, mattermost_user_id=mm_user_id
user_uuid=sender_uuid, mattermost_user_id=mm_user_id
)
object_key = "voice/def456/clip.ogg"
@ -99,20 +99,20 @@ def test_list_messages_normalized():
svc, _, storage = _make_service(download_url=download_url, publishers=[])
svc._mm = mm_client # inject after construction to keep _make_service simple
messages = svc.list_messages(conv.id, page=0, per_page=20)
messages = svc.list_messages(conv.uuid, page=0, per_page=20)
assert len(messages) == 2
text_msg = messages[0]
assert text_msg["post_id"] == "p1"
assert text_msg["sender_id"] == sender_uuid
assert text_msg["sender_uuid"] == sender_uuid
assert text_msg["message_type"] == "text"
assert text_msg["text"] == "First message"
assert text_msg["url"] is None
voice_msg = messages[1]
assert voice_msg["post_id"] == "p2"
assert voice_msg["sender_id"] == sender_uuid
assert voice_msg["sender_uuid"] == sender_uuid
assert voice_msg["message_type"] == "voice"
assert voice_msg["url"] == download_url
assert voice_msg["text"] is None
@ -141,7 +141,7 @@ def test_list_messages_treats_colon_in_plain_text_as_text():
svc, _, storage = _make_service(publishers=[])
svc._mm = mm_client
messages = svc.list_messages(conv.id, page=0, per_page=20)
messages = svc.list_messages(conv.uuid, page=0, per_page=20)
assert messages[0]["message_type"] == "text"
assert messages[0]["text"] == "Meeting at 10:30"

View file

@ -14,29 +14,29 @@ def _make_conv(channel_id="ch-read"):
@pytest.mark.django_db
def test_mark_read_creates_read_state():
conv = _make_conv()
user_id = uuid.uuid4()
user_uuid = uuid.uuid4()
svc = ReadStateService(mattermost_client=Mock())
read_state = svc.mark_read(conv.id, user_id, "post-111")
read_state = svc.mark_read(conv.uuid, user_uuid, "post-111")
assert read_state.conversation_id == conv.id
assert read_state.user_id == user_id
assert read_state.conversation_id == conv.uuid
assert read_state.user_uuid == user_uuid
assert read_state.last_read_mattermost_post_id == "post-111"
assert ConversationReadState.objects.filter(
conversation=conv, user_id=user_id
conversation=conv, user_uuid=user_uuid
).count() == 1
@pytest.mark.django_db
def test_mark_read_updates_existing():
conv = _make_conv()
user_id = uuid.uuid4()
user_uuid = uuid.uuid4()
svc = ReadStateService(mattermost_client=Mock())
svc.mark_read(conv.id, user_id, "post-old")
svc.mark_read(conv.id, user_id, "post-new")
svc.mark_read(conv.uuid, user_uuid, "post-old")
svc.mark_read(conv.uuid, user_uuid, "post-new")
rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id)
rows = ConversationReadState.objects.filter(conversation=conv, user_uuid=user_uuid)
assert rows.count() == 1
assert rows.first().last_read_mattermost_post_id == "post-new"
@ -47,10 +47,10 @@ def test_has_unread_true(mock_cache):
mock_cache.get.return_value = None # force cache miss → MM lookup
conv = _make_conv()
user_id = uuid.uuid4()
user_uuid = uuid.uuid4()
ConversationReadState.objects.create(
conversation=conv,
user_id=user_id,
user_uuid=user_uuid,
last_read_mattermost_post_id="post-old",
)
@ -58,7 +58,7 @@ def test_has_unread_true(mock_cache):
mm_client.get_latest_post_id.return_value = "post-new"
svc = ReadStateService(mattermost_client=mm_client)
result = svc.has_unread(conv.id, user_id)
result = svc.has_unread(conv.uuid, user_uuid)
assert result is True
mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id)
@ -70,10 +70,10 @@ def test_has_unread_false(mock_cache):
mock_cache.get.return_value = None # force cache miss → MM lookup
conv = _make_conv()
user_id = uuid.uuid4()
user_uuid = uuid.uuid4()
ConversationReadState.objects.create(
conversation=conv,
user_id=user_id,
user_uuid=user_uuid,
last_read_mattermost_post_id="post-current",
)
@ -81,6 +81,6 @@ def test_has_unread_false(mock_cache):
mm_client.get_latest_post_id.return_value = "post-current"
svc = ReadStateService(mattermost_client=mm_client)
result = svc.has_unread(conv.id, user_id)
result = svc.has_unread(conv.uuid, user_uuid)
assert result is False

View file

@ -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
@ -9,8 +11,10 @@ from apps.chat.views.read_state import ChatEventsView, ReadStateView
urlpatterns = [
path("api/chats/", ConversationCreateView.as_view(), name="chat-create"),
path("api/users/<uuid:user_id>/chats/", UserConversationListView.as_view(), name="user-chat-list"),
path("api/chats/<uuid:chat_id>/messages/", MessageView.as_view(), name="chat-messages"),
path("api/chats/<uuid:chat_id>/read/", ReadStateView.as_view(), name="chat-read"),
path("api/chats/<uuid:chat_id>/events/", ChatEventsView.as_view(), name="chat-events"),
path("api/users/<uuid:user_uuid>/chats/", UserConversationListView.as_view(), name="user-chat-list"),
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"),
]

View file

@ -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
@ -29,8 +30,8 @@ class ConversationCreateView(APIView):
serializer.is_valid(raise_exception=True)
conversation = ConversationService().create(
serializer.validated_data["user_1_id"],
serializer.validated_data["user_2_id"],
serializer.validated_data["user_1_uuid"],
serializer.validated_data["user_2_uuid"],
)
return Response(
@ -50,8 +51,8 @@ class UserConversationListView(APIView):
],
responses={200: ConversationListSerializer(many=True)},
)
def get(self, request, user_id):
uid = UUID(str(user_id))
def get(self, request, user_uuid):
uid = UUID(str(user_uuid))
page = int(request.query_params.get("page", 0))
page_size = int(request.query_params.get("page_size", 20))
@ -64,26 +65,28 @@ class UserConversationListView(APIView):
result = []
for conv in conversations:
participants = list(
ConversationParticipant.objects.filter(conversation_id=conv.id)
ConversationParticipant.objects.filter(conversation_id=conv.uuid)
.order_by("joined_at")
.values_list("user_id", flat=True)
.values_list("user_uuid", flat=True)
)
try:
has_unread = read_svc.has_unread(conv.id, uid)
has_unread = read_svc.has_unread(conv.uuid, uid)
except Exception:
has_unread = False
try:
last_msgs = msg_svc.list_messages(conv.id, page=0, per_page=1)
last_msgs = msg_svc.list_messages(conv.uuid, page=0, per_page=1)
last_message = last_msgs[0] if last_msgs else None
except Exception:
last_message = None
result.append(
{
"id": conv.id,
"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)

View file

@ -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_id):
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_id=UUID(str(chat_id)),
sender_id=data["sender_id"],
message_type=data["message_type"],
text=data.get("text"),
object_key=data.get("object_key"),
)
try:
result = MessageService().send(
conversation_uuid=UUID(str(chat_uuid)),
sender_uuid=data["sender_uuid"],
message_type=data["message_type"],
text=data.get("text"),
object_key=data.get("object_key"),
)
except ConversationClosedError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN)
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
@ -45,14 +49,14 @@ class MessageView(APIView):
],
responses={200: MessageSerializer(many=True)},
)
def get(self, request, chat_id):
def get(self, request, chat_uuid):
page = int(request.query_params.get("page", 0))
per_page = int(request.query_params.get("per_page", 20))
since_raw = request.query_params.get("since")
since = int(since_raw) if since_raw else None
messages = MessageService().list_messages(
conversation_id=UUID(str(chat_id)),
conversation_uuid=UUID(str(chat_uuid)),
page=page,
per_page=per_page,
since=since,

View file

@ -20,22 +20,22 @@ class ReadStateView(APIView):
request=MarkReadSerializer,
responses={200: ReadStateSerializer},
)
def post(self, request, chat_id):
def post(self, request, chat_uuid):
serializer = MarkReadSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
read_state = ReadStateService().mark_read(
conversation_id=UUID(str(chat_id)),
user_id=data["user_id"],
conversation_uuid=UUID(str(chat_uuid)),
user_uuid=data["user_uuid"],
post_id=data["post_id"],
)
return Response(
ReadStateSerializer(
{
"conversation_id": read_state.conversation_id,
"user_id": read_state.user_id,
"conversation_uuid": read_state.conversation_id,
"user_uuid": read_state.user_uuid,
"has_unread": False,
}
).data
@ -54,11 +54,11 @@ class ChatEventsView(APIView):
"arrives; returns an empty list on timeout."
),
)
def get(self, request, chat_id):
def get(self, request, chat_uuid):
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)
redis = get_redis_connection("default")
key = f"chat_events:{chat_id}"
key = f"chat_events:{chat_uuid}"
result = redis.blpop(key, timeout=timeout)

View file

@ -5,8 +5,8 @@ from channels.generic.websocket import AsyncWebsocketConsumer
class ChatConsumer(AsyncWebsocketConsumer):
async def connect(self):
self.chat_id = self.scope["url_route"]["kwargs"]["chat_id"]
self.group_name = f"chat_{self.chat_id}"
self.chat_uuid = self.scope["url_route"]["kwargs"]["chat_uuid"]
self.group_name = f"chat_{self.chat_uuid}"
await self.channel_layer.group_add(self.group_name, self.channel_name)
await self.accept()

View file

@ -3,5 +3,5 @@ from django.urls import path
from apps.chat.websocket.consumers import ChatConsumer
websocket_urlpatterns = [
path("ws/chat/<uuid:chat_id>/", ChatConsumer.as_asgi()),
path("ws/chat/<uuid:chat_uuid>/", ChatConsumer.as_asgi()),
]

View file

@ -1,20 +0,0 @@
# Generated by Django 5.2.13 on 2026-07-12 07:43
import oauth2_provider.generators
import oauth2_provider.models
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('gooyal_oauth2', '0003_alter_application_authorization_grant_type'),
]
operations = [
migrations.AlterField(
model_name='application',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(blank=True, db_index=True, default=oauth2_provider.generators.generate_client_secret, help_text='Client secret for authentication', max_length=255),
),
]