Merge pull request 'feature/minio-integration' (#2) from feature/minio-integration into master
Reviewed-on: #2
This commit is contained in:
commit
7a0236d24a
17 changed files with 396 additions and 64 deletions
|
|
@ -27,10 +27,15 @@ MATTERMOST_SERVICE_USERNAME=chat-service
|
||||||
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
|
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
|
||||||
|
|
||||||
# MinIO object storage
|
# MinIO object storage
|
||||||
|
# The frontend uploads media directly to MinIO and sends us only the
|
||||||
|
# resulting object key — these credentials are used solely to sign
|
||||||
|
# short-lived download URLs when messages are read back.
|
||||||
MINIO_ENDPOINT=localhost:9000
|
MINIO_ENDPOINT=localhost:9000
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
MINIO_BUCKET_CHAT=chat
|
MINIO_BUCKET_CHAT=chat
|
||||||
|
MINIO_SECURE=false
|
||||||
|
MINIO_PRESIGN_EXPIRY_SECONDS=3600
|
||||||
|
|
||||||
# Chat long-poll tuning
|
# Chat long-poll tuning
|
||||||
CHAT_LONG_POLL_TIMEOUT_SECONDS=25
|
CHAT_LONG_POLL_TIMEOUT_SECONDS=25
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
@ -1,11 +1,28 @@
|
||||||
from rest_framework import serializers
|
from rest_framework import serializers
|
||||||
|
|
||||||
|
MEDIA_MESSAGE_TYPES = {"image", "video", "voice"}
|
||||||
|
|
||||||
|
|
||||||
class SendMessageSerializer(serializers.Serializer):
|
class SendMessageSerializer(serializers.Serializer):
|
||||||
sender_uuid = serializers.UUIDField()
|
sender_uuid = serializers.UUIDField()
|
||||||
message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text")
|
message_type = serializers.ChoiceField(
|
||||||
|
choices=["text", "image", "video", "voice"], default="text"
|
||||||
|
)
|
||||||
text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
file = serializers.FileField(required=False, allow_null=True)
|
object_key = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||||
|
|
||||||
|
def validate(self, attrs):
|
||||||
|
message_type = attrs.get("message_type", "text")
|
||||||
|
if message_type in MEDIA_MESSAGE_TYPES:
|
||||||
|
if not attrs.get("object_key"):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"object_key": "object_key is required for image, video, and voice messages."}
|
||||||
|
)
|
||||||
|
elif not attrs.get("text"):
|
||||||
|
raise serializers.ValidationError(
|
||||||
|
{"text": "text is required for text messages."}
|
||||||
|
)
|
||||||
|
return attrs
|
||||||
|
|
||||||
|
|
||||||
class MessageSerializer(serializers.Serializer):
|
class MessageSerializer(serializers.Serializer):
|
||||||
|
|
|
||||||
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",
|
||||||
|
]
|
||||||
|
|
@ -11,6 +11,8 @@ from apps.chat.models import Conversation, ConversationStatus, MattermostAccount
|
||||||
from apps.chat.services.account import AccountService
|
from apps.chat.services.account import AccountService
|
||||||
from apps.chat.services.storage import StorageService
|
from apps.chat.services.storage import StorageService
|
||||||
|
|
||||||
|
MEDIA_MESSAGE_TYPES = {"image", "video", "voice"}
|
||||||
|
|
||||||
|
|
||||||
class MessageService:
|
class MessageService:
|
||||||
def __init__(
|
def __init__(
|
||||||
|
|
@ -41,7 +43,7 @@ class MessageService:
|
||||||
sender_uuid: UUID,
|
sender_uuid: UUID,
|
||||||
message_type: str,
|
message_type: str,
|
||||||
text: str | None = None,
|
text: str | None = None,
|
||||||
file=None,
|
object_key: str | None = None,
|
||||||
) -> dict:
|
) -> dict:
|
||||||
conversation = Conversation.objects.get(uuid=conversation_uuid)
|
conversation = Conversation.objects.get(uuid=conversation_uuid)
|
||||||
if conversation.status == ConversationStatus.CLOSED:
|
if conversation.status == ConversationStatus.CLOSED:
|
||||||
|
|
@ -50,15 +52,15 @@ class MessageService:
|
||||||
if not self._account.validate_user(sender_uuid):
|
if not self._account.validate_user(sender_uuid):
|
||||||
raise ValueError(f"Sender {sender_uuid} is not valid")
|
raise ValueError(f"Sender {sender_uuid} is not valid")
|
||||||
|
|
||||||
file_url: str | None = None
|
# Media was already uploaded straight to MinIO by the client; the
|
||||||
if file is not None:
|
# Mattermost post body only ever carries "<type>:<object_key>", never
|
||||||
filename = getattr(file, "name", f"{sender_uuid}")
|
# a URL, so a stored link can't outlive its presigned expiry.
|
||||||
file_url = self._storage.upload_file(file, filename)
|
download_url: str | None = None
|
||||||
|
if message_type in MEDIA_MESSAGE_TYPES:
|
||||||
if message_type == "text":
|
mm_message = f"{message_type}:{object_key}"
|
||||||
mm_message = text or ""
|
download_url = self._storage.get_download_url(object_key)
|
||||||
else:
|
else:
|
||||||
mm_message = file_url or text or ""
|
mm_message = text or ""
|
||||||
|
|
||||||
post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message)
|
post_id = self._mm.post_message(conversation.mattermost_channel_id, mm_message)
|
||||||
|
|
||||||
|
|
@ -67,7 +69,7 @@ class MessageService:
|
||||||
post_id=post_id,
|
post_id=post_id,
|
||||||
sender_uuid=sender_uuid,
|
sender_uuid=sender_uuid,
|
||||||
message_type=message_type,
|
message_type=message_type,
|
||||||
payload={"text": text, "file_url": file_url},
|
payload={"text": text, "object_key": object_key, "url": download_url},
|
||||||
)
|
)
|
||||||
for publisher in self._publishers:
|
for publisher in self._publishers:
|
||||||
publisher.publish(event)
|
publisher.publish(event)
|
||||||
|
|
@ -77,7 +79,7 @@ class MessageService:
|
||||||
"sender_uuid": sender_uuid,
|
"sender_uuid": sender_uuid,
|
||||||
"message_type": message_type,
|
"message_type": message_type,
|
||||||
"text": text,
|
"text": text,
|
||||||
"url": file_url,
|
"url": download_url,
|
||||||
"created_at": None,
|
"created_at": None,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -106,23 +108,33 @@ class MessageService:
|
||||||
|
|
||||||
return [self._normalize_post(p, mappings) for p in raw_posts]
|
return [self._normalize_post(p, mappings) for p in raw_posts]
|
||||||
|
|
||||||
@staticmethod
|
def _normalize_post(self, post: dict, mappings: dict) -> dict:
|
||||||
def _normalize_post(post: dict, mappings: dict) -> dict:
|
|
||||||
mm_uid = post.get("user_id")
|
mm_uid = post.get("user_id")
|
||||||
sender_uuid = mappings.get(mm_uid)
|
sender_uuid = mappings.get(mm_uid)
|
||||||
msg = post.get("message", "")
|
msg = post.get("message", "")
|
||||||
is_url = msg.startswith("http")
|
|
||||||
created_ms = post.get("create_at")
|
created_ms = post.get("create_at")
|
||||||
created_at = (
|
created_at = (
|
||||||
datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc)
|
datetime.fromtimestamp(created_ms / 1000, tz=timezone.utc)
|
||||||
if created_ms
|
if created_ms
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
|
||||||
|
message_type, sep, object_key = msg.partition(":")
|
||||||
|
if sep and message_type in MEDIA_MESSAGE_TYPES and object_key:
|
||||||
return {
|
return {
|
||||||
"post_id": post.get("id"),
|
"post_id": post.get("id"),
|
||||||
"sender_uuid": sender_uuid,
|
"sender_uuid": sender_uuid,
|
||||||
"message_type": "file" if is_url else "text",
|
"message_type": message_type,
|
||||||
"text": None if is_url else msg,
|
"text": None,
|
||||||
"url": msg if is_url else None,
|
"url": self._storage.get_download_url(object_key),
|
||||||
|
"created_at": created_at,
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"post_id": post.get("id"),
|
||||||
|
"sender_uuid": sender_uuid,
|
||||||
|
"message_type": "text",
|
||||||
|
"text": msg,
|
||||||
|
"url": None,
|
||||||
"created_at": created_at,
|
"created_at": 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,
|
||||||
|
)
|
||||||
|
|
@ -1,12 +1,17 @@
|
||||||
import io
|
from datetime import timedelta
|
||||||
import mimetypes
|
|
||||||
from uuid import uuid4
|
|
||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from minio import Minio
|
from minio import Minio
|
||||||
|
|
||||||
|
|
||||||
class StorageService:
|
class StorageService:
|
||||||
|
"""
|
||||||
|
Thin adapter around MinIO for the object-key-in-message-body flow: the
|
||||||
|
frontend uploads media directly to MinIO and only ever sends us the
|
||||||
|
resulting object key, so this service's job is limited to turning that
|
||||||
|
key back into a short-lived download URL on read.
|
||||||
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
endpoint = getattr(settings, "MINIO_ENDPOINT", None)
|
endpoint = getattr(settings, "MINIO_ENDPOINT", None)
|
||||||
if not endpoint:
|
if not endpoint:
|
||||||
|
|
@ -20,23 +25,21 @@ class StorageService:
|
||||||
secure=secure,
|
secure=secure,
|
||||||
)
|
)
|
||||||
self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat")
|
self._bucket = getattr(settings, "MINIO_BUCKET_CHAT", "chat")
|
||||||
scheme = "https" if secure else "http"
|
self._expiry = timedelta(
|
||||||
self._base_url = f"{scheme}://{endpoint}"
|
seconds=getattr(settings, "MINIO_PRESIGN_EXPIRY_SECONDS", 3600)
|
||||||
|
|
||||||
def upload_file(self, file, filename: str) -> str:
|
|
||||||
data = file.read() if hasattr(file, "read") else file
|
|
||||||
size = len(data)
|
|
||||||
|
|
||||||
content_type, _ = mimetypes.guess_type(filename)
|
|
||||||
if not content_type:
|
|
||||||
content_type = "application/octet-stream"
|
|
||||||
|
|
||||||
object_name = f"{uuid4().hex}/{filename}"
|
|
||||||
self._client.put_object(
|
|
||||||
self._bucket,
|
|
||||||
object_name,
|
|
||||||
io.BytesIO(data),
|
|
||||||
size,
|
|
||||||
content_type=content_type,
|
|
||||||
)
|
)
|
||||||
return f"{self._base_url}/{self._bucket}/{object_name}"
|
|
||||||
|
def get_download_url(self, object_key: str) -> str | None:
|
||||||
|
"""
|
||||||
|
Sign a time-limited download URL for an object the client already
|
||||||
|
uploaded. Generated fresh on every call rather than cached/stored,
|
||||||
|
since a stored link would eventually expire while still displayed.
|
||||||
|
Returns None on failure so one bad key can't fail an entire message
|
||||||
|
list — callers should treat that as "link unavailable", not an error.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return self._client.presigned_get_object(
|
||||||
|
self._bucket, object_key, expires=self._expiry
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
|
||||||
|
|
@ -7,14 +7,13 @@ from apps.chat.models import Conversation, MattermostAccountMapping
|
||||||
from apps.chat.services.message import MessageService
|
from apps.chat.services.message import MessageService
|
||||||
|
|
||||||
|
|
||||||
def _make_service(*, mm_post_id="post-1", storage_url=None, publishers=None):
|
def _make_service(*, mm_post_id="post-1", download_url=None, publishers=None):
|
||||||
"""Return a MessageService with all external deps mocked."""
|
"""Return a MessageService with all external deps mocked."""
|
||||||
mm_client = Mock()
|
mm_client = Mock()
|
||||||
mm_client.post_message.return_value = mm_post_id
|
mm_client.post_message.return_value = mm_post_id
|
||||||
|
|
||||||
storage = Mock()
|
storage = Mock()
|
||||||
if storage_url:
|
storage.get_download_url.return_value = download_url
|
||||||
storage.upload_file.return_value = storage_url
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
MessageService(
|
MessageService(
|
||||||
|
|
@ -33,11 +32,12 @@ def test_send_text_message():
|
||||||
sender_uuid = uuid.uuid4()
|
sender_uuid = uuid.uuid4()
|
||||||
|
|
||||||
ws_publisher = Mock()
|
ws_publisher = Mock()
|
||||||
svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
|
svc, mm_client, storage = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
|
||||||
|
|
||||||
result = svc.send(conv.uuid, sender_uuid, "text", text="Hello world")
|
result = svc.send(conv.uuid, sender_uuid, "text", text="Hello world")
|
||||||
|
|
||||||
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
|
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
|
||||||
|
storage.get_download_url.assert_not_called()
|
||||||
ws_publisher.publish.assert_called_once()
|
ws_publisher.publish.assert_called_once()
|
||||||
|
|
||||||
event = ws_publisher.publish.call_args[0][0]
|
event = ws_publisher.publish.call_args[0][0]
|
||||||
|
|
@ -47,6 +47,7 @@ def test_send_text_message():
|
||||||
|
|
||||||
assert result["post_id"] == "post-abc"
|
assert result["post_id"] == "post-abc"
|
||||||
assert result["text"] == "Hello world"
|
assert result["text"] == "Hello world"
|
||||||
|
assert result["url"] is None
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
@ -54,17 +55,16 @@ def test_send_image_message():
|
||||||
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
|
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
|
||||||
sender_uuid = uuid.uuid4()
|
sender_uuid = uuid.uuid4()
|
||||||
|
|
||||||
file_url = "http://minio.local/chat/abc123/photo.jpg"
|
object_key = "images/abc123/photo.jpg"
|
||||||
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[])
|
download_url = "http://minio.local/chat/images/abc123/photo.jpg?X-Amz-Signature=..."
|
||||||
|
svc, mm_client, storage = _make_service(download_url=download_url, publishers=[])
|
||||||
|
|
||||||
fake_file = Mock()
|
result = svc.send(conv.uuid, sender_uuid, "image", object_key=object_key)
|
||||||
fake_file.name = "photo.jpg"
|
|
||||||
|
|
||||||
result = svc.send(conv.uuid, sender_uuid, "image", file=fake_file)
|
storage.get_download_url.assert_called_once_with(object_key)
|
||||||
|
mm_client.post_message.assert_called_once_with("ch-send-image", f"image:{object_key}")
|
||||||
storage.upload_file.assert_called_once_with(fake_file, "photo.jpg")
|
assert result["url"] == download_url
|
||||||
mm_client.post_message.assert_called_once_with("ch-send-image", file_url)
|
assert result["message_type"] == "image"
|
||||||
assert result["url"] == file_url
|
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
|
|
@ -77,6 +77,7 @@ def test_list_messages_normalized():
|
||||||
user_uuid=sender_uuid, mattermost_user_id=mm_user_id
|
user_uuid=sender_uuid, mattermost_user_id=mm_user_id
|
||||||
)
|
)
|
||||||
|
|
||||||
|
object_key = "voice/def456/clip.ogg"
|
||||||
raw_posts = [
|
raw_posts = [
|
||||||
{
|
{
|
||||||
"id": "p1",
|
"id": "p1",
|
||||||
|
|
@ -87,14 +88,15 @@ def test_list_messages_normalized():
|
||||||
{
|
{
|
||||||
"id": "p2",
|
"id": "p2",
|
||||||
"user_id": mm_user_id,
|
"user_id": mm_user_id,
|
||||||
"message": "http://minio.local/chat/file.pdf",
|
"message": f"voice:{object_key}",
|
||||||
"create_at": 1700000001000,
|
"create_at": 1700000001000,
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
|
download_url = "http://minio.local/chat/voice/def456/clip.ogg?X-Amz-Signature=..."
|
||||||
mm_client = Mock()
|
mm_client = Mock()
|
||||||
mm_client.get_posts.return_value = raw_posts
|
mm_client.get_posts.return_value = raw_posts
|
||||||
svc, _, _ = _make_service(publishers=[])
|
svc, _, storage = _make_service(download_url=download_url, publishers=[])
|
||||||
svc._mm = mm_client # inject after construction to keep _make_service simple
|
svc._mm = mm_client # inject after construction to keep _make_service simple
|
||||||
|
|
||||||
messages = svc.list_messages(conv.uuid, page=0, per_page=20)
|
messages = svc.list_messages(conv.uuid, page=0, per_page=20)
|
||||||
|
|
@ -108,9 +110,39 @@ def test_list_messages_normalized():
|
||||||
assert text_msg["text"] == "First message"
|
assert text_msg["text"] == "First message"
|
||||||
assert text_msg["url"] is None
|
assert text_msg["url"] is None
|
||||||
|
|
||||||
file_msg = messages[1]
|
voice_msg = messages[1]
|
||||||
assert file_msg["post_id"] == "p2"
|
assert voice_msg["post_id"] == "p2"
|
||||||
assert file_msg["sender_uuid"] == sender_uuid
|
assert voice_msg["sender_uuid"] == sender_uuid
|
||||||
assert file_msg["message_type"] == "file"
|
assert voice_msg["message_type"] == "voice"
|
||||||
assert file_msg["url"] == "http://minio.local/chat/file.pdf"
|
assert voice_msg["url"] == download_url
|
||||||
assert file_msg["text"] is None
|
assert voice_msg["text"] is None
|
||||||
|
|
||||||
|
storage.get_download_url.assert_called_once_with(object_key)
|
||||||
|
|
||||||
|
|
||||||
|
@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()
|
||||||
|
|
|
||||||
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"),
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,7 @@ class MessageView(APIView):
|
||||||
sender_uuid=data["sender_uuid"],
|
sender_uuid=data["sender_uuid"],
|
||||||
message_type=data["message_type"],
|
message_type=data["message_type"],
|
||||||
text=data.get("text"),
|
text=data.get("text"),
|
||||||
file=data.get("file"),
|
object_key=data.get("object_key"),
|
||||||
)
|
)
|
||||||
except ConversationClosedError as exc:
|
except ConversationClosedError as exc:
|
||||||
return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN)
|
return Response({"detail": str(exc)}, status=status.HTTP_403_FORBIDDEN)
|
||||||
|
|
|
||||||
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)
|
||||||
|
|
@ -24,3 +24,5 @@ MINIO_ENDPOINT=localhost:9000
|
||||||
MINIO_ACCESS_KEY=minioadmin
|
MINIO_ACCESS_KEY=minioadmin
|
||||||
MINIO_SECRET_KEY=minioadmin
|
MINIO_SECRET_KEY=minioadmin
|
||||||
MINIO_BUCKET_CHAT=chat
|
MINIO_BUCKET_CHAT=chat
|
||||||
|
MINIO_SECURE=false
|
||||||
|
MINIO_PRESIGN_EXPIRY_SECONDS=3600
|
||||||
|
|
|
||||||
|
|
@ -288,5 +288,7 @@ MINIO_ENDPOINT = config('MINIO_ENDPOINT', default=None)
|
||||||
MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None)
|
MINIO_ACCESS_KEY = config('MINIO_ACCESS_KEY', default=None)
|
||||||
MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None)
|
MINIO_SECRET_KEY = config('MINIO_SECRET_KEY', default=None)
|
||||||
MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat')
|
MINIO_BUCKET_CHAT = config('MINIO_BUCKET_CHAT', default='chat')
|
||||||
|
MINIO_SECURE = config('MINIO_SECURE', default=False, cast=bool)
|
||||||
|
MINIO_PRESIGN_EXPIRY_SECONDS = config('MINIO_PRESIGN_EXPIRY_SECONDS', default=3600, cast=int)
|
||||||
GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None)
|
GDAL_LIBRARY_PATH = config('GDAL_LIBRARY_PATH', default=None)
|
||||||
GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None)
|
GEOS_LIBRARY_PATH = config('GEOS_LIBRARY_PATH', default=None)
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue