add report chat
This commit is contained in:
parent
e4cab3ab62
commit
c8883832c9
9 changed files with 260 additions and 1 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
29
apps/chat/migrations/0004_conversationreport.py
Normal file
29
apps/chat/migrations/0004_conversationreport.py
Normal 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')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -2,6 +2,7 @@ from apps.chat.models.conversation import Conversation, ConversationStatus, Conv
|
||||||
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",
|
||||||
|
|
@ -10,4 +11,6 @@ __all__ = [
|
||||||
"ConversationParticipant",
|
"ConversationParticipant",
|
||||||
"MattermostAccountMapping",
|
"MattermostAccountMapping",
|
||||||
"ConversationReadState",
|
"ConversationReadState",
|
||||||
|
"ConversationReport",
|
||||||
|
"ReportReason",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
37
apps/chat/models/report.py
Normal file
37
apps/chat/models/report.py
Normal 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}"
|
||||||
31
apps/chat/serializers/reports.py
Normal file
31
apps/chat/serializers/reports.py
Normal 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",
|
||||||
|
]
|
||||||
20
apps/chat/services/report.py
Normal file
20
apps/chat/services/report.py
Normal 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,
|
||||||
|
)
|
||||||
85
apps/chat/tests/test_reports.py
Normal file
85
apps/chat/tests/test_reports.py
Normal 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
|
||||||
|
|
@ -8,6 +8,7 @@ from apps.chat.views.conversations import (
|
||||||
)
|
)
|
||||||
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"),
|
||||||
|
|
@ -17,4 +18,5 @@ urlpatterns = [
|
||||||
path("api/chats/<uuid:chat_uuid>/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>/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>/reopen/", ConversationReopenView.as_view(), name="chat-reopen"),
|
||||||
|
path("api/chats/<uuid:chat_uuid>/report/", ConversationReportView.as_view(), name="chat-report"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
32
apps/chat/views/reports.py
Normal file
32
apps/chat/views/reports.py
Normal 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)
|
||||||
Loading…
Add table
Reference in a new issue