complete mattermost integration
This commit is contained in:
parent
b7239defcf
commit
dcb1873608
24 changed files with 509 additions and 40 deletions
41
.env.example
Normal file
41
.env.example
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Database
|
||||
DB_NAME=gooyal_chat
|
||||
DB_USER=postgres
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PASSWORD=
|
||||
DB_PORT=5432
|
||||
|
||||
DEBUG=true
|
||||
|
||||
# OAuth2 / SSO provider
|
||||
OAUTH2_PROVIDER_BASE_PUBLIC_URL=https://accounts.example.com/oauth2
|
||||
OAUTH2_PROVIDER_BASE_PRIVATE_URL=https://accounts.example.com/oauth2
|
||||
OAUTH2_PROVIDER_CLIENT_ID=
|
||||
OAUTH2_PROVIDER_CLIENT_SECRET=
|
||||
OAUTH2_PROVIDER_SCOPES=
|
||||
|
||||
# Redis (used for cache, channel layer, and long-poll)
|
||||
REDIS_BASE_URL=redis://127.0.0.1:6379/1
|
||||
|
||||
# Mattermost
|
||||
# MATTERMOST_TOKEN must be a Personal Access Token (not a session token — session tokens expire)
|
||||
# Enable at: System Console → Integrations → Integration Management → Enable Personal Access Tokens
|
||||
MATTERMOST_URL=https://chat.example.com
|
||||
MATTERMOST_TOKEN=
|
||||
MATTERMOST_TEAM_ID=
|
||||
MATTERMOST_SERVICE_USERNAME=chat-service
|
||||
MATTERMOST_SERVICE_EMAIL=chat-service@local.invalid
|
||||
|
||||
# MinIO object storage
|
||||
MINIO_ENDPOINT=localhost:9000
|
||||
MINIO_ACCESS_KEY=minioadmin
|
||||
MINIO_SECRET_KEY=minioadmin
|
||||
MINIO_BUCKET_CHAT=chat
|
||||
|
||||
# Chat long-poll tuning
|
||||
CHAT_LONG_POLL_TIMEOUT_SECONDS=25
|
||||
CHAT_LONG_POLL_INTERVAL_SECONDS=1
|
||||
|
||||
# Native library paths (macOS Homebrew — leave empty on Linux)
|
||||
GDAL_LIBRARY_PATH=
|
||||
GEOS_LIBRARY_PATH=
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -5,3 +5,5 @@ media
|
|||
/delme.py
|
||||
/log/accounts.log
|
||||
/log/errors.log
|
||||
__pycache__
|
||||
.claude
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,5 +1,6 @@
|
|||
from uuid import UUID
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
|
@ -8,6 +9,7 @@ from apps.chat.models import ConversationParticipant
|
|||
from apps.chat.serializers.conversations import (
|
||||
ConversationListSerializer,
|
||||
ConversationSerializer,
|
||||
CreateConversationSerializer,
|
||||
)
|
||||
from apps.chat.services.conversation import ConversationService
|
||||
from apps.chat.services.message import MessageService
|
||||
|
|
@ -15,11 +17,21 @@ from apps.chat.services.read_state import ReadStateService
|
|||
|
||||
|
||||
class ConversationCreateView(APIView):
|
||||
def post(self, request):
|
||||
user_1_id = UUID(request.data["user_1_id"])
|
||||
user_2_id = UUID(request.data["user_2_id"])
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
conversation = ConversationService().create(user_1_id, user_2_id)
|
||||
@extend_schema(
|
||||
request=CreateConversationSerializer,
|
||||
responses={201: ConversationSerializer},
|
||||
)
|
||||
def post(self, request):
|
||||
serializer = CreateConversationSerializer(data=request.data)
|
||||
serializer.is_valid(raise_exception=True)
|
||||
|
||||
conversation = ConversationService().create(
|
||||
serializer.validated_data["user_1_id"],
|
||||
serializer.validated_data["user_2_id"],
|
||||
)
|
||||
|
||||
return Response(
|
||||
ConversationSerializer(conversation).data,
|
||||
|
|
@ -28,6 +40,16 @@ class ConversationCreateView(APIView):
|
|||
|
||||
|
||||
class UserConversationListView(APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("page", int, description="0-based page number", default=0),
|
||||
OpenApiParameter("page_size", int, description="Results per page", default=20),
|
||||
],
|
||||
responses={200: ConversationListSerializer(many=True)},
|
||||
)
|
||||
def get(self, request, user_id):
|
||||
uid = UUID(str(user_id))
|
||||
page = int(request.query_params.get("page", 0))
|
||||
|
|
|
|||
|
|
@ -1,30 +1,50 @@
|
|||
from uuid import UUID
|
||||
|
||||
from drf_spectacular.utils import OpenApiParameter, extend_schema
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.chat.serializers.messages import MessageSerializer
|
||||
from apps.chat.serializers.messages import MessageSerializer, SendMessageSerializer
|
||||
from apps.chat.services.message import MessageService
|
||||
|
||||
|
||||
class MessageView(APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
@extend_schema(
|
||||
request=SendMessageSerializer,
|
||||
responses={201: MessageSerializer},
|
||||
)
|
||||
def post(self, request, chat_id):
|
||||
sender_id = UUID(request.data["sender_id"])
|
||||
message_type = request.data.get("message_type", "text")
|
||||
text = request.data.get("text")
|
||||
file = request.FILES.get("file")
|
||||
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=sender_id,
|
||||
message_type=message_type,
|
||||
text=text,
|
||||
file=file,
|
||||
sender_id=data["sender_id"],
|
||||
message_type=data["message_type"],
|
||||
text=data.get("text"),
|
||||
file=data.get("file"),
|
||||
)
|
||||
|
||||
return Response(MessageSerializer(result).data, status=status.HTTP_201_CREATED)
|
||||
|
||||
@extend_schema(
|
||||
parameters=[
|
||||
OpenApiParameter("page", int, description="0-based page number", default=0),
|
||||
OpenApiParameter("per_page", int, description="Results per page", default=20),
|
||||
OpenApiParameter(
|
||||
"since",
|
||||
int,
|
||||
required=False,
|
||||
description="Return only posts after this Unix timestamp (milliseconds)",
|
||||
),
|
||||
],
|
||||
responses={200: MessageSerializer(many=True)},
|
||||
)
|
||||
def get(self, request, chat_id):
|
||||
page = int(request.query_params.get("page", 0))
|
||||
per_page = int(request.query_params.get("per_page", 20))
|
||||
|
|
|
|||
|
|
@ -3,33 +3,57 @@ from uuid import UUID
|
|||
|
||||
from django.conf import settings
|
||||
from django_redis import get_redis_connection
|
||||
from drf_spectacular.utils import extend_schema
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
|
||||
from apps.chat.serializers.read_state import ReadStateSerializer
|
||||
from apps.chat.serializers.messages import MessageSerializer
|
||||
from apps.chat.serializers.read_state import MarkReadSerializer, ReadStateSerializer
|
||||
from apps.chat.services.read_state import ReadStateService
|
||||
|
||||
|
||||
class ReadStateView(APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
@extend_schema(
|
||||
request=MarkReadSerializer,
|
||||
responses={200: ReadStateSerializer},
|
||||
)
|
||||
def post(self, request, chat_id):
|
||||
user_id = UUID(request.data["user_id"])
|
||||
post_id = request.data["post_id"]
|
||||
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=user_id,
|
||||
post_id=post_id,
|
||||
user_id=data["user_id"],
|
||||
post_id=data["post_id"],
|
||||
)
|
||||
|
||||
data = {
|
||||
"conversation_id": read_state.conversation_id,
|
||||
"user_id": read_state.user_id,
|
||||
"has_unread": False,
|
||||
}
|
||||
return Response(ReadStateSerializer(data).data)
|
||||
return Response(
|
||||
ReadStateSerializer(
|
||||
{
|
||||
"conversation_id": read_state.conversation_id,
|
||||
"user_id": read_state.user_id,
|
||||
"has_unread": False,
|
||||
}
|
||||
).data
|
||||
)
|
||||
|
||||
|
||||
class ChatEventsView(APIView):
|
||||
authentication_classes = []
|
||||
permission_classes = []
|
||||
|
||||
@extend_schema(
|
||||
responses={200: MessageSerializer(many=True)},
|
||||
description=(
|
||||
"Long-poll endpoint. Blocks up to CHAT_LONG_POLL_TIMEOUT_SECONDS "
|
||||
"waiting for a new message event. Returns immediately when an event "
|
||||
"arrives; returns an empty list on timeout."
|
||||
),
|
||||
)
|
||||
def get(self, request, chat_id):
|
||||
timeout = getattr(settings, "CHAT_LONG_POLL_TIMEOUT_SECONDS", 25)
|
||||
|
||||
|
|
|
|||
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -1,8 +1,14 @@
|
|||
from drf_spectacular.utils import extend_schema_field
|
||||
from rest_framework import serializers
|
||||
|
||||
from apps.chat.models import Conversation, ConversationParticipant
|
||||
|
||||
|
||||
class CreateConversationSerializer(serializers.Serializer):
|
||||
user_1_id = serializers.UUIDField()
|
||||
user_2_id = serializers.UUIDField()
|
||||
|
||||
|
||||
class ConversationSerializer(serializers.ModelSerializer):
|
||||
participants = serializers.SerializerMethodField()
|
||||
|
||||
|
|
@ -10,6 +16,7 @@ class ConversationSerializer(serializers.ModelSerializer):
|
|||
model = Conversation
|
||||
fields = ["id", "type", "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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,13 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class SendMessageSerializer(serializers.Serializer):
|
||||
sender_id = serializers.UUIDField()
|
||||
message_type = serializers.ChoiceField(choices=["text", "image", "file"], default="text")
|
||||
text = serializers.CharField(required=False, allow_null=True, allow_blank=True)
|
||||
file = serializers.FileField(required=False, allow_null=True)
|
||||
|
||||
|
||||
class MessageSerializer(serializers.Serializer):
|
||||
post_id = serializers.CharField()
|
||||
sender_id = serializers.UUIDField(allow_null=True)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,11 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
|
||||
class MarkReadSerializer(serializers.Serializer):
|
||||
user_id = serializers.UUIDField()
|
||||
post_id = serializers.CharField()
|
||||
|
||||
|
||||
class ReadStateSerializer(serializers.Serializer):
|
||||
conversation_id = serializers.UUIDField()
|
||||
user_id = serializers.UUIDField()
|
||||
|
|
|
|||
|
|
@ -0,0 +1,73 @@
|
|||
import uuid
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.chat.models import (
|
||||
Conversation,
|
||||
ConversationParticipant,
|
||||
MattermostAccountMapping,
|
||||
)
|
||||
from apps.chat.services.conversation import ConversationService
|
||||
|
||||
|
||||
def _make_mm_client(mm_user_ids=("mm-u1", "mm-u2"), channel_id="mm-ch-1"):
|
||||
client = Mock()
|
||||
client.get_or_create_user.side_effect = list(mm_user_ids)
|
||||
client.create_private_channel.return_value = channel_id
|
||||
return client
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_conversation_success():
|
||||
user_1 = uuid.uuid4()
|
||||
user_2 = uuid.uuid4()
|
||||
|
||||
svc = ConversationService(mattermost_client=_make_mm_client())
|
||||
conv = svc.create(user_1, user_2)
|
||||
|
||||
assert Conversation.objects.filter(id=conv.id).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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_create_conversation_creates_mattermost_mapping():
|
||||
user_1 = uuid.uuid4()
|
||||
user_2 = uuid.uuid4()
|
||||
|
||||
mm_client = _make_mm_client(mm_user_ids=("mm-user-aaa", "mm-user-bbb"))
|
||||
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)
|
||||
|
||||
assert mapping_1.mattermost_user_id == "mm-user-aaa"
|
||||
assert mapping_2.mattermost_user_id == "mm-user-bbb"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_for_user_returns_only_user_conversations():
|
||||
target_user = uuid.uuid4()
|
||||
other_user = uuid.uuid4()
|
||||
|
||||
conv_with_user = Conversation.objects.create(mattermost_channel_id="ch-target")
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv_with_user, user_id=target_user
|
||||
)
|
||||
|
||||
conv_without_user = Conversation.objects.create(mattermost_channel_id="ch-other")
|
||||
ConversationParticipant.objects.create(
|
||||
conversation=conv_without_user, user_id=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
|
||||
|
|
@ -0,0 +1,116 @@
|
|||
import uuid
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
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):
|
||||
"""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
|
||||
|
||||
return (
|
||||
MessageService(
|
||||
mattermost_client=mm_client,
|
||||
storage_service=storage,
|
||||
publishers=publishers if publishers is not None else [],
|
||||
),
|
||||
mm_client,
|
||||
storage,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_send_text_message():
|
||||
conv = Conversation.objects.create(mattermost_channel_id="ch-send-text")
|
||||
sender_id = uuid.uuid4()
|
||||
|
||||
ws_publisher = Mock()
|
||||
svc, mm_client, _ = _make_service(mm_post_id="post-abc", publishers=[ws_publisher])
|
||||
|
||||
result = svc.send(conv.id, sender_id, "text", text="Hello world")
|
||||
|
||||
mm_client.post_message.assert_called_once_with("ch-send-text", "Hello world")
|
||||
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.message_type == "text"
|
||||
|
||||
assert result["post_id"] == "post-abc"
|
||||
assert result["text"] == "Hello world"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_send_image_message():
|
||||
conv = Conversation.objects.create(mattermost_channel_id="ch-send-image")
|
||||
sender_id = uuid.uuid4()
|
||||
|
||||
file_url = "http://minio.local/chat/abc123/photo.jpg"
|
||||
svc, mm_client, storage = _make_service(storage_url=file_url, publishers=[])
|
||||
|
||||
fake_file = Mock()
|
||||
fake_file.name = "photo.jpg"
|
||||
|
||||
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
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_list_messages_normalized():
|
||||
conv = Conversation.objects.create(mattermost_channel_id="ch-list")
|
||||
sender_uuid = uuid.uuid4()
|
||||
mm_user_id = "mm-user-xyz"
|
||||
|
||||
MattermostAccountMapping.objects.create(
|
||||
user_id=sender_uuid, mattermost_user_id=mm_user_id
|
||||
)
|
||||
|
||||
raw_posts = [
|
||||
{
|
||||
"id": "p1",
|
||||
"user_id": mm_user_id,
|
||||
"message": "First message",
|
||||
"create_at": 1700000000000,
|
||||
},
|
||||
{
|
||||
"id": "p2",
|
||||
"user_id": mm_user_id,
|
||||
"message": "http://minio.local/chat/file.pdf",
|
||||
"create_at": 1700000001000,
|
||||
},
|
||||
]
|
||||
|
||||
mm_client = Mock()
|
||||
mm_client.get_posts.return_value = raw_posts
|
||||
svc, _, _ = _make_service(publishers=[])
|
||||
svc._mm = mm_client # inject after construction to keep _make_service simple
|
||||
|
||||
messages = svc.list_messages(conv.id, page=0, per_page=20)
|
||||
|
||||
assert len(messages) == 2
|
||||
|
||||
text_msg = messages[0]
|
||||
assert text_msg["post_id"] == "p1"
|
||||
assert text_msg["sender_id"] == sender_uuid
|
||||
assert text_msg["message_type"] == "text"
|
||||
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["url"] == "http://minio.local/chat/file.pdf"
|
||||
assert file_msg["text"] is None
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
import uuid
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from apps.chat.models import Conversation, ConversationReadState
|
||||
from apps.chat.services.read_state import ReadStateService
|
||||
|
||||
|
||||
def _make_conv(channel_id="ch-read"):
|
||||
return Conversation.objects.create(mattermost_channel_id=channel_id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_read_creates_read_state():
|
||||
conv = _make_conv()
|
||||
user_id = uuid.uuid4()
|
||||
|
||||
svc = ReadStateService(mattermost_client=Mock())
|
||||
read_state = svc.mark_read(conv.id, user_id, "post-111")
|
||||
|
||||
assert read_state.conversation_id == conv.id
|
||||
assert read_state.user_id == user_id
|
||||
assert read_state.last_read_mattermost_post_id == "post-111"
|
||||
assert ConversationReadState.objects.filter(
|
||||
conversation=conv, user_id=user_id
|
||||
).count() == 1
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_mark_read_updates_existing():
|
||||
conv = _make_conv()
|
||||
user_id = 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")
|
||||
|
||||
rows = ConversationReadState.objects.filter(conversation=conv, user_id=user_id)
|
||||
assert rows.count() == 1
|
||||
assert rows.first().last_read_mattermost_post_id == "post-new"
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.chat.services.read_state.cache")
|
||||
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()
|
||||
ConversationReadState.objects.create(
|
||||
conversation=conv,
|
||||
user_id=user_id,
|
||||
last_read_mattermost_post_id="post-old",
|
||||
)
|
||||
|
||||
mm_client = Mock()
|
||||
mm_client.get_latest_post_id.return_value = "post-new"
|
||||
|
||||
svc = ReadStateService(mattermost_client=mm_client)
|
||||
result = svc.has_unread(conv.id, user_id)
|
||||
|
||||
assert result is True
|
||||
mm_client.get_latest_post_id.assert_called_once_with(conv.mattermost_channel_id)
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
@patch("apps.chat.services.read_state.cache")
|
||||
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()
|
||||
ConversationReadState.objects.create(
|
||||
conversation=conv,
|
||||
user_id=user_id,
|
||||
last_read_mattermost_post_id="post-current",
|
||||
)
|
||||
|
||||
mm_client = Mock()
|
||||
mm_client.get_latest_post_id.return_value = "post-current"
|
||||
|
||||
svc = ReadStateService(mattermost_client=mm_client)
|
||||
result = svc.has_unread(conv.id, user_id)
|
||||
|
||||
assert result is False
|
||||
|
|
@ -0,0 +1,23 @@
|
|||
import json
|
||||
|
||||
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}"
|
||||
await self.channel_layer.group_add(self.group_name, self.channel_name)
|
||||
await self.accept()
|
||||
|
||||
async def disconnect(self, close_code):
|
||||
await self.channel_layer.group_discard(self.group_name, self.channel_name)
|
||||
|
||||
async def receive(self, text_data=None, bytes_data=None):
|
||||
# Frontend receives only; messages are sent via HTTP API
|
||||
pass
|
||||
|
||||
async def chat_message(self, event):
|
||||
"""Handle chat.message events forwarded from the channel layer."""
|
||||
payload = {k: v for k, v in event.items() if k != "type"}
|
||||
await self.send(text_data=json.dumps(payload))
|
||||
7
apps/chat/websocket/routing.py
Normal file
7
apps/chat/websocket/routing.py
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
from django.urls import path
|
||||
|
||||
from apps.chat.websocket.consumers import ChatConsumer
|
||||
|
||||
websocket_urlpatterns = [
|
||||
path("ws/chat/<uuid:chat_id>/", ChatConsumer.as_asgi()),
|
||||
]
|
||||
22
conftest.py
Normal file
22
conftest.py
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def django_db_setup(django_test_environment, django_db_blocker):
|
||||
"""
|
||||
Override pytest-django's default DB setup to:
|
||||
- Use the local unix-socket superuser instead of the .env credentials
|
||||
- Remove psycopg3 connection pooling (pool=True conflicts with test
|
||||
transaction wrapping and causes PoolTimeout during test-DB creation)
|
||||
"""
|
||||
from django.conf import settings
|
||||
from django.test.utils import setup_databases
|
||||
|
||||
db = settings.DATABASES["default"]
|
||||
db["USER"] = "hashdal"
|
||||
db["PASSWORD"] = ""
|
||||
db["HOST"] = ""
|
||||
db["OPTIONS"] = {} # clear pool=True; psycopg3 pool conflicts with pytest-django
|
||||
|
||||
with django_db_blocker.unblock():
|
||||
setup_databases(verbosity=0, interactive=False)
|
||||
Binary file not shown.
26
main/asgi.py
26
main/asgi.py
|
|
@ -6,22 +6,22 @@ It exposes the ASGI callable as a module-level variable named ``application``.
|
|||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
from apps.chat.websocket import websocket_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'main.settings')
|
||||
|
||||
django_asgi_application = get_asgi_application()
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
django_asgi_app = get_asgi_application()
|
||||
|
||||
async def application(scope, receive, send):
|
||||
if scope["type"] == "http":
|
||||
await django_asgi_application(scope, receive, send)
|
||||
return
|
||||
from channels.auth import AuthMiddlewareStack
|
||||
from channels.routing import ProtocolTypeRouter, URLRouter
|
||||
|
||||
if scope["type"] == "websocket":
|
||||
await websocket_application(scope, receive, send)
|
||||
return
|
||||
from apps.chat.websocket.routing import websocket_urlpatterns
|
||||
|
||||
raise ValueError(f"Unsupported ASGI scope type: {scope['type']}")
|
||||
application = ProtocolTypeRouter(
|
||||
{
|
||||
"http": django_asgi_app,
|
||||
"websocket": AuthMiddlewareStack(
|
||||
URLRouter(websocket_urlpatterns)
|
||||
),
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -223,9 +223,12 @@ SPECTACULAR_SETTINGS = {
|
|||
'DESCRIPTION': 'service api reference',
|
||||
'VERSION': '1.0.0',
|
||||
'SERVE_INCLUDE_SCHEMA': False,
|
||||
# OTHER SETTINGS
|
||||
"PARSER_WHITELIST": ["rest_framework.parsers.JSONParser"],
|
||||
|
||||
'SERVE_PERMISSIONS': ['rest_framework.permissions.AllowAny'],
|
||||
'SECURITY': [],
|
||||
"PARSER_WHITELIST": [
|
||||
"rest_framework.parsers.JSONParser",
|
||||
"rest_framework.parsers.MultiPartParser",
|
||||
],
|
||||
}
|
||||
|
||||
AUTH_USER_MODEL = 'users.User'
|
||||
|
|
@ -254,6 +257,15 @@ CACHES = {
|
|||
}
|
||||
}
|
||||
|
||||
CHANNEL_LAYERS = {
|
||||
"default": {
|
||||
"BACKEND": "channels_redis.core.RedisChannelLayer",
|
||||
"CONFIG": {
|
||||
"hosts": [config('REDIS_BASE_URL')],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
from main.other_settings.logging import get_logging_setting
|
||||
LOGGING = get_logging_setting()
|
||||
|
|
|
|||
2
pytest.ini
Normal file
2
pytest.ini
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
[pytest]
|
||||
DJANGO_SETTINGS_MODULE = main.settings
|
||||
Loading…
Add table
Reference in a new issue