diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..18b410d --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore index c25a548..3c2dd46 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,5 @@ media /delme.py /log/accounts.log /log/errors.log +__pycache__ +.claude \ No newline at end of file diff --git a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc index 6750f1c..e9bbfe9 100644 Binary files a/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc and b/apps/chat/api/views/__pycache__/conversations.cpython-313.pyc differ diff --git a/apps/chat/api/views/__pycache__/messages.cpython-313.pyc b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc index 1a33037..4e8e04d 100644 Binary files a/apps/chat/api/views/__pycache__/messages.cpython-313.pyc and b/apps/chat/api/views/__pycache__/messages.cpython-313.pyc differ diff --git a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc index 9abd17f..a2e5ead 100644 Binary files a/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc and b/apps/chat/api/views/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/api/views/conversations.py b/apps/chat/api/views/conversations.py index f147e29..0a5cd8c 100644 --- a/apps/chat/api/views/conversations.py +++ b/apps/chat/api/views/conversations.py @@ -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)) diff --git a/apps/chat/api/views/messages.py b/apps/chat/api/views/messages.py index bd18b25..7c0eb06 100644 --- a/apps/chat/api/views/messages.py +++ b/apps/chat/api/views/messages.py @@ -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)) diff --git a/apps/chat/api/views/read_state.py b/apps/chat/api/views/read_state.py index a1b1a3e..f63ef01 100644 --- a/apps/chat/api/views/read_state.py +++ b/apps/chat/api/views/read_state.py @@ -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) diff --git a/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc b/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc index 4b61e38..c3704c9 100644 Binary files a/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc and b/apps/chat/serializers/__pycache__/conversations.cpython-313.pyc differ diff --git a/apps/chat/serializers/__pycache__/messages.cpython-313.pyc b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc index 5e9a526..5a92a57 100644 Binary files a/apps/chat/serializers/__pycache__/messages.cpython-313.pyc and b/apps/chat/serializers/__pycache__/messages.cpython-313.pyc differ diff --git a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc index 643a26c..411c7f1 100644 Binary files a/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc and b/apps/chat/serializers/__pycache__/read_state.cpython-313.pyc differ diff --git a/apps/chat/serializers/conversations.py b/apps/chat/serializers/conversations.py index 8b3f322..4eaf918 100644 --- a/apps/chat/serializers/conversations.py +++ b/apps/chat/serializers/conversations.py @@ -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) diff --git a/apps/chat/serializers/messages.py b/apps/chat/serializers/messages.py index 833568b..fba8f8c 100644 --- a/apps/chat/serializers/messages.py +++ b/apps/chat/serializers/messages.py @@ -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) diff --git a/apps/chat/serializers/read_state.py b/apps/chat/serializers/read_state.py index 23a5c17..9c3ecc6 100644 --- a/apps/chat/serializers/read_state.py +++ b/apps/chat/serializers/read_state.py @@ -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() diff --git a/apps/chat/tests/test_conversations.py b/apps/chat/tests/test_conversations.py index e69de29..ec81a64 100644 --- a/apps/chat/tests/test_conversations.py +++ b/apps/chat/tests/test_conversations.py @@ -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 diff --git a/apps/chat/tests/test_messages.py b/apps/chat/tests/test_messages.py index e69de29..afb7fca 100644 --- a/apps/chat/tests/test_messages.py +++ b/apps/chat/tests/test_messages.py @@ -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 diff --git a/apps/chat/tests/test_read_state.py b/apps/chat/tests/test_read_state.py index e69de29..5eeb480 100644 --- a/apps/chat/tests/test_read_state.py +++ b/apps/chat/tests/test_read_state.py @@ -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 diff --git a/apps/chat/websocket/consumers.py b/apps/chat/websocket/consumers.py index e69de29..d8866f8 100644 --- a/apps/chat/websocket/consumers.py +++ b/apps/chat/websocket/consumers.py @@ -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)) diff --git a/apps/chat/websocket/routing.py b/apps/chat/websocket/routing.py new file mode 100644 index 0000000..1f87234 --- /dev/null +++ b/apps/chat/websocket/routing.py @@ -0,0 +1,7 @@ +from django.urls import path + +from apps.chat.websocket.consumers import ChatConsumer + +websocket_urlpatterns = [ + path("ws/chat//", ChatConsumer.as_asgi()), +] diff --git a/conftest.py b/conftest.py new file mode 100644 index 0000000..a1dd7ea --- /dev/null +++ b/conftest.py @@ -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) diff --git a/main/__pycache__/settings.cpython-313.pyc b/main/__pycache__/settings.cpython-313.pyc index 1e0345a..0f64b4f 100644 Binary files a/main/__pycache__/settings.cpython-313.pyc and b/main/__pycache__/settings.cpython-313.pyc differ diff --git a/main/asgi.py b/main/asgi.py index 31b565c..d04d7e7 100644 --- a/main/asgi.py +++ b/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) + ), + } +) diff --git a/main/settings.py b/main/settings.py index 8e9d500..47446b4 100644 --- a/main/settings.py +++ b/main/settings.py @@ -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() diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..71ddc8e --- /dev/null +++ b/pytest.ini @@ -0,0 +1,2 @@ +[pytest] +DJANGO_SETTINGS_MODULE = main.settings