feature/minio-integration #2

Merged
Ghasemi merged 3 commits from feature/minio-integration into master 2026-07-20 06:32:07 -04:00
9 changed files with 260 additions and 1 deletions
Showing only changes of commit c8883832c9 - Show all commits

View file

@ -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

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

@ -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",
]

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

@ -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

@ -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

@ -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

@ -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/<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

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