REFACTOR(chat): rename all internal id fields to uuid

Renames PK/reference fields across chat models, services, serializers,
views, urls, websocket routing, and events (id->uuid, user_id->user_uuid,
chat_id->chat_uuid, sender_id->sender_uuid, user_1_id/user_2_id->
user_1_uuid/user_2_uuid) for consistency with the uuid-named PK
convention already used in core/users/gooyal_oauth2. Mattermost's own
opaque identifiers (post_id, channel_id, mattermost_user_id) are left
unchanged since they are not UUIDs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Ali Asadi 2026-07-19 12:11:32 +03:30
parent ba236c2b3e
commit 0cf77aa21c
25 changed files with 230 additions and 134 deletions

View file

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

View file

@ -10,11 +10,11 @@ _KEY_TTL = 300
class LongPollPublisher: class LongPollPublisher:
def publish(self, event: MessageSentEvent) -> None: def publish(self, event: MessageSentEvent) -> None:
redis = get_redis_connection("default") redis = get_redis_connection("default")
key = f"chat_events:{event.chat_id}" key = f"chat_events:{event.chat_uuid}"
payload = json.dumps( payload = json.dumps(
{ {
"post_id": event.post_id, "post_id": event.post_id,
"sender_id": str(event.sender_id), "sender_uuid": str(event.sender_uuid),
"message_type": event.message_type, "message_type": event.message_type,
**{k: v for k, v in event.payload.items() if v is not None}, **{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: class WebSocketPublisher:
def publish(self, event: MessageSentEvent) -> None: def publish(self, event: MessageSentEvent) -> None:
channel_layer = get_channel_layer() 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)( async_to_sync(channel_layer.group_send)(
group_name, group_name,
{ {
"type": "chat.message", "type": "chat.message",
"post_id": event.post_id, "post_id": event.post_id,
"sender_id": str(event.sender_id), "sender_uuid": str(event.sender_uuid),
"message_type": event.message_type, "message_type": event.message_type,
**event.payload, **event.payload,
}, },

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

@ -9,7 +9,7 @@ class ConversationType(models.TextChoices):
class Conversation(models.Model): 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 # Internal only — never exposed through any public API or serializer
mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True) mattermost_channel_id = models.CharField(max_length=255, unique=True, db_index=True)
type = models.CharField( type = models.CharField(
@ -27,4 +27,4 @@ class Conversation(models.Model):
] ]
def __str__(self): 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): class MattermostAccountMapping(models.Model):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
user_id = models.UUIDField(unique=True, db_index=True) user_uuid = models.UUIDField(unique=True, db_index=True)
mattermost_user_id = models.CharField(max_length=255, 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) created_at = models.DateTimeField(auto_now_add=True)
@ -15,4 +15,4 @@ class MattermostAccountMapping(models.Model):
] ]
def __str__(self): 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): 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 = models.ForeignKey(
Conversation, Conversation,
related_name="participants", related_name="participants",
on_delete=models.CASCADE, on_delete=models.CASCADE,
) )
user_id = models.UUIDField() user_uuid = models.UUIDField()
joined_at = models.DateTimeField(auto_now_add=True) joined_at = models.DateTimeField(auto_now_add=True)
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["conversation", "user_id"], fields=["conversation", "user_uuid"],
name="unique_participant_per_conversation", name="unique_participant_per_conversation",
) )
] ]
indexes = [ indexes = [
models.Index(fields=["user_id"]), models.Index(fields=["user_uuid"]),
models.Index(fields=["conversation", "user_id"]), models.Index(fields=["conversation", "user_uuid"]),
] ]
def __str__(self): 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): 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 = models.ForeignKey(
Conversation, Conversation,
related_name="read_states", related_name="read_states",
on_delete=models.CASCADE, 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) last_read_mattermost_post_id = models.CharField(max_length=255, null=True, blank=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
class Meta: class Meta:
constraints = [ constraints = [
models.UniqueConstraint( models.UniqueConstraint(
fields=["conversation", "user_id"], fields=["conversation", "user_uuid"],
name="unique_read_state_per_user", name="unique_read_state_per_user",
) )
] ]
indexes = [ indexes = [
models.Index(fields=["user_id"]), models.Index(fields=["user_uuid"]),
models.Index(fields=["conversation", "user_id"]), models.Index(fields=["conversation", "user_uuid"]),
] ]
def __str__(self): 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,8 @@ from apps.chat.models import Conversation, ConversationParticipant
class CreateConversationSerializer(serializers.Serializer): class CreateConversationSerializer(serializers.Serializer):
user_1_id = serializers.UUIDField() user_1_uuid = serializers.UUIDField()
user_2_id = serializers.UUIDField() user_2_uuid = serializers.UUIDField()
class ConversationSerializer(serializers.ModelSerializer): class ConversationSerializer(serializers.ModelSerializer):
@ -14,19 +14,19 @@ class ConversationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Conversation model = Conversation
fields = ["id", "type", "created_at", "participants"] fields = ["uuid", "type", "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):
return list( return list(
ConversationParticipant.objects.filter(conversation_id=obj.id) ConversationParticipant.objects.filter(conversation_id=obj.uuid)
.order_by("joined_at") .order_by("joined_at")
.values_list("user_id", flat=True) .values_list("user_uuid", flat=True)
) )
class ConversationListSerializer(serializers.Serializer): class ConversationListSerializer(serializers.Serializer):
id = serializers.UUIDField() uuid = serializers.UUIDField()
type = serializers.CharField() type = serializers.CharField()
created_at = serializers.DateTimeField() created_at = serializers.DateTimeField()
participants = serializers.ListField(child=serializers.UUIDField()) participants = serializers.ListField(child=serializers.UUIDField())

View file

@ -2,7 +2,7 @@ from rest_framework import serializers
class SendMessageSerializer(serializers.Serializer): class SendMessageSerializer(serializers.Serializer):
sender_id = serializers.UUIDField() sender_uuid = serializers.UUIDField()
message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text") message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text")
text = serializers.CharField(required=False, allow_null=True, allow_blank=True) text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
file = serializers.FileField(required=False, allow_null=True) file = serializers.FileField(required=False, allow_null=True)
@ -10,7 +10,7 @@ class SendMessageSerializer(serializers.Serializer):
class MessageSerializer(serializers.Serializer): class MessageSerializer(serializers.Serializer):
post_id = serializers.CharField() post_id = serializers.CharField()
sender_id = serializers.UUIDField(allow_null=True) sender_uuid = serializers.UUIDField(allow_null=True)
message_type = serializers.CharField(default="text") message_type = serializers.CharField(default="text")
text = serializers.CharField(allow_null=True, required=False) text = serializers.CharField(allow_null=True, required=False)
url = 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): class MarkReadSerializer(serializers.Serializer):
user_id = serializers.UUIDField() user_uuid = serializers.UUIDField()
post_id = serializers.CharField() post_id = serializers.CharField()
class ReadStateSerializer(serializers.Serializer): class ReadStateSerializer(serializers.Serializer):
conversation_id = serializers.UUIDField() conversation_uuid = serializers.UUIDField()
user_id = serializers.UUIDField() user_uuid = serializers.UUIDField()
has_unread = serializers.BooleanField() has_unread = serializers.BooleanField()

