Skip to content

Commit f72e736

Browse files
Merge pull request #138 from UNLV-CS472-672/chat-feature
PR 4: Add WebSocket-Based Real-Time Chat Using Django Channels
2 parents f7a6cb2 + 41dc004 commit f72e736

File tree

9 files changed

+266
-108
lines changed

9 files changed

+266
-108
lines changed

backend/.coveragerc

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ source =
1212
apps/search
1313

1414
omit =
15-
*/tests/*
1615
*/migrations/*
1716
*/__init__.py
1817
*/settings.py

backend/apps/chat/consumers.py

Lines changed: 62 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,67 @@
11
# The below code is just for testing purposes (it works!)
22
# Tested it using https://websocketking.com/
33
# and entered ws://localhost:8000/ws/apps/chat/practice/
4-
from channels.generic.websocket import AsyncJsonWebsocketConsumer
4+
import json
5+
from channels.generic.websocket import AsyncWebsocketConsumer
6+
from asgiref.sync import sync_to_async
7+
from .models import Message, Chat
8+
from django.contrib.auth.models import User
59

6-
class PracticeConsumer(AsyncJsonWebsocketConsumer):
7-
async def connect(self):
8-
await self.accept()
10+
# https://medium.com/@farad.dev/how-to-build-a-real-time-chat-app-using-django-channels-2ba2621ea972
11+
class ChatConsumer(AsyncWebsocketConsumer):
912

10-
async def receive(self, text_data=None, bytes_data=None, **kwargs):
11-
if text_data == 'PING':
12-
# Send back a response
13-
await self.send(text_data='{"message": "PONG"}')
13+
async def connect(self):
14+
user_id = self.scope.get('user_id')
15+
if user_id is not None:
16+
self.user_id = user_id
17+
self.room_name = self.scope["url_route"]["kwargs"]["room_name"] # Extracts the room_name from the URL
18+
self.room_group_name = f'chat_{self.room_name}'
19+
20+
await self.channel_layer.group_add( # Adds the WebSocket connection (client) to a channel group
21+
self.room_group_name,
22+
self.channel_name
23+
)
24+
await self.accept("chat") # Returns protocol back to browser (DO NOT DELETE)
25+
26+
27+
async def disconnect(self, close_code):
28+
print(f"Disconnected! Code: {close_code}")
29+
await self.channel_layer.group_discard(
30+
self.room_group_name,
31+
self.channel_name
32+
)
33+
34+
# This function receive messages from WebSocket.
35+
async def receive(self, text_data):
36+
text_data_json = json.loads(text_data)
37+
message = text_data_json['message']
38+
39+
# Create a new message object
40+
username = await self.save_message(self.user_id, self.room_name, message)
41+
42+
await self.channel_layer.group_send( # sends message to all users in a channel group
43+
self.room_group_name,
44+
{
45+
'type': 'chat_message', # Calls chat_message()
46+
'message': message,
47+
'username': username
48+
}
49+
)
50+
51+
async def chat_message(self, event):
52+
await self.send(text_data=json.dumps({
53+
'message': 'successful',
54+
'body': event['message'],
55+
'username': event['username']
56+
}))
57+
58+
@sync_to_async
59+
def save_message(self, id, room_name, content):
60+
try:
61+
user = User.objects.get(id=id)
62+
chat = Chat.objects.get(name = room_name)
63+
Message.objects.create(content=content, chat = chat, sender = user) # Creates and saves message
64+
return user.username
65+
except (Chat.DoesNotExist):
66+
print(f"Chat room '{room_name}' not found. Message not saved.")
67+
return "Unknown"
Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
# https://medium.com/@josephmiracle119/authentication-in-websocket-with-django-and-django-rest-framework-drf-50406ef95f3c
2+
from channels.middleware import BaseMiddleware
3+
from rest_framework_simplejwt.tokens import AccessToken
4+
from channels.db import database_sync_to_async
5+
6+
class JWTAuthMiddleware(BaseMiddleware):
7+
async def __call__(self, scope, receive, send):
8+
# Get the token from the subprotocols
9+
token = self.get_token_from_subprotocol(scope)
10+
11+
if token:
12+
user_id = await self.get_user_from_token(token)
13+
if user_id:
14+
# Attach the user_id to the scope if the token is valid
15+
scope['user_id'] = user_id
16+
else:
17+
scope['error'] = 'Invalid token'
18+
else:
19+
scope['error'] = 'Provide an auth token'
20+
21+
return await super().__call__(scope, receive, send)
22+
23+
def get_token_from_subprotocol(self, scope):
24+
# Extract token from subprotocols sent during the WebSocket handshake.
25+
# WebSocket subprotocols are sent as a list of protocols during the handshake.
26+
27+
subprotocols = scope.get("subprotocols", [])
28+
if subprotocols:
29+
# Assuming the token is the second protocol (after the real protocol)
30+
return subprotocols[1] # The access token is the second subprotocol
31+
return None
32+
33+
@database_sync_to_async
34+
def get_user_from_token(self, token):
35+
# Validate and get the user from the provided JWT token
36+
try:
37+
access_token = AccessToken(token)
38+
return access_token['user_id']
39+
except:
40+
return None
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
# Generated by Django 5.1.6 on 2025-04-02 02:58
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('chat', '0005_alter_message_chat'),
10+
]
11+
12+
operations = [
13+
migrations.AddField(
14+
model_name='chat',
15+
name='name',
16+
field=models.CharField(default='Unknown', max_length=100),
17+
),
18+
]

backend/apps/chat/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ class Chat(models.Model):
88
user2 = models.ForeignKey(User, on_delete=models.CASCADE, related_name="chats_received")
99
createdAt = models.DateTimeField(auto_now_add=True)
1010
updatedAt = models.DateTimeField(auto_now=True)
11+
name = models.CharField(max_length=100, default="Unknown")
1112

1213
objects = ChatManager()
1314

backend/apps/chat/routing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
from . import consumers
33

44
websocket_urlpatterns = [
5-
re_path(r'ws/apps/chat/practice/$', consumers.PracticeConsumer.as_asgi()),
5+
re_path(r"ws/apps/chat/(?P<room_name>[\w-]+)/$", consumers.ChatConsumer.as_asgi()),
66
]

backend/backend/asgi.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import os
1010
from channels.routing import ProtocolTypeRouter, URLRouter
1111
from django.core.asgi import get_asgi_application
12-
from channels.auth import AuthMiddlewareStack
12+
from apps.chat.jwt_middleware import JWTAuthMiddleware
1313
from apps.chat.routing import websocket_urlpatterns as chat_websocket_urlpatterns
1414
from apps.notifications.routing import websocket_urlpatterns as notification_websocket_urlpatterns
1515

@@ -19,7 +19,7 @@
1919

2020
application = ProtocolTypeRouter({
2121
"http": get_asgi_application(),
22-
"websocket": AuthMiddlewareStack(
22+
"websocket": JWTAuthMiddleware(
2323
URLRouter(
2424
combined_websocket_urlpatterns # Use the imported websocket_urlpatterns here
2525
)

backend/backend/settings.py

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -197,18 +197,19 @@
197197
ASGI_APPLICATION = "backend.asgi.application"
198198

199199
CHANNEL_LAYERS = {
200-
# "default": {
201-
# "BACKEND": "channels_redis.core.RedisChannelLayer",
202-
# "CONFIG": {
203-
# "hosts": [('127.0.0.1', 6379)],
204-
# },
205-
# },
206-
207-
"default": {
208-
"BACKEND": "channels.layers.InMemoryChannelLayer",
209-
},
200+
"default": {
201+
"BACKEND": "channels_redis.core.RedisChannelLayer",
202+
"CONFIG": {
203+
"hosts": [('127.0.0.1', 6379)],
204+
},
205+
},
206+
207+
#"default": {
208+
# "BACKEND": "channels.layers.InMemoryChannelLayer",
209+
#},
210210
}
211211

212+
212213
# cloudinary
213214
cloudinary.config(
214215
CLOUD_NAME = str(os.getenv('CLOUDINARY_CLOUD_NAME')),

0 commit comments

Comments
 (0)