diff --git a/.env.example b/.env.example index 18b410d..10cfe8d 100644 --- a/.env.example +++ b/.env.example @@ -27,10 +27,15 @@ MATTERMOST_SERVICE_USERNAME=chat-service MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid # 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_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_CHAT=chat +MINIO_SECURE=false +MINIO_PRESIGN_EXPIRY_SECONDS=3600 # Chat long-poll tuning CHAT_LONG_POLL_TIMEOUT_SECONDS=25 diff --git a/apps/chat/admin.py b/apps/chat/admin.py index 8c38f3f..a389924 100644 --- a/apps/chat/admin.py +++ b/apps/chat/admin.py @@ -1,3 +1,23 @@ 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 diff --git a/apps/chat/events/event.py b/apps/chat/events/event.py index 5d4d448..85871c5 100644 --- a/apps/chat/events/event.py +++ b/apps/chat/events/event.py @@ -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 diff --git a/apps/chat/events/publishers/longpoll.py b/apps/chat/events/publishers/longpoll.py index 101487f..0afd568 100644 --- a/apps/chat/events/publishers/longpoll.py +++ b/apps/chat/events/publishers/longpoll.py @@ -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}, } diff --git a/apps/chat/events/publishers/websocket.py b/apps/chat/events/publishers/websocket.py index 22e2f0f..934d8f1 100644 --- a/apps/chat/events/publishers/websocket.py +++ b/apps/chat/events/publishers/websocket.py @@ -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, }, diff --git a/apps/chat/exceptions.py b/apps/chat/exceptions.py new file mode 100644 index 0000000..53b452c --- /dev/null +++ b/apps/chat/exceptions.py @@ -0,0 +1,2 @@ +class ConversationClosedError(Exception): + """Raised when attempting to send a message to a closed conversation.""" diff --git a/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py b/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py new file mode 100644 index 0000000..ae7a301 --- /dev/null +++ b/apps/chat/migrations/0002_remove_conversationparticipant_unique_participant_per_conversation_and_more.py @@ -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'), + ), + ] diff --git a/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py b/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py new file mode 100644 index 0000000..41abdd4 --- /dev/null +++ b/apps/chat/migrations/0003_conversation_closed_by_uuid_conversation_status_and_more.py @@ -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'), + ), + ] diff --git a/apps/chat/migrations/0004_conversationreport.py b/apps/chat/migrations/0004_conversationreport.py new file mode 100644 index 0000000..8b9352c --- /dev/null +++ b/apps/chat/migrations/0004_conversationreport.py @@ -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')], + }, + ), + ] diff --git a/apps/chat/models/__init__.py b/apps/chat/models/__init__.py index d9080a4..5eb06c5 100644 --- a/apps/chat/models/__init__.py +++ b/apps/chat/models/__init__.py @@ -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.participant import ConversationParticipant from apps.chat.models.read_state import ConversationReadState +from apps.chat.models.report import ConversationReport, ReportReason __all__ = [ "Conversation", "ConversationType", + "ConversationStatus", "ConversationParticipant", "MattermostAccountMapping", "ConversationReadState", + "ConversationReport", + "ReportReason", ] diff --git a/apps/chat/models/conversation.py b/apps/chat/models/conversation.py index d0f5314..2f67bc1 100644 --- a/apps/chat/models/conversation.py +++ b/apps/chat/models/conversation.py @@ -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) diff --git a/apps/chat/models/mapping.py b/apps/chat/models/mapping.py index 184a86a..4254b97 100644 --- a/apps/chat/models/mapping.py +++ b/apps/chat/models/mapping.py @@ -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}" diff --git a/apps/chat/models/participant.py b/apps/chat/models/participant.py index 528c30d..be150dc 100644 --- a/apps/chat/models/participant.py +++ b/apps/chat/models/participant.py @@ -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}" diff --git a/apps/chat/models/read_state.py b/apps/chat/models/read_state.py index 259c75b..e28a356 100644 --- a/apps/chat/models/read_state.py +++ b/apps/chat/models/read_state.py @@ -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}" diff --git a/apps/chat/models/report.py b/apps/chat/models/report.py new file mode 100644 index 0000000..e6e2460 --- /dev/null +++ b/apps/chat/models/report.py @@ -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}" diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py index 4eaf918..d757ecf 100644 --- a/apps/chat/serializers/conversations.py +++ b/apps/chat/serializers/conversations.py @@ -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() diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index 8c493be..29ab299 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -1,16 +1,33 @@ from rest_framework import serializers +MEDIA_MESSAGE_TYPES = {"image", "video", "voice"} + class SendMessageSerializer(serializers.Serializer): - sender_id = serializers.UUIDField() - message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text") + sender_uuid = serializers.UUIDField() + message_type = serializers.ChoiceField( + choices=["text", "image", "video", "voice"], default="text" + ) 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): post_id = serializers.CharField() - sender_id = serializers.UUIDField(allow_null=True) + sender_uuid = serializers.UUIDField(allow_null=True) message_type = serializers.CharField(default="text") mattermost_message_type = serializers.CharField(default="", allow_blank=True) text = serializers.CharField(allow_null=True, required=False) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py index 9c3ecc6..c8c9137 100644 --- a/apps/chat/serializers/read_state.py +++ b/apps/chat/serializers/read_state.py @@ -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() diff --git a/apps/chat/serializers/reports.py b/apps/chat/serializers/reports.py new file mode 100644 index 0000000..09ca0ce --- /dev/null +++ b/apps/chat/serializers/reports.py @@ -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", + ] diff --git a/apps/chat/services/account.py b/apps/chat/services/account.py index ea4e885..163951f 100644 --- a/apps/chat/services/account.py +++ b/apps/chat/services/account.py @@ -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 diff --git a/apps/chat/services/conversation.py b/apps/chat/services/conversation.py index 8a0fe75..b11fe9b 100644 --- a/apps/chat/services/conversation.py +++ b/apps/chat/services/conversation.py @@ -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 diff --git a/apps/chat/services/message.py b/apps/chat/services/message.py index cee7faa..2a75337 100644 --- a/apps/chat/services/message.py +++ b/apps/chat/services/message.py @@ -5,11 +5,14 @@ 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 +MEDIA_MESSAGE_TYPES = {"image", "video", "voice"} + class MessageService: def __init__( @@ -36,56 +39,59 @@ class MessageService: def send( self, - conversation_id, - sender_id: UUID, + conversation_uuid, + sender_uuid: UUID, message_type: str, text: str | None = None, - file=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") - file_url: str | None = None - if file is not None: - filename = getattr(file, "name", f"{sender_id}") - file_url = self._storage.upload_file(file, filename) + if not self._account.validate_user(sender_uuid): + raise ValueError(f"Sender {sender_uuid} is not valid") - if message_type == "text": - mm_message = text or "" + # Media was already uploaded straight to MinIO by the client; the + # Mattermost post body only ever carries ":", 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: - 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) 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, "file_url": file_url}, + payload={"text": text, "object_key": object_key, "url": download_url}, ) for publisher in self._publishers: publisher.publish(event) return { "post_id": post_id, - "sender_id": sender_id, + "sender_uuid": sender_uuid, "message_type": message_type, "mattermost_message_type": "", "text": text, - "url": file_url, + "url": download_url, "created_at": None, } 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, @@ -95,7 +101,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 ) @@ -103,24 +109,35 @@ class MessageService: return [self._normalize_post(p, mappings) for p in raw_posts] - @staticmethod - def _normalize_post(post: dict, mappings: dict) -> dict: + 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", "") - is_url = msg.startswith("http") created_ms = post.get("create_at") created_at = ( datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc) if created_ms 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 { "post_id": post.get("id"), - "sender_id": sender_id, - "message_type": "file" if is_url else "text", + "sender_uuid": sender_uuid, + "message_type": "text", "mattermost_message_type": post.get("type", ""), - "text": None if is_url else msg, - "url": msg if is_url else None, + "text": msg, + "url": None, "created_at": created_at, } diff --git a/apps/chat/services/read_state.py b/apps/chat/services/read_state.py index 679b9c9..8cb2a86 100644 --- a/apps/chat/services/read_state.py +++ b/apps/chat/services/read_state.py @@ -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 ) diff --git a/apps/chat/services/realtime.py b/apps/chat/services/realtime.py index 190fe10..1d30ea1 100644 --- a/apps/chat/services/realtime.py +++ b/apps/chat/services/realtime.py @@ -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}, diff --git a/apps/chat/services/report.py b/apps/chat/services/report.py new file mode 100644 index 0000000..30ead42 --- /dev/null +++ b/apps/chat/services/report.py @@ -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, + ) diff --git a/apps/chat/services/storage.py b/apps/chat/services/storage.py index eff1738..65a1b03 100644 --- a/apps/chat/services/storage.py +++ b/apps/chat/services/storage.py @@ -1,12 +1,17 @@ -import io -import mimetypes -from uuid import uuid4 +from datetime import timedelta from django.conf import settings from minio import Minio 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): endpoint = getattr(settings, "MINIO_ENDPOINT", None) if not endpoint: @@ -20,23 +25,21 @@ class StorageService: secure=secure, ) self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat") - scheme = "https" if secure else "http" - self._base_url = f"{scheme}://{endpoint}" - - 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, + self._expiry = timedelta( + seconds=getattr(settings, "MINIO_PRESIGN_EXPIRY_SECONDS", 3600) ) - 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 diff --git a/apps/chat/tests/test_conversation_close.py b/apps/chat/tests/test_conversation_close.py new file mode 100644 index 0000000..4092c29 --- /dev/null +++ b/apps/chat/tests/test_conversation_close.py @@ -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") diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py index ec81a64..278a14e 100644 --- a/apps/chat/tests/test_conversations.py +++ b/apps/chat/tests/test_conversations.py @@ -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 diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index 3b56fb6..d0126c4 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -7,14 +7,13 @@ from apps.chat.models import Conversation, MattermostAccountMapping 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.""" mm_client = Mock() mm_client.post_message.return_value = mm_post_id storage = Mock() - if storage_url: - storage.upload_file.return_value = storage_url + storage.get_download_url.return_value = download_url return ( MessageService( @@ -30,41 +29,42 @@ def _make_service(*, mm_post_id="post-1", storage_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, _ = _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") + storage.get_download_url.assert_not_called() ws_publisher.publish.assert_called_once() 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" assert result["text"] == "Hello world" + assert result["url"] is None @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() - file_url = "http://minio.local/chat/abc123/photo.jpg" - svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[]) + 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=[]) - fake_file = Mock() - fake_file.name = "photo.jpg" + result = svc.send(conv.uuid, sender_uuid, "image", object_key=object_key) - result = svc.send(conv.id, sender_id, "image", file=fake_file) - - storage.upload_file.assert_called_once_with(fake_file, "photo.jpg") - mm_client.post_message.assert_called_once_with("ch-send-image", file_url) - assert result["url"] == file_url + 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}") + assert result["url"] == download_url + assert result["message_type"] == "image" @pytest.mark.django_db @@ -74,9 +74,10 @@ 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" raw_posts = [ { "id": "p1", @@ -87,7 +88,7 @@ def test_list_messages_normalized(): { "id": "p2", "user_id": mm_user_id, - "message": "http://minio.local/chat/file.pdf", + "message": f"voice:{object_key}", "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.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 - 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 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["mattermost_message_type"] == "" assert text_msg["text"] == "First message" assert text_msg["url"] is None - file_msg = messages[1] - assert file_msg["post_id"] == "p2" - assert file_msg["sender_id"] == sender_uuid - assert file_msg["message_type"] == "file" - assert file_msg["mattermost_message_type"] == "" - assert file_msg["url"] == "http://minio.local/chat/file.pdf" - assert file_msg["text"] is None + voice_msg = messages[1] + assert voice_msg["post_id"] == "p2" + assert voice_msg["sender_uuid"] == sender_uuid + assert voice_msg["message_type"] == "voice" + assert voice_msg["mattermost_message_type"] == "" + assert voice_msg["url"] == download_url + assert voice_msg["text"] is None + + storage.get_download_url.assert_called_once_with(object_key) system_msg = messages[2] assert system_msg["post_id"] == "sys1" + assert system_msg["sender_uuid"] == sender_uuid 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() diff --git a/apps/chat/tests/test_read_state.py b/apps/chat/tests/test_read_state.py index 5eeb480..93c8b18 100644 --- a/apps/chat/tests/test_read_state.py +++ b/apps/chat/tests/test_read_state.py @@ -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 diff --git a/apps/chat/tests/test_reports.py b/apps/chat/tests/test_reports.py new file mode 100644 index 0000000..f468f26 --- /dev/null +++ b/apps/chat/tests/test_reports.py @@ -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 diff --git a/apps/chat/urls.py b/apps/chat/urls.py index f024578..44d9bf4 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -1,16 +1,22 @@ from django.urls import path from apps.chat.views.conversations import ( + ConversationCloseView, ConversationCreateView, + ConversationReopenView, UserConversationListView, ) from apps.chat.views.messages import MessageView from apps.chat.views.read_state import ChatEventsView, ReadStateView +from apps.chat.views.reports import ConversationReportView urlpatterns = [ path("api/chats/", ConversationCreateView.as_view(), name="chat-create"), - path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), - path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), - path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), - path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), + path("api/users//chats/", UserConversationListView.as_view(), name="user-chat-list"), + path("api/chats//messages/", MessageView.as_view(), name="chat-messages"), + path("api/chats//read/", ReadStateView.as_view(), name="chat-read"), + path("api/chats//events/", ChatEventsView.as_view(), name="chat-events"), + path("api/chats//close/", ConversationCloseView.as_view(), name="chat-close"), + path("api/chats//reopen/", ConversationReopenView.as_view(), name="chat-reopen"), + path("api/chats//report/", ConversationReportView.as_view(), name="chat-report"), ] diff --git a/apps/chat/views/conversations.py b/apps/chat/views/conversations.py index 0a5cd8c..f1dc8e1 100644 --- a/apps/chat/views/conversations.py +++ b/apps/chat/views/conversations.py @@ -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) diff --git a/apps/chat/views/messages.py b/apps/chat/views/messages.py index 7c0eb06..5bc1aee 100644 --- a/apps/chat/views/messages.py +++ b/apps/chat/views/messages.py @@ -5,6 +5,7 @@ from rest_framework import status from rest_framework.response import Response from rest_framework.views import APIView +from apps.chat.exceptions import ConversationClosedError from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer from apps.chat.services.message import MessageService @@ -15,20 +16,23 @@ class MessageView(APIView): @extend_schema( request=SendMessageSerializer, - responses={201: MessageSerializer}, + responses={201: MessageSerializer, 403: None}, ) - def post(self, request, chat_id): + def post(self, request, chat_uuid): serializer = SendMessageSerializer(data=request.data) serializer.is_valid(raise_exception=True) data = serializer.validated_data - result = MessageService().send( - conversation_id=UUID(str(chat_id)), - sender_id=data["sender_id"], - message_type=data["message_type"], - text=data.get("text"), - file=data.get("file"), - ) + try: + result = MessageService().send( + conversation_uuid=UUID(str(chat_uuid)), + sender_uuid=data["sender_uuid"], + message_type=data["message_type"], + text=data.get("text"), + object_key=data.get("object_key"), + ) + except ConversationClosedError as exc: + return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN) return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED) @@ -45,14 +49,14 @@ class MessageView(APIView): ], responses={200: MessageSerializer(many=True)}, ) - def get(self, request, chat_id): + def get(self, request, chat_uuid): page = int(request.query_params.get("page", 0)) per_page = int(request.query_params.get("per_page", 20)) since_raw = request.query_params.get("since") since = int(since_raw) if since_raw else None messages = MessageService().list_messages( - conversation_id=UUID(str(chat_id)), + conversation_uuid=UUID(str(chat_uuid)), page=page, per_page=per_page, since=since, diff --git a/apps/chat/views/read_state.py b/apps/chat/views/read_state.py index f63ef01..b477941 100644 --- a/apps/chat/views/read_state.py +++ b/apps/chat/views/read_state.py @@ -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) diff --git a/apps/chat/views/reports.py b/apps/chat/views/reports.py new file mode 100644 index 0000000..519bba7 --- /dev/null +++ b/apps/chat/views/reports.py @@ -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) diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py index d8866f8..9298984 100644 --- a/apps/chat/websocket/consumers.py +++ b/apps/chat/websocket/consumers.py @@ -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() diff --git a/apps/chat/websocket/routing.py b/apps/chat/websocket/routing.py index 1f87234..3bd2b88 100644 --- a/apps/chat/websocket/routing.py +++ b/apps/chat/websocket/routing.py @@ -3,5 +3,5 @@ from django.urls import path from apps.chat.websocket.consumers import ChatConsumer websocket_urlpatterns = [ - path("ws/chat//", ChatConsumer.as_asgi()), + path("ws/chat//", ChatConsumer.as_asgi()), ] diff --git a/env.sample b/env.sample index 1dd3033..4177b22 100644 --- a/env.sample +++ b/env.sample @@ -24,3 +24,5 @@ MINIO_ENDPOINT=localhost:9000 MINIO_ACCESS_KEY=minioadmin MINIO_SECRET_KEY=minioadmin MINIO_BUCKET_CHAT=chat +MINIO_SECURE=false +MINIO_PRESIGN_EXPIRY_SECONDS=3600 diff --git a/main/settings.py b/main/settings.py index 47446b4..be4b5d8 100644 --- a/main/settings.py +++ b/main/settings.py @@ -288,5 +288,7 @@ MINIO_ENDPOINT = config('MINIO_ENDPOINT', default=None) MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None) MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None) 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) GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None)