merge master into branch

This commit is contained in:
Ali Asadi 2026-07-21 15:29:04 +03:30
commit 85fcfc37d0
40 changed files with 875 additions and 202 deletions

View file

@ -27,10 +27,15 @@ MATTERMOST_SERVICE_USERNAME=chat-service
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
# MinIO object storage # MinIO object storage
# The frontend uploads media directly to MinIO and sends us only the
# resulting object key — these credentials are used solely to sign
# short-lived download URLs when messages are read back.
MINIO_ENDPOINT=localhost:9000 MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_CHAT=chat MINIO_BUCKET_CHAT=chat
MINIO_SECURE=false
MINIO_PRESIGN_EXPIRY_SECONDS=3600
# Chat long-poll tuning # Chat long-poll tuning
CHAT_LONG_POLL_TIMEOUT_SECONDS=25 CHAT_LONG_POLL_TIMEOUT_SECONDS=25

View file

@ -1,3 +1,23 @@
from django.contrib import admin from django.contrib import admin
# Register your models here. from apps.chat.models import ConversationReport
@admin.register(ConversationReport)
class ConversationReportAdmin(admin.ModelAdmin):
list_display = ("uuid", "conversation", "reporter_uuid", "reason", "created_at")
list_filter = ("reason", "created_at")
search_fields = ("uuid", "conversation__uuid", "reporter_uuid")
readonly_fields = (
"uuid",
"conversation",
"reporter_uuid",
"reason",
"other_reason_text",
"created_at",
)
ordering = ("-created_at",)
def has_add_permission(self, request):
# Reports are only ever created by users through the API.
return False

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,
}, },

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

@ -0,0 +1,29 @@
# Generated by Django 5.2.13 on 2026-07-20 10:17
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('chat', '0003_conversation_closed_by_uuid_conversation_status_and_more'),
]
operations = [
migrations.CreateModel(
name='ConversationReport',
fields=[
('uuid', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)),
('reporter_uuid', models.UUIDField()),
('reason', models.CharField(choices=[('harassing_message', 'پیام آزار دهنده'), ('fraud', 'کلاه\u200cبرداری'), ('inappropriate_content', 'محتوای نامناسب'), ('other', 'سایر')], max_length=32)),
('other_reason_text', models.TextField(blank=True, null=True)),
('created_at', models.DateTimeField(auto_now_add=True)),
('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='reports', to='chat.conversation')),
],
options={
'indexes': [models.Index(fields=['conversation'], name='chat_conver_convers_d48bec_idx'), models.Index(fields=['reporter_uuid'], name='chat_conver_reporte_5dc041_idx'), models.Index(fields=['reason'], name='chat_conver_reason_62cd7f_idx'), models.Index(fields=['created_at'], name='chat_conver_created_6ce88a_idx')],
},
),
]

View file

@ -1,12 +1,16 @@
from apps.chat.models.conversation import Conversation, ConversationType from apps.chat.models.conversation import Conversation, ConversationStatus, ConversationType
from apps.chat.models.mapping import MattermostAccountMapping from apps.chat.models.mapping import MattermostAccountMapping
from apps.chat.models.participant import ConversationParticipant from apps.chat.models.participant import ConversationParticipant
from apps.chat.models.read_state import ConversationReadState from apps.chat.models.read_state import ConversationReadState
from apps.chat.models.report import ConversationReport, ReportReason
__all__ = [ __all__ = [
"Conversation", "Conversation",
"ConversationType", "ConversationType",
"ConversationStatus",
"ConversationParticipant", "ConversationParticipant",
"MattermostAccountMapping", "MattermostAccountMapping",
"ConversationReadState", "ConversationReadState",
"ConversationReport",
"ReportReason",
] ]

View file

@ -8,8 +8,13 @@ class ConversationType(models.TextChoices):
GROUP = "group", "Group" GROUP = "group", "Group"
class ConversationStatus(models.TextChoices):
OPEN = "open", "Open"
CLOSED = "closed", "Closed"
class Conversation(models.Model): class Conversation(models.Model):
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(
@ -17,6 +22,13 @@ class Conversation(models.Model):
choices=ConversationType.choices, choices=ConversationType.choices,
default=ConversationType.DIRECT, default=ConversationType.DIRECT,
) )
status = models.CharField(
max_length=20,
choices=ConversationStatus.choices,
default=ConversationStatus.OPEN,
)
# Set only while status is CLOSED; identifies who is allowed to reopen it.
closed_by_uuid = models.UUIDField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True) created_at = models.DateTimeField(auto_now_add=True)
updated_at = models.DateTimeField(auto_now=True) updated_at = models.DateTimeField(auto_now=True)
@ -24,7 +36,8 @@ class Conversation(models.Model):
indexes = [ indexes = [
models.Index(fields=["type"]), models.Index(fields=["type"]),
models.Index(fields=["created_at"]), models.Index(fields=["created_at"]),
models.Index(fields=["status"]),
] ]
def __str__(self): def __str__(self):
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

@ -0,0 +1,37 @@
import uuid
from django.db import models
from apps.chat.models.conversation import Conversation
class ReportReason(models.TextChoices):
HARASSING_MESSAGE = "harassing_message", "پیام آزار دهنده"
FRAUD = "fraud", "کلاه‌برداری"
INAPPROPRIATE_CONTENT = "inappropriate_content", "محتوای نامناسب"
OTHER = "other", "سایر"
class ConversationReport(models.Model):
uuid = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
conversation = models.ForeignKey(
Conversation,
related_name="reports",
on_delete=models.CASCADE,
)
reporter_uuid = models.UUIDField()
reason = models.CharField(max_length=32, choices=ReportReason.choices)
# Only populated when reason == OTHER.
other_reason_text = models.TextField(null=True, blank=True)
created_at = models.DateTimeField(auto_now_add=True)
class Meta:
indexes = [
models.Index(fields=["conversation"]),
models.Index(fields=["reporter_uuid"]),
models.Index(fields=["reason"]),
models.Index(fields=["created_at"]),
]
def __str__(self):
return f"{self.get_reason_display()} — {self.conversation_id}"

View file

@ -5,8 +5,12 @@ 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 ConversationUserActionSerializer(serializers.Serializer):
user_uuid = serializers.UUIDField()
class ConversationSerializer(serializers.ModelSerializer): class ConversationSerializer(serializers.ModelSerializer):
@ -14,20 +18,22 @@ class ConversationSerializer(serializers.ModelSerializer):
class Meta: class Meta:
model = Conversation 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())) @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()
status = serializers.CharField()
closed_by_uuid = serializers.UUIDField(allow_null=True)
created_at = serializers.DateTimeField() created_at = serializers.DateTimeField()
participants = serializers.ListField(child=serializers.UUIDField()) participants = serializers.ListField(child=serializers.UUIDField())
has_unread = serializers.BooleanField() has_unread = serializers.BooleanField()

View file

@ -1,16 +1,33 @@
from rest_framework import serializers from rest_framework import serializers
MEDIA_MESSAGE_TYPES = {"image", "video", "voice"}
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", "video", "voice"], 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) object_key = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def validate(self, attrs):
message_type = attrs.get("message_type", "text")
if message_type in MEDIA_MESSAGE_TYPES:
if not attrs.get("object_key"):
raise serializers.ValidationError(
{"object_key": "object_key is required for image, video, and voice messages."}
)
elif not attrs.get("text"):
raise serializers.ValidationError(
{"text": "text is required for text messages."}
)
return attrs
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")
mattermost_message_type = serializers.CharField(default="", allow_blank=True) mattermost_message_type = serializers.CharField(default="", allow_blank=True)
text = serializers.CharField(allow_null=True, required=False) text = 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

@ -0,0 +1,31 @@
from rest_framework import serializers
from apps.chat.models import ConversationReport, ReportReason
class CreateReportSerializer(serializers.Serializer):
reporter_uuid = serializers.UUIDField()
reason = serializers.ChoiceField(choices=ReportReason.choices)
other_reason_text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
def validate(self, attrs):
if attrs["reason"] == ReportReason.OTHER and not attrs.get("other_reason_text"):
raise serializers.ValidationError(
{"other_reason_text": "other_reason_text is required when reason is 'other'."}
)
return attrs
class ReportSerializer(serializers.ModelSerializer):
conversation_uuid = serializers.UUIDField(source="conversation_id")
class Meta:
model = ConversationReport
fields = [
"uuid",
"conversation_uuid",
"reporter_uuid",
"reason",
"other_reason_text",
"created_at",
]

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

@ -6,6 +6,7 @@ from apps.chat.integrations.mattermost.client import MattermostClient
from apps.chat.models import ( from apps.chat.models import (
Conversation, Conversation,
ConversationParticipant, ConversationParticipant,
ConversationStatus,
MattermostAccountMapping, MattermostAccountMapping,
) )
from apps.chat.services.account import AccountService from apps.chat.services.account import AccountService
@ -20,21 +21,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 +44,48 @@ 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]
) )
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,11 +5,14 @@ from apps.chat.events.event import MessageSentEvent
from apps.chat.events.publishers.longpoll import LongPollPublisher from apps.chat.events.publishers.longpoll import LongPollPublisher
from apps.chat.events.publishers.push import PushPublisher from apps.chat.events.publishers.push import PushPublisher
from apps.chat.events.publishers.websocket import WebSocketPublisher from apps.chat.events.publishers.websocket import WebSocketPublisher
from apps.chat.exceptions import ConversationClosedError
from apps.chat.integrations.mattermost.client import MattermostClient from apps.chat.integrations.mattermost.client import MattermostClient
from apps.chat.models import Conversation, MattermostAccountMapping from apps.chat.models import Conversation, ConversationStatus, MattermostAccountMapping
from apps.chat.services.account import AccountService from apps.chat.services.account import AccountService
from apps.chat.services.storage import StorageService from apps.chat.services.storage import StorageService
MEDIA_MESSAGE_TYPES = {"image", "video", "voice"}
class MessageService: class MessageService:
def __init__( def __init__(
@ -36,56 +39,59 @@ 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, object_key: str | None = None,
) -> dict: ) -> dict:
if not self._account.validate_user(sender_id): conversation = Conversation.objects.get(uuid=conversation_uuid)
raise ValueError(f"Sender {sender_id} is not valid") if conversation.status == ConversationStatus.CLOSED:
raise ConversationClosedError(f"Conversation {conversation_uuid} is closed")
file_url: str | None = None if not self._account.validate_user(sender_uuid):
if file is not None: raise ValueError(f"Sender {sender_uuid} is not valid")
filename = getattr(file, "name", f"{sender_id}")
file_url = self._storage.upload_file(file, filename)
if message_type == "text": # Media was already uploaded straight to MinIO by the client; the
mm_message = text or "" # Mattermost post body only ever carries "<type>:<object_key>", never
# a URL, so a stored link can't outlive its presigned expiry.
download_url: str | None = None
if message_type in MEDIA_MESSAGE_TYPES:
mm_message = f"{message_type}:{object_key}"
download_url = self._storage.get_download_url(object_key)
else: else:
mm_message = file_url or text or "" mm_message = text or ""
conversation = Conversation.objects.get(id=conversation_id)
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, "object_key": object_key, "url": download_url},
) )
for publisher in self._publishers: for publisher in self._publishers:
publisher.publish(event) publisher.publish(event)
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,
"mattermost_message_type": "", "mattermost_message_type": "",
"text": text, "text": text,
"url": file_url, "url": download_url,
"created_at": None, "created_at": None,
} }
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,
@ -95,7 +101,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
) )
@ -103,24 +109,35 @@ class MessageService:
return [self._normalize_post(p, mappings) for p in raw_posts] return [self._normalize_post(p, mappings) for p in raw_posts]
@staticmethod def _normalize_post(self, 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")
created_ms = post.get("create_at") created_ms = post.get("create_at")
created_at = ( created_at = (
datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc)
if created_ms if created_ms
else None else None
) )
message_type, sep, object_key = msg.partition(":")
if sep and message_type in MEDIA_MESSAGE_TYPES and object_key:
return {
"post_id": post.get("id"),
"sender_uuid": sender_uuid,
"message_type": message_type,
"mattermost_message_type": post.get("type", ""),
"text": None,
"url": self._storage.get_download_url(object_key),
"created_at": created_at,
}
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": "text",
"mattermost_message_type": post.get("type", ""), "mattermost_message_type": post.get("type", ""),
"text": None if is_url else msg, "text": msg,
"url": msg if is_url else None, "url": None,
"created_at": created_at, "created_at": created_at,
} }

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

@ -0,0 +1,20 @@
from uuid import UUID
from apps.chat.models import Conversation, ConversationReport, ReportReason
class ReportService:
def create(
self,
conversation_uuid: UUID,
reporter_uuid: UUID,
reason: str,
other_reason_text: str | None = None,
) -> ConversationReport:
conversation = Conversation.objects.get(uuid=conversation_uuid)
return ConversationReport.objects.create(
conversation=conversation,
reporter_uuid=reporter_uuid,
reason=reason,
other_reason_text=other_reason_text if reason == ReportReason.OTHER else None,
)

