-
Notifications
You must be signed in to change notification settings - Fork 0
Implemented mongodb schema for messaging #48 #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -37,7 +37,15 @@ services: | |
| test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"] | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please check if Docker is working properly. |
||
| interval: 10s | ||
| retries: 5 | ||
| mongo: | ||
| image: mongo:6.0 | ||
| restart: unless-stopped | ||
| ports: | ||
| - "27017:27017" | ||
| volumes: | ||
| - mongo_data:/data/db | ||
|
|
||
| volumes: | ||
| postgres_data: | ||
| pgadmin_data: | ||
| pgadmin_data: | ||
| mongo_data: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. No channel_layer check |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| import json | ||
| import logging, traceback | ||
| from django.utils import timezone | ||
| from channels.generic.websocket import AsyncWebsocketConsumer | ||
| from .models_mongo import Room, Message | ||
|
|
||
| logger = logging.getLogger("communications") | ||
|
|
||
| class TestConsumer(AsyncWebsocketConsumer): | ||
| async def connect(self): | ||
| user = self.scope["user"] | ||
| if user.is_authenticated: | ||
| await self.accept() | ||
| await self.send(text_data="WebSocket connected.") | ||
| else: | ||
| await self.close() | ||
|
|
||
| async def receive(self, text_data): | ||
| await self.send(text_data=f"You sent: {text_data}") | ||
|
|
||
| async def disconnect(self, code): | ||
| print(f"WebSocket disconnected with code: {code}") | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. print in production code should be replaced with logging: |
||
| class ChatConsumer(AsyncWebsocketConsumer): | ||
| async def connect(self): | ||
| user = self.scope["user"] | ||
| if not user.is_authenticated: | ||
| return await self.close() | ||
|
|
||
| self.room_id = self.scope["url_route"]["kwargs"]["room_id"] | ||
| self.room_name = f"room_{self.room_id}" | ||
|
|
||
| await self.channel_layer.group_add(self.room_name, self.channel_name) | ||
| await self.accept() | ||
|
|
||
| async def disconnect(self, code): | ||
| await self.channel_layer.group_discard(self.room_name, self.channel_name) | ||
| print(f"WebSocket disconnected with code: {code}") | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. print in production code should be replaced with logging: |
||
|
|
||
| async def receive(self, text_data, bytes_data=None): | ||
| try: | ||
| user = self.scope["user"] | ||
| payload = json.loads(text_data or "{}") | ||
| text = payload.get("text", "").strip() | ||
| if not text: | ||
| return | ||
|
|
||
| user_pk = user.pk | ||
|
|
||
| room = Room.objects(room_id=self.room_id).first() | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Repetitive code in Room logic can be moved to a helper function for cleanliness: |
||
| if room is None: | ||
| room = Room(room_id=self.room_id, participants=[user_pk]) | ||
| else: | ||
| if user_pk not in room.participants: | ||
| room.participants.append(user_pk) | ||
| room.updated_at = timezone.now() | ||
|
|
||
| room.save() | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing await in room.save() and Message(...).save() Solution: move from async def to a separate sync_to_async block: from asgiref.sync import sync_to_async @sync_to_async
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. And in receive(): |
||
|
|
||
| Message( | ||
| room=room, | ||
| sender_id=user_pk, | ||
| text=text, | ||
| timestamp=timezone.now() | ||
| ).save() | ||
|
|
||
| await self.channel_layer.group_send( | ||
| self.room_name, | ||
| { | ||
| "type": "chat.message", | ||
| "user": user.user_name, | ||
| "text": text | ||
| } | ||
| ) | ||
| except Exception as e: | ||
| logging.error(f"Error in ChatConsumer.receive: {e} \n{traceback.format_exc()}") | ||
| await self.close() | ||
|
|
||
| async def chat_message(self, event): | ||
| await self.send(text_data=json.dumps({ | ||
| "user": event["user"], | ||
| "text": event["text"] | ||
| })) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| """ | ||
| Use UntypedToken to validate the signature and expiry | ||
| ============================= | ||
| jwt.decode() with SECRET_KEY and ALGORITHM from our SIMPLE_JWT settings to extract user | ||
| ============================= | ||
| get_user() fetches the real Django user | ||
| """ | ||
|
|
||
|
|
||
| import logging, traceback | ||
| from urllib.parse import parse_qs | ||
| import jwt | ||
| from django.conf import settings | ||
| from django.contrib.auth import get_user_model | ||
| from django.contrib.auth.models import AnonymousUser | ||
| from channels.db import database_sync_to_async | ||
| from rest_framework_simplejwt.tokens import UntypedToken | ||
| from rest_framework_simplejwt.exceptions import InvalidToken, TokenError | ||
|
|
||
| logger = logging.getLogger("communications") | ||
|
|
||
| User = get_user_model() | ||
|
|
||
| @database_sync_to_async | ||
| def get_user(user_id): | ||
| pk_name = User._meta.pk.name | ||
| try: | ||
| return User.objects.get(**{pk_name: user_id}) | ||
| except User.DoesNotExist: | ||
| return AnonymousUser() | ||
|
|
||
| class JwtAuthMiddleware: | ||
| """ | ||
| Extracts a JWT access token from ?token=<token> and | ||
| populates scope['user'] if valid, or AnonymousUser | ||
| """ | ||
|
|
||
| def __init__(self, app): | ||
| self.app = app | ||
|
|
||
| async def __call__(self, scope, receive, send): | ||
| try: | ||
| qs = parse_qs(scope.get("query_string", b"").decode()) | ||
| token = qs.get("token", [None])[0] | ||
|
|
||
| user = AnonymousUser() | ||
|
|
||
| if token: | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Missing token check for string type |
||
| UntypedToken(token) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Token verification happens twice and then again: Solution: Get the payload from UntypedToken: |
||
| payload = jwt.decode( | ||
| token, | ||
| settings.SECRET_KEY, | ||
| algorithms=[settings.SIMPLE_JWT["ALGORITHM"]], | ||
| options={"verify_exp": True}, | ||
| ) | ||
| user = await get_user(payload[settings.SIMPLE_JWT["USER_ID_CLAIM"]]) | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Non-existent user not logging in |
||
| scope["user"] = user | ||
| return await self.app(scope, receive, send) | ||
|
|
||
| except (InvalidToken, TokenError, jwt.PyJWTError) as e: | ||
| logger.warning(f"JWT authorization failed: {e}") | ||
| scope["user"] = AnonymousUser() | ||
| return await self.app(scope, receive, send) | ||
|
|
||
| except Exception as e: | ||
| logger.error(f"Error in JwtAuthMiddleWare: {e}\n {traceback.format_exc()}") | ||
| raise | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| from django.utils import timezone | ||
| import mongoengine as me | ||
| from django.contrib.auth import get_user_model | ||
|
|
||
| User = get_user_model() | ||
|
|
||
| class Room(me.Document): | ||
| meta = { | ||
| "db_alias": "chat", | ||
| "collection": "rooms", | ||
| "indexes": ["participants", "updated_at"] | ||
| } | ||
| room_id = me.StringField(primary_key=True, required=True) | ||
| participants = me.ListField(me.IntField(), default=list, required=True) | ||
| created_at = me.DateTimeField(required=True, default=timezone.now) | ||
| updated_at = me.DateTimeField(required=True, default=timezone.now) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Adding auto_now for updated_at makes your logic store updated_at manually, but it's easy to forget. It's worth creating a "hook" to make this happen automatically. |
||
|
|
||
| class Message(me.Document): | ||
| meta = { | ||
| "db_alias": "chat", | ||
| "collection": "messages", | ||
| "indexes": [ | ||
| {"fields": ["room", "timestamp"]}, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is good, but you can also add something "-" to quickly sort backwards in time: |
||
| ] | ||
| } | ||
| room = me.ReferenceField(Room, required=True, reverse_delete_rule=me.CASCADE) | ||
| sender_id = me.IntField(required=True) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use ReferenceField for sender_id if users are stored in Mongo — it's worth: |
||
| text = me.StringField(required=True) | ||
| timestamp = me.DateTimeField(required=True, default=timezone.now) | ||
|
|
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If there is default=timezone.now, then required=True is not required. |
||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. AuthMiddlewareStack already tries to authenticate via session or cookie, but if you are working exclusively with JWT via query param, then AuthMiddlewareStack may be unnecessary. If you are definitely not using cookie-based login: "websocket": JwtAuthMiddleware( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Add healthcheck for MongoDB (optional)
healthcheck:
test: echo 'db.runCommand("ping").ok' | mongosh localhost:27017/test --quiet
interval: 10s
retries: 5