From c8883832c9bc9717432cdad1862d93ed53e6313e Mon Sep 17 00:00:00 2001 From: Ali Asadi Date: Mon, 20 Jul 2026 13:58:42 +0330 Subject: [PATCH] add report chat --- apps/chat/admin.py | 22 ++++- .../migrations/0004_conversationreport.py | 29 +++++++ apps/chat/models/__init__.py | 3 + apps/chat/models/report.py | 37 ++++++++ apps/chat/serializers/reports.py | 31 +++++++ apps/chat/services/report.py | 20 +++++ apps/chat/tests/test_reports.py | 85 +++++++++++++++++++ apps/chat/urls.py | 2 + apps/chat/views/reports.py | 32 +++++++ 9 files changed, 260 insertions(+), 1 deletion(-) create mode 100644 apps/chat/migrations/0004_conversationreport.py create mode 100644 apps/chat/models/report.py create mode 100644 apps/chat/serializers/reports.py create mode 100644 apps/chat/services/report.py create mode 100644 apps/chat/tests/test_reports.py create mode 100644 apps/chat/views/reports.py 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/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 873fa0d..5eb06c5 100644 --- a/apps/chat/models/__init__.py +++ b/apps/chat/models/__init__.py @@ -2,6 +2,7 @@ from apps.chat.models.conversation import Conversation, ConversationStatus, Conv 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", @@ -10,4 +11,6 @@ __all__ = [ "ConversationParticipant", "MattermostAccountMapping", "ConversationReadState", + "ConversationReport", + "ReportReason", ] 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/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/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/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 1571a28..44d9bf4 100644 --- a/apps/chat/urls.py +++ b/apps/chat/urls.py @@ -8,6 +8,7 @@ from apps.chat.views.conversations import ( ) 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"), @@ -17,4 +18,5 @@ urlpatterns = [ 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/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)