View file

@ -1,12 +1,17 @@
import io from datetime import timedelta
import mimetypes
from uuid import uuid4
from django.conf import settings from django.conf import settings
from minio import Minio from minio import Minio
class StorageService: class StorageService:
"""
Thin adapter around MinIO for the object-key-in-message-body flow: the
frontend uploads media directly to MinIO and only ever sends us the
resulting object key, so this service's job is limited to turning that
key back into a short-lived download URL on read.
"""
def __init__(self): def __init__(self):
endpoint = getattr(settings, "MINIO_ENDPOINT", None) endpoint = getattr(settings, "MINIO_ENDPOINT", None)
if not endpoint: if not endpoint:
@ -20,23 +25,21 @@ class StorageService:
secure=secure, secure=secure,
) )
self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat") self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat")
scheme = "https" if secure else "http" self._expiry = timedelta(
self._base_url = f"{scheme}://{endpoint}" seconds=getattr(settings, "MINIO_PRESIGN_EXPIRY_SECONDS", 3600)
def upload_file(self, file, filename: str) -> str:
data = file.read() if hasattr(file, "read") else file
size = len(data)
content_type, _ = mimetypes.guess_type(filename)
if not content_type:
content_type = "application/octet-stream"
object_name = f"{uuid4().hex}/{filename}"
self._client.put_object(
self._bucket,
object_name,
io.BytesIO(data),
size,
content_type=content_type,
) )
return f"{self._base_url}/{self._bucket}/{object_name}"
def get_download_url(self, object_key: str) -> str | None:
"""
Sign a time-limited download URL for an object the client already
uploaded. Generated fresh on every call rather than cached/stored,
since a stored link would eventually expire while still displayed.
Returns None on failure so one bad key can't fail an entire message
list — callers should treat that as "link unavailable", not an error.
"""
try:
return self._client.presigned_get_object(
self._bucket, object_key, expires=self._expiry
)
except Exception:
return None

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()) 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

@ -7,14 +7,13 @@ from apps.chat.models import Conversation, MattermostAccountMapping
from apps.chat.services.message import MessageService from apps.chat.services.message import MessageService
def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None): def _make_service(*, mm_post_id="post-1", download_url=None, publishers=None):
"""Return a MessageService with all external deps mocked.""" """Return a MessageService with all external deps mocked."""
mm_client = Mock() mm_client = Mock()
mm_client.post_message.return_value = mm_post_id mm_client.post_message.return_value = mm_post_id
storage = Mock() storage = Mock()
if storage_url: storage.get_download_url.return_value = download_url
storage.upload_file.return_value = storage_url
return ( return (
MessageService( MessageService(
@ -30,41 +29,42 @@ 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, 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") mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
storage.get_download_url.assert_not_called()
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"
assert result["text"] == "Hello world" assert result["text"] == "Hello world"
assert result["url"] is None
@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" object_key = "images/abc123/photo.jpg"
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) download_url = "http://minio.local/chat/images/abc123/photo.jpg?X-Amz-Signature=..."
svc, mm_client, storage = _make_service(download_url=download_url, publishers=[])
fake_file = Mock() result = svc.send(conv.uuid, sender_uuid, "image", object_key=object_key)
fake_file.name = "photo.jpg"
result = svc.send(conv.id, sender_id, "image", file=fake_file) 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}")
storage.upload_file.assert_called_once_with(fake_file, "photo.jpg") assert result["url"] == download_url
mm_client.post_message.assert_called_once_with("ch-send-image", file_url) assert result["message_type"] == "image"
assert result["url"] == file_url
@pytest.mark.django_db @pytest.mark.django_db
@ -74,9 +74,10 @@ 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
) )
object_key = "voice/def456/clip.ogg"
raw_posts = [ raw_posts = [
{ {
"id": "p1", "id": "p1",
@ -87,7 +88,7 @@ def test_list_messages_normalized():
{ {
"id": "p2", "id": "p2",
"user_id": mm_user_id, "user_id": mm_user_id,
"message": "http://minio.local/chat/file.pdf", "message": f"voice:{object_key}",
"create_at": 1700000001000, "create_at": 1700000001000,
}, },
{ {
@ -99,31 +100,63 @@ def test_list_messages_normalized():
}, },
] ]
download_url = "http://minio.local/chat/voice/def456/clip.ogg?X-Amz-Signature=..."
mm_client = Mock() mm_client = Mock()
mm_client.get_posts.return_value = raw_posts mm_client.get_posts.return_value = raw_posts
svc, _, _ = _make_service(publishers=[]) svc, _, storage = _make_service(download_url=download_url, 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) == 3 assert len(messages) == 3
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["mattermost_message_type"] == "" assert text_msg["mattermost_message_type"] == ""
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] voice_msg = messages[1]
assert file_msg["post_id"] == "p2" assert voice_msg["post_id"] == "p2"
assert file_msg["sender_id"] == sender_uuid assert voice_msg["sender_uuid"] == sender_uuid
assert file_msg["message_type"] == "file" assert voice_msg["message_type"] == "voice"
assert file_msg["mattermost_message_type"] == "" assert voice_msg["mattermost_message_type"] == ""
assert file_msg["url"] == "http://minio.local/chat/file.pdf" assert voice_msg["url"] == download_url
assert file_msg["text"] is None assert voice_msg["text"] is None
storage.get_download_url.assert_called_once_with(object_key)
system_msg = messages[2] system_msg = messages[2]
assert system_msg["post_id"] == "sys1" assert system_msg["post_id"] == "sys1"
assert system_msg["sender_uuid"] == sender_uuid
assert system_msg["mattermost_message_type"] == "system_add_to_channel" assert system_msg["mattermost_message_type"] == "system_add_to_channel"
@pytest.mark.django_db
def test_list_messages_treats_colon_in_plain_text_as_text():
"""A plain-text message that happens to contain a colon but doesn't use
a recognized media-type prefix must not be mistaken for a media message.
"""
conv = Conversation.objects.create(mattermost_channel_id="ch-list-colon")
raw_posts = [
{
"id": "p1",
"user_id": None,
"message": "Meeting at 10:30",
"create_at": 1700000000000,
},
]
mm_client = Mock()
mm_client.get_posts.return_value = raw_posts
svc, _, storage = _make_service(publishers=[])
svc._mm = mm_client
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"
storage.get_download_url.assert_not_called()

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

@ -0,0 +1,85 @@
import uuid
import pytest
from apps.chat.models import Conversation, ConversationReport, ReportReason
from apps.chat.serializers.reports import CreateReportSerializer
from apps.chat.services.report import ReportService
def _make_conv(channel_id="ch-report"):
return Conversation.objects.create(mattermost_channel_id=channel_id)
@pytest.mark.django_db
def test_create_report_with_predefined_reason():
conv = _make_conv()
reporter_uuid = uuid.uuid4()
report = ReportService().create(
conv.uuid, reporter_uuid, ReportReason.FRAUD
)
assert report.conversation_id == conv.uuid
assert report.reporter_uuid == reporter_uuid
assert report.reason == ReportReason.FRAUD
assert report.other_reason_text is None
assert ConversationReport.objects.filter(uuid=report.uuid).exists()
@pytest.mark.django_db
def test_create_report_other_reason_stores_text():
conv = _make_conv()
reporter_uuid = uuid.uuid4()
report = ReportService().create(
conv.uuid, reporter_uuid, ReportReason.OTHER, other_reason_text="spamming me constantly"
)
assert report.reason == ReportReason.OTHER
assert report.other_reason_text == "spamming me constantly"
@pytest.mark.django_db
def test_create_report_ignores_other_reason_text_for_non_other_reason():
conv = _make_conv()
reporter_uuid = uuid.uuid4()
report = ReportService().create(
conv.uuid,
reporter_uuid,
ReportReason.INAPPROPRIATE_CONTENT,
other_reason_text="this should be dropped",
)
assert report.reason == ReportReason.INAPPROPRIATE_CONTENT
assert report.other_reason_text is None
def test_serializer_requires_other_reason_text_when_reason_is_other():
serializer = CreateReportSerializer(
data={"reporter_uuid": str(uuid.uuid4()), "reason": ReportReason.OTHER}
)
assert not serializer.is_valid()
assert "other_reason_text" in serializer.errors
def test_serializer_valid_without_other_reason_text_for_predefined_reason():
serializer = CreateReportSerializer(
data={"reporter_uuid": str(uuid.uuid4()), "reason": ReportReason.HARASSING_MESSAGE}
)
assert serializer.is_valid(), serializer.errors
def test_serializer_valid_with_other_reason_text_for_other_reason():
serializer = CreateReportSerializer(
data={
"reporter_uuid": str(uuid.uuid4()),
"reason": ReportReason.OTHER,
"other_reason_text": "something specific",
}
)
assert serializer.is_valid(), serializer.errors

View file

@ -1,16 +1,22 @@
from django.urls import path from django.urls import path
from apps.chat.views.conversations import ( from apps.chat.views.conversations import (
ConversationCloseView,
ConversationCreateView, ConversationCreateView,
ConversationReopenView,
UserConversationListView, UserConversationListView,
) )
from apps.chat.views.messages import MessageView from apps.chat.views.messages import MessageView
from apps.chat.views.read_state import ChatEventsView, ReadStateView from apps.chat.views.read_state import ChatEventsView, ReadStateView
from apps.chat.views.reports import ConversationReportView
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"),
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"),
path("api/chats/<uuid:chat_uuid>/report/", ConversationReportView.as_view(), name="chat-report"),
] ]

View file

@ -9,6 +9,7 @@ from apps.chat.models import ConversationParticipant
from apps.chat.serializers.conversations import ( from apps.chat.serializers.conversations import (
ConversationListSerializer, ConversationListSerializer,
ConversationSerializer, ConversationSerializer,
ConversationUserActionSerializer,
CreateConversationSerializer, CreateConversationSerializer,
) )
from apps.chat.services.conversation import ConversationService from apps.chat.services.conversation import ConversationService
@ -29,8 +30,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 +51,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,26 +65,28 @@ 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,
"status": conv.status,
"closed_by_uuid": conv.closed_by_uuid,
"created_at": conv.created_at, "created_at": conv.created_at,
"participants": participants, "participants": participants,
"has_unread": has_unread, "has_unread": has_unread,
@ -92,3 +95,44 @@ class UserConversationListView(APIView):
) )
return Response(ConversationListSerializer(result, many=True).data) return Response(ConversationListSerializer(result, many=True).data)
class ConversationCloseView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
request=ConversationUserActionSerializer,
responses={200: ConversationSerializer},
)
def post(self, request, chat_uuid):
serializer = ConversationUserActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
conversation = ConversationService().close(
UUID(str(chat_uuid)), serializer.validated_data["user_uuid"]
)
return Response(ConversationSerializer(conversation).data)
class ConversationReopenView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
request=ConversationUserActionSerializer,
responses={200: ConversationSerializer, 403: None},
)
def post(self, request, chat_uuid):
serializer = ConversationUserActionSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
conversation = ConversationService().reopen(
UUID(str(chat_uuid)), serializer.validated_data["user_uuid"]
)
except PermissionError as exc:
return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN)
return Response(ConversationSerializer(conversation).data)

View file

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

@ -0,0 +1,32 @@
from uuid import UUID
from drf_spectacular.utils import extend_schema
from rest_framework import status
from rest_framework.response import Response
from rest_framework.views import APIView
from apps.chat.serializers.reports import CreateReportSerializer, ReportSerializer
from apps.chat.services.report import ReportService
class ConversationReportView(APIView):
authentication_classes = []
permission_classes = []
@extend_schema(
request=CreateReportSerializer,
responses={201: ReportSerializer},
)
def post(self, request, chat_uuid):
serializer = CreateReportSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
data = serializer.validated_data
report = ReportService().create(
conversation_uuid=UUID(str(chat_uuid)),
reporter_uuid=data["reporter_uuid"],
reason=data["reason"],
other_reason_text=data.get("other_reason_text"),
)
return Response(ReportSerializer(report).data, status=status.HTTP_201_CREATED)

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()),
] ]

View file

@ -24,3 +24,5 @@ MINIO_ENDPOINT=localhost:9000
MINIO_ACCESS_KEY=minioadmin MINIO_ACCESS_KEY=minioadmin
MINIO_SECRET_KEY=minioadmin MINIO_SECRET_KEY=minioadmin
MINIO_BUCKET_CHAT=chat MINIO_BUCKET_CHAT=chat
MINIO_SECURE=false
MINIO_PRESIGN_EXPIRY_SECONDS=3600

View file

@ -288,5 +288,7 @@ MINIO_ENDPOINT = config('MINIO_ENDPOINT', default=None)
MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None) MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None)
MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None) MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None)
MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat') MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat')
MINIO_SECURE = config('MINIO_SECURE', default=False, cast=bool)
MINIO_PRESIGN_EXPIRY_SECONDS = config('MINIO_PRESIGN_EXPIRY_SECONDS', default=3600, cast=int)
GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None) GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None)
GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None) GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None)