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:
commit
e4cab3ab62
30 changed files with 482 additions and 163 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
2
apps/chat/exceptions.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
class ConversationClosedError(Exception):
|
||||
"""Raised when attempting to send a message to a closed conversation."""
|
||||
|
|
@ -0,0 +1,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'),
|
||||
),
|
||||
]
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
# Generated by Django 5.2.13 on 2026-07-19 09:05
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('chat', '0002_remove_conversationparticipant_unique_participant_per_conversation_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='closed_by_uuid',
|
||||
field=models.UUIDField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='conversation',
|
||||
name='status',
|
||||
field=models.CharField(choices=[('open', 'Open'), ('closed', 'Closed')], default='open', max_length=20),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='conversation',
|
||||
index=models.Index(fields=['status'], name='chat_conver_status_9f0685_idx'),
|
||||
),
|
||||
]
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
from apps.chat.models.conversation import Conversation, ConversationType
|
||||
from apps.chat.models.conversation import Conversation, ConversationStatus, ConversationType
|
||||
from apps.chat.models.mapping import MattermostAccountMapping
|
||||
from apps.chat.models.participant import ConversationParticipant
|
||||
from apps.chat.models.read_state import ConversationReadState
|
||||
|
|
@ -6,6 +6,7 @@ from apps.chat.models.read_state import ConversationReadState
|
|||
__all__ = [
|
||||
"Conversation",
|
||||
"ConversationType",
|
||||
"ConversationStatus",
|
||||
"ConversationParticipant",
|
||||
"MattermostAccountMapping",
|
||||
"ConversationReadState",
|
||||
|
|
|
|||
|
|
@ -8,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)
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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},
|
||||
|
|
|
|||
112
apps/chat/tests/test_conversation_close.py
Normal file
112
apps/chat/tests/test_conversation_close.py
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import uuid
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.chat.exceptions import ConversationClosedError
|
||||
from apps.chat.models import Conversation, ConversationStatus
|
||||
from apps.chat.services.conversation import ConversationService
|
||||
from apps.chat.services.message import MessageService
|
||||
|
||||
|
||||
def _make_conv(status=ConversationStatus.OPEN, closed_by_uuid=None, channel_id="ch-close"):
|
||||
return Conversation.objects.create(
|
||||
mattermost_channel_id=channel_id,
|
||||
status=status,
|
||||
closed_by_uuid=closed_by_uuid,
|
||||
)
|
||||
|
||||
|
||||
def _svc():
|
||||
return ConversationService(mattermost_client=Mock())
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_close_sets_status_and_closed_by():
|
||||
conv = _make_conv()
|
||||
closer = uuid.uuid4()
|
||||
|
||||
result = _svc().close(conv.uuid, closer)
|
||||
|
||||
assert result.status == ConversationStatus.CLOSED
|
||||
assert result.closed_by_uuid == closer
|
||||
|
||||
conv.refresh_from_db()
|
||||
assert conv.status == ConversationStatus.CLOSED
|
||||
assert conv.closed_by_uuid == closer
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_close_is_idempotent():
|
||||
closer = uuid.uuid4()
|
||||
other = uuid.uuid4()
|
||||
conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer)
|
||||
|
||||
result = _svc().close(conv.uuid, other)
|
||||
|
||||
assert result.status == ConversationStatus.CLOSED
|
||||
assert result.closed_by_uuid == closer # unchanged — original closer preserved
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reopen_by_closer_succeeds():
|
||||
closer = uuid.uuid4()
|
||||
conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer)
|
||||
|
||||
result = _svc().reopen(conv.uuid, closer)
|
||||
|
||||
assert result.status == ConversationStatus.OPEN
|
||||
assert result.closed_by_uuid is None
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reopen_by_other_user_raises_permission_error():
|
||||
closer = uuid.uuid4()
|
||||
other = uuid.uuid4()
|
||||
conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer)
|
||||
|
||||
with pytest.raises(PermissionError):
|
||||
_svc().reopen(conv.uuid, other)
|
||||
|
||||
conv.refresh_from_db()
|
||||
assert conv.status == ConversationStatus.CLOSED
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reopen_is_idempotent_when_already_open():
|
||||
conv = _make_conv(status=ConversationStatus.OPEN)
|
||||
someone = uuid.uuid4()
|
||||
|
||||
result = _svc().reopen(conv.uuid, someone)
|
||||
|
||||
assert result.status == ConversationStatus.OPEN
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_send_message_to_closed_conversation_raises():
|
||||
closer = uuid.uuid4()
|
||||
conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer)
|
||||
sender_uuid = uuid.uuid4()
|
||||
|
||||
svc = MessageService(mattermost_client=Mock(), storage_service=Mock(), publishers=[])
|
||||
|
||||
with pytest.raises(ConversationClosedError):
|
||||
svc.send(conv.uuid, sender_uuid, "text", text="hello")
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_send_message_after_reopen_succeeds():
|
||||
closer = uuid.uuid4()
|
||||
conv = _make_conv(status=ConversationStatus.CLOSED, closed_by_uuid=closer)
|
||||
sender_uuid = uuid.uuid4()
|
||||
|
||||
_svc().reopen(conv.uuid, closer)
|
||||
|
||||
mm_client = Mock()
|
||||
mm_client.post_message.return_value = "post-1"
|
||||
svc = MessageService(mattermost_client=mm_client, storage_service=Mock(), publishers=[])
|
||||
|
||||
result = svc.send(conv.uuid, sender_uuid, "text", text="hello again")
|
||||
|
||||
assert result["post_id"] == "post-1"
|
||||
mm_client.post_message.assert_called_once_with("ch-close", "hello again")
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
try:
|
||||
result = MessageService().send(
|
||||
conversation_id=UUID(str(chat_id)),
|
||||
sender_id=data["sender_id"],
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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()),
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
),
|
||||
]
|
||||
Loading…
Add table
Reference in a new issue