View file

@ -2,6 +2,6 @@ from uuid import UUID
class AccountService: 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 # Future: call external Account Service HTTP API
return True return True

View file

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

View file

@ -36,18 +36,18 @@ class MessageService:
def send( def send(
self, self,
conversation_id, conversation_uuid,
sender_id: UUID, sender_uuid: UUID,
message_type: str, message_type: str,
text: str | None = None, text: str | None = None,
file=None, file=None,
) -> dict: ) -> dict:
if not self._account.validate_user(sender_id): if not self._account.validate_user(sender_uuid):
raise ValueError(f"Sender {sender_id} is not valid") raise ValueError(f"Sender {sender_uuid} is not valid")
file_url: str | None = None file_url: str | None = None
if file is not None: if file is not None:
filename = getattr(file, "name", f"{sender_id}") filename = getattr(file, "name", f"{sender_uuid}")
file_url = self._storage.upload_file(file, filename) file_url = self._storage.upload_file(file, filename)
if message_type == "text": if message_type == "text":
@ -55,13 +55,13 @@ class MessageService:
else: else:
mm_message = file_url or text or "" mm_message = file_url or text or ""
conversation = Conversation.objects.get(id=conversation_id) 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(
chat_id=conversation_id, chat_uuid=conversation_uuid,
post_id=post_id, post_id=post_id,
sender_id=sender_id, sender_uuid=sender_uuid,
message_type=message_type, message_type=message_type,
payload={"text": text, "file_url": file_url}, payload={"text": text, "file_url": file_url},
) )
@ -70,7 +70,7 @@ class MessageService:
return { return {
"post_id": post_id, "post_id": post_id,
"sender_id": sender_id, "sender_uuid": sender_uuid,
"message_type": message_type, "message_type": message_type,
"text": text, "text": text,
"url": file_url, "url": file_url,
@ -79,12 +79,12 @@ class MessageService:
def list_messages( def list_messages(
self, self,
conversation_id, conversation_uuid,
page: int, page: int,
per_page: int, per_page: int,
since: int | None = None, since: int | None = None,
) -> list[dict]: ) -> list[dict]:
conversation = Conversation.objects.get(id=conversation_id) conversation = Conversation.objects.get(uuid=conversation_uuid)
raw_posts = self._mm.get_posts( raw_posts = self._mm.get_posts(
conversation.mattermost_channel_id, conversation.mattermost_channel_id,
page=page, page=page,
@ -94,7 +94,7 @@ class MessageService:
mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")} mm_user_ids = {p["user_id"] for p in raw_posts if p.get("user_id")}
mappings = { mappings = {
m.mattermost_user_id: m.user_id m.mattermost_user_id: m.user_uuid
for m in MattermostAccountMapping.objects.filter( for m in MattermostAccountMapping.objects.filter(
mattermost_user_id__in=mm_user_ids mattermost_user_id__in=mm_user_ids
) )
@ -105,7 +105,7 @@ class MessageService:
@staticmethod @staticmethod
def _normalize_post(post: dict, mappings: dict) -> dict: def _normalize_post(post: dict, mappings: dict) -> dict:
mm_uid = post.get("user_id") mm_uid = post.get("user_id")
sender_id = mappings.get(mm_uid) sender_uuid = mappings.get(mm_uid)
msg = post.get("message", "") msg = post.get("message", "")
is_url = msg.startswith("http") is_url = msg.startswith("http")
created_ms = post.get("create_at") created_ms = post.get("create_at")
@ -116,7 +116,7 @@ class MessageService:
) )
return { return {
"post_id": post.get("id"), "post_id": post.get("id"),
"sender_id": sender_id, "sender_uuid": sender_uuid,
"message_type": "file" if is_url else "text", "message_type": "file" if is_url else "text",
"text": None if is_url else msg, "text": None if is_url else msg,
"url": msg if is_url else None, "url": msg if is_url else None,

View file

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

View file

@ -3,9 +3,9 @@ from channels.layers import get_channel_layer
class RealtimeService: 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() channel_layer = get_channel_layer()
group_name = f"chat_{chat_id}" group_name = f"chat_{chat_uuid}"
async_to_sync(channel_layer.group_send)( async_to_sync(channel_layer.group_send)(
group_name, group_name,
{"type": "chat.message", **payload}, {"type": "chat.message", **payload},

View file

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

View file

@ -30,19 +30,19 @@ def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None):
@pytest.mark.django_db @pytest.mark.django_db
def test_send_text_message(): def test_send_text_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-text") conv = Conversation.objects.create(mattermost_channel_id="ch-send-text")
sender_id = uuid.uuid4() sender_uuid = uuid.uuid4()
ws_publisher = Mock() ws_publisher = Mock()
svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher]) svc, mm_client, _ = _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") mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
ws_publisher.publish.assert_called_once() ws_publisher.publish.assert_called_once()
event = ws_publisher.publish.call_args[0][0] event = ws_publisher.publish.call_args[0][0]
assert event.post_id == "post-abc" assert event.post_id == "post-abc"
assert event.sender_id == sender_id assert event.sender_uuid == sender_uuid
assert event.message_type == "text" assert event.message_type == "text"
assert result["post_id"] == "post-abc" assert result["post_id"] == "post-abc"
@ -52,7 +52,7 @@ def test_send_text_message():
@pytest.mark.django_db @pytest.mark.django_db
def test_send_image_message(): def test_send_image_message():
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image") conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
sender_id = uuid.uuid4() sender_uuid = uuid.uuid4()
file_url = "http://minio.local/chat/abc123/photo.jpg" file_url = "http://minio.local/chat/abc123/photo.jpg"
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[])
@ -60,7 +60,7 @@ def test_send_image_message():
fake_file = Mock() fake_file = Mock()
fake_file.name = "photo.jpg" fake_file.name = "photo.jpg"
result = svc.send(conv.id, sender_id, "image", file=fake_file) result = svc.send(conv.uuid, sender_uuid, "image", file=fake_file)
storage.upload_file.assert_called_once_with(fake_file, "photo.jpg") storage.upload_file.assert_called_once_with(fake_file, "photo.jpg")
mm_client.post_message.assert_called_once_with("ch-send-image", file_url) mm_client.post_message.assert_called_once_with("ch-send-image", file_url)
@ -74,7 +74,7 @@ def test_list_messages_normalized():
mm_user_id = "mm-user-xyz" mm_user_id = "mm-user-xyz"
MattermostAccountMapping.objects.create( MattermostAccountMapping.objects.create(
user_id=sender_uuid, mattermost_user_id=mm_user_id user_uuid=sender_uuid, mattermost_user_id=mm_user_id
) )
raw_posts = [ raw_posts = [
@ -97,20 +97,20 @@ def test_list_messages_normalized():
svc, _, _ = _make_service(publishers=[]) svc, _, _ = _make_service(publishers=[])
svc._mm = mm_client # inject after construction to keep _make_service simple 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 assert len(messages) == 2
text_msg = messages[0] text_msg = messages[0]
assert text_msg["post_id"] == "p1" 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["message_type"] == "text"
assert text_msg["text"] == "First message" assert text_msg["text"] == "First message"
assert text_msg["url"] is None assert text_msg["url"] is None
file_msg = messages[1] file_msg = messages[1]
assert file_msg["post_id"] == "p2" assert file_msg["post_id"] == "p2"
assert file_msg["sender_id"] == sender_uuid assert file_msg["sender_uuid"] == sender_uuid
assert file_msg["message_type"] == "file" assert file_msg["message_type"] == "file"
assert file_msg["url"] == "http://minio.local/chat/file.pdf" assert file_msg["url"] == "http://minio.local/chat/file.pdf"
assert file_msg["text"] is None assert file_msg["text"] is None

View file

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

View file

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

View file

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

View file

@ -17,14 +17,14 @@ class MessageView(APIView):
request=SendMessageSerializer, request=SendMessageSerializer,
responses={201: MessageSerializer}, responses={201: MessageSerializer},
) )
def post(self, request, chat_id): 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( result = MessageService().send(
conversation_id=UUID(str(chat_id)), conversation_uuid=UUID(str(chat_uuid)),
sender_id=data["sender_id"], sender_uuid=data["sender_uuid"],
message_type=data["message_type"], message_type=data["message_type"],
text=data.get("text"), text=data.get("text"),
file=data.get("file"), file=data.get("file"),
@ -45,14 +45,14 @@ class MessageView(APIView):
], ],
responses={200: MessageSerializer(many=True)}, 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)) page = int(request.query_params.get("page", 0))
per_page = int(request.query_params.get("per_page", 20)) per_page = int(request.query_params.get("per_page", 20))
since_raw = request.query_params.get("since") since_raw = request.query_params.get("since")
since = int(since_raw) if since_raw else None since = int(since_raw) if since_raw else None
messages = MessageService().list_messages( messages = MessageService().list_messages(
conversation_id=UUID(str(chat_id)), conversation_uuid=UUID(str(chat_uuid)),
page=page, page=page,
per_page=per_page, per_page=per_page,
since=since, since=since,

View file

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

View file

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

View file

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