diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 98aa1dd..f255dfb 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -8,7 +8,8 @@ on:
- authentication
- pushkar
- Arnav
- - feature/* # future feature branches
+ - chat
+ - feature/*
jobs:
ci-cd:
@@ -39,7 +40,8 @@ jobs:
github.ref == 'refs/heads/development' ||
github.ref == 'refs/heads/authentication' ||
github.ref == 'refs/heads/pushkar' ||
- github.ref == 'refs/heads/Arnav'
+ github.ref == 'refs/heads/Arnav' ||
+ github.ref == 'refs/heads/chat'
uses: appleboy/ssh-action@v1.0.0
with:
host: ${{ secrets.EC2_HOST }}
@@ -49,7 +51,7 @@ jobs:
# Navigate to Django project folder
cd /home/ubuntu/tripsync-backend/auth
- # Pull latest code from the branch
+ # Pull latest code
git pull origin $GITHUB_REF_NAME
# Activate virtual environment
@@ -58,10 +60,14 @@ jobs:
# Install requirements
pip install -r ../requirements.txt
- # Run migrations and collect static files
+ # Apply migrations and collect static files
python manage.py migrate
python manage.py collectstatic --noinput
- # Restart Gunicorn and Nginx
+ # Restart Redis (if managed manually)
+ sudo systemctl restart redis-server
+
+ # Restart Daphne and Nginx
+ sudo systemctl restart daphne
sudo systemctl restart gunicorn
sudo systemctl restart nginx
diff --git a/auth/.dockerignore b/auth/.dockerignore
deleted file mode 100644
index cc24015..0000000
--- a/auth/.dockerignore
+++ /dev/null
@@ -1,8 +0,0 @@
-*.pyc
-__pycache__
-db.sqlite3
-.env
-.git
-.gitignore
-.venv
-venv/
\ No newline at end of file
diff --git a/auth/Dockerfile b/auth/Dockerfile
deleted file mode 100644
index 175abd8..0000000
--- a/auth/Dockerfile
+++ /dev/null
@@ -1,14 +0,0 @@
-FROM python:3.11-slim
-
-WORKDIR /app
-
-COPY requirements.txt /app/
-
-RUN pip install --upgrade pip
-RUN pip install -r requirements.txt
-
-COPY . /app
-
-EXPOSE 8000
-
-CMD ["sh", "-c", "python manage.py migrate && python manage.py runserver 0.0.0.0:8000"]
\ No newline at end of file
diff --git a/auth/account/migrations/__pycache__/0001_initial.cpython-310.pyc b/auth/account/migrations/__pycache__/0001_initial.cpython-310.pyc
index 16439e4..4fd4351 100644
Binary files a/auth/account/migrations/__pycache__/0001_initial.cpython-310.pyc and b/auth/account/migrations/__pycache__/0001_initial.cpython-310.pyc differ
diff --git a/auth/account/utils.py b/auth/account/utils.py
index eb2d0a1..e48ea68 100644
--- a/auth/account/utils.py
+++ b/auth/account/utils.py
@@ -1,162 +1,65 @@
-from sendgrid import SendGridAPIClient
-from sendgrid.helpers.mail import Mail, Email, To, Content
from django.conf import settings
import logging
logger = logging.getLogger(__name__)
def send_otp_email(email, otp, purpose="verification"):
+ """
+ Send OTP email using SendGrid HTTP API (works on Render Free tier)
+ """
try:
- sendgrid_api_key = getattr(settings, 'SENDGRID_API_KEY', None)
- if not sendgrid_api_key:
- logger.error("SENDGRID_API_KEY not configured in settings")
- return False
-
- from_email = getattr(settings, 'DEFAULT_FROM_EMAIL', 'noreply@tripsync.com')
+ from sendgrid import SendGridAPIClient
+ from sendgrid.helpers.mail import Mail
if purpose == "verification":
subject = "Email Verification OTP - TripSync"
html_content = f"""
-
-
-
-
-
-
-
-
Email Verification
-
Hello,
-
Thank you for registering with TripSync - Your Travel Planning Companion!
-
To verify your email address, please use the following OTP code:
-
-
-
-
This OTP is valid for 10 minutes.
-
⚠️ Please do not share this OTP with anyone.
-
If you did not request this verification, please ignore this email.
-
-
-
+ Welcome to TripSync!
+ Your email verification OTP is:
+ {otp}
+ This OTP is valid for 10 minutes.
+ If you didn't request this, please ignore this email.
+
+ Best regards,
TripSync Team
"""
- plain_content = f"""
-Hello,
-
-Welcome to TripSync - Your Travel Planning Companion!
-
-Your OTP for email verification is: {otp}
-
-This OTP is valid for 10 minutes. Please do not share this OTP with anyone.
-
-If you did not request this, please ignore this email.
-
-Best regards,
-TripSync Team
- """
else:
subject = "Password Reset OTP - TripSync"
html_content = f"""
-
-
-
-
-
-
-
-
Reset Your Password
-
Hello,
-
You have requested to reset your password for your TripSync account.
-
Your OTP for password reset is:
-
-
-
-
This OTP is valid for 10 minutes.
-
-
-
🔒 Security Notice
-
If you did not request this password reset, please secure your account immediately and contact our support team.
-
-
-
⚠️ Never share this OTP with anyone, including TripSync staff.
-
-
-
+ Password Reset Request
+ Your password reset OTP is:
+ {otp}
+ This OTP is valid for 10 minutes.
+ If you didn't request this, please secure your account immediately.
+
+ Best regards,
TripSync Team
"""
- plain_content = f"""
-Hello,
-
-You have requested to reset your password for your TripSync account.
-
-Your OTP for password reset is: {otp}
-
-This OTP is valid for 10 minutes. Please do not share this OTP with anyone.
-
-If you did not request this, please secure your account immediately and contact support.
-
-Best regards,
-TripSync Team
- """
message = Mail(
- from_email=Email(from_email),
- to_emails=To(email),
+ from_email=settings.DEFAULT_FROM_EMAIL,
+ to_emails=email,
subject=subject,
- plain_text_content=Content("text/plain", plain_content),
- html_content=Content("text/html", html_content)
+ html_content=html_content
)
- sg = SendGridAPIClient(sendgrid_api_key)
+ sg = SendGridAPIClient(settings.SENDGRID_API_KEY)
response = sg.send(message)
if response.status_code in [200, 201, 202]:
- logger.info(f"OTP email sent successfully to {email} via SendGrid")
+ logger.info(f"✓ OTP email sent successfully to {email} via SendGrid API")
return True
else:
- logger.error(f"SendGrid returned status code {response.status_code}")
+ logger.error(f"✗ SendGrid API returned status {response.status_code}")
return False
except Exception as e:
- logger.error(f"Error sending email via SendGrid: {str(e)}")
- import traceback
- logger.error(traceback.format_exc())
+ logger.error(f"✗ SendGrid API error for {email}: {str(e)}")
+ logger.exception("Full traceback:")
return False
\ No newline at end of file
diff --git a/auth/auth/asgi.py b/auth/auth/asgi.py
new file mode 100644
index 0000000..8f43a1a
--- /dev/null
+++ b/auth/auth/asgi.py
@@ -0,0 +1,23 @@
+"""
+ASGI config for chatsystemproj project.
+
+It exposes the ASGI callable as a module-level variable named ``application``.
+
+For more information on this file, see
+https://docs.djangoproject.com/en/5.1/howto/deployment/asgi/
+"""
+import os
+from django.core.asgi import get_asgi_application
+from channels.routing import ProtocolTypeRouter, URLRouter
+from channels.auth import AuthMiddlewareStack
+
+os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'auth.settings')
+
+from chat.routing import websocket_urlpatterns
+
+application = ProtocolTypeRouter({
+ 'http': get_asgi_application(),
+ 'websocket':AuthMiddlewareStack(
+ URLRouter(websocket_urlpatterns)
+ ),
+})
\ No newline at end of file
diff --git a/auth/auth/settings.py b/auth/auth/settings.py
index db6cd6a..c61a1f1 100644
--- a/auth/auth/settings.py
+++ b/auth/auth/settings.py
@@ -8,11 +8,12 @@
SECRET_KEY = os.environ.get("SECRET_KEY", "unsafe-dev-secret-key")
-DEBUG = config('DEBUG', default=False, cast=bool)
+# DEBUG = config('DEBUG', default=False, cast=bool)
+DEBUG = True
-ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,51.20.254.52").split(',')
-
-INSTALLED_APPS = ['django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'account.apps.AccountConfig', 'rest_framework', 'rest_framework_simplejwt', 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'drf_spectacular']
+# ALLOWED_HOSTS = os.environ.get("ALLOWED_HOSTS", "localhost,127.0.0.1,51.20.254.52").split(',')
+ALLOWED_HOSTS = ["*"]
+INSTALLED_APPS = ['channels','daphne','django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'account.apps.AccountConfig', 'rest_framework', 'rest_framework_simplejwt', 'rest_framework_simplejwt.token_blacklist', 'corsheaders', 'drf_spectacular','chat.apps.ChatConfig']
MIDDLEWARE = ['django.middleware.security.SecurityMiddleware', 'whitenoise.middleware.WhiteNoiseMiddleware', 'django.contrib.sessions.middleware.SessionMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', 'django.middleware.csrf.CsrfViewMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware']
@@ -22,14 +23,31 @@
WSGI_APPLICATION = 'auth.wsgi.application'
-database_url = os.environ.get("DATABASE_URL","postgresql://arnav_db_user:FupuhQQOsNLTkJKNEp2EA6Q8Kia7hvCu@dpg-d3mk6mvdiees73c95o2g-a.singapore-postgres.render.com/arnav_db")
-if database_url.startswith("postgres://"):
- database_url = database_url.replace("postgres://", "postgresql://", 1)
-DATABASES = {'default': dj_database_url.parse(database_url, conn_max_age=600, ssl_require=True)}
+ASGI_APPLICATION = 'auth.asgi.application'
+
+REDIS_HOST = config('REDIS_HOST', default='127.0.0.1')
+REDIS_PORT = config('REDIS_PORT', default=6379, cast=int)
+
+CHANNEL_LAYERS = {
+ 'default': {
+ 'BACKEND': 'channels_redis.core.RedisChannelLayer',
+ 'CONFIG': {
+ 'hosts': [(REDIS_HOST, REDIS_PORT)],
+ },
+ },
+}
+
+database_url = os.environ.get("DATABASE_URL")
+if database_url:
+ if database_url.startswith("postgres://"):
+ database_url = database_url.replace("postgres://", "postgresql://", 1)
+ DATABASES = {'default': dj_database_url.parse(database_url, conn_max_age=600, ssl_require=True)}
+else:
+ DATABASES = {'default': {'ENGINE': 'django.db.backends.sqlite3', 'NAME': BASE_DIR / 'db.sqlite3'}}
REST_FRAMEWORK = {'DEFAULT_AUTHENTICATION_CLASSES': ('rest_framework_simplejwt.authentication.JWTAuthentication',), 'DEFAULT_SCHEMA_CLASS': 'drf_spectacular.openapi.AutoSchema', 'DEFAULT_RENDERER_CLASSES': ['rest_framework.renderers.JSONRenderer', 'rest_framework.renderers.BrowsableAPIRenderer']}
-SPECTACULAR_SETTINGS = {'TITLE': 'TripSync API', 'DESCRIPTION': 'TripSync - API Documentation', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, 'CONTACT': {'name': 'TripSync Support', 'email': 'support@tripsync.com'}, 'LICENSE': {'name': 'MIT License'}, 'TAGS': [{'name': 'Authentication', 'description': 'User registration, login and token management'}, {'name': 'Password Reset', 'description': 'Password reset functionality with OTP'}, {'name': 'User Management', 'description': 'User profile and management endpoints'}], 'SERVERS': [{'url': 'http://127.0.0.1:8000', 'description': 'Development server'}, {'url': 'https://tripsync-backend-ruak.onrender.com', 'description': 'Production server'}], 'COMPONENT_SPLIT_REQUEST': True, 'SWAGGER_UI_SETTINGS': {'deepLinking': True, 'persistAuthorization': True, 'displayOperationId': False, 'filter': True}}
+SPECTACULAR_SETTINGS = {'TITLE': 'TripSync API', 'DESCRIPTION': 'TripSync - API Documentation', 'VERSION': '1.0.0', 'SERVE_INCLUDE_SCHEMA': False, 'CONTACT': {'name': 'TripSync Support', 'email': 'support@tripsync.com'}, 'LICENSE': {'name': 'MIT License'}, 'TAGS': [{'name': 'Authentication', 'description': 'User registration, login and token management'}, {'name': 'Password Reset', 'description': 'Password reset functionality with OTP'}], 'SERVERS': [{'url': 'http://127.0.0.1:8000', 'description': 'Development server'}, {'url': 'https://tripsync-backend-ruak.onrender.com', 'description': 'Production server'}], 'COMPONENT_SPLIT_REQUEST': True, 'SWAGGER_UI_SETTINGS': {'deepLinking': True, 'persistAuthorization': True, 'displayOperationId': False, 'filter': True}}
AUTH_PASSWORD_VALIDATORS = [{'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator'}, {'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator', 'OPTIONS': {'min_length': 8}}, {'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator'}, {'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator'}]
@@ -58,7 +76,7 @@
CORS_ALLOW_METHODS = ['DELETE', 'GET', 'OPTIONS', 'PATCH', 'POST', 'PUT']
-CORS_ALLOW_HEADERS = ['accept', 'accept-encoding', 'authorization', 'content-type', 'dnt', 'origin', 'user-agent', 'x-csrftoken', 'x-requested-with']
+CORS_ALLOW_HEADERS = ['accept', 'accept-encoding', 'authorization', 'content-type', 'dnt', 'origin', 'user-agent', 'x-csrftoken', 'x-requested-with','sec-websocket-key','sec-websocket-version','sec-websocket-extensions' ]
if not DEBUG:
SECURE_SSL_REDIRECT = False
@@ -75,6 +93,12 @@
CSRF_COOKIE_SECURE = False
SESSION_COOKIE_SECURE = False
+CSRF_TRUSTED_ORIGINS = [
+ 'http://127.0.0.1:8000',
+ 'http://localhost:8000',
+ 'http://51.20.254.52'
+]
+
SENDGRID_API_KEY = config('SENDGRID_API_KEY', default='')
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='TripSync ')
EMAIL_BACKEND = 'django.core.mail.backends.smtp.EmailBackend'
diff --git a/auth/auth/urls.py b/auth/auth/urls.py
index c6a56d9..496c422 100644
--- a/auth/auth/urls.py
+++ b/auth/auth/urls.py
@@ -4,8 +4,6 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from django.http import JsonResponse
-from django.conf import settings
-from django.conf.urls.static import static
@api_view(['GET'])
def root_redirect(request):
@@ -22,10 +20,8 @@ def health_check(request):
path('health/', health_check, name='health-check'),
path('admin/', admin.site.urls),
path('api/account/', include('account.urls')),
+ path('api/chat/', include('chat.urls')),
path('api/schema/', SpectacularAPIView.as_view(), name='schema'),
path('api/docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
path('api/redoc/', SpectacularRedocView.as_view(url_name='schema'), name='redoc'),
]
-if settings.DEBUG:
- urlpatterns += static(settings.MEDIA_URL, document_root = settings.MEDIA_ROOT)
- urlpatterns += static(settings.STATIC_URL, document_root = settings.STATIC_URL)
\ No newline at end of file
diff --git a/auth/chat/__init__.py b/auth/chat/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/auth/chat/admin.py b/auth/chat/admin.py
new file mode 100644
index 0000000..d935748
--- /dev/null
+++ b/auth/chat/admin.py
@@ -0,0 +1,5 @@
+from django.contrib import admin
+from .models import *
+
+admin.site.register(Conversation)
+admin.site.register(Message)
\ No newline at end of file
diff --git a/auth/chat/apps.py b/auth/chat/apps.py
new file mode 100644
index 0000000..2fe899a
--- /dev/null
+++ b/auth/chat/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class ChatConfig(AppConfig):
+ default_auto_field = 'django.db.models.BigAutoField'
+ name = 'chat'
diff --git a/auth/chat/consumers.py b/auth/chat/consumers.py
new file mode 100644
index 0000000..670c7c1
--- /dev/null
+++ b/auth/chat/consumers.py
@@ -0,0 +1,251 @@
+from asgiref.sync import sync_to_async
+import json
+import jwt
+from channels.generic.websocket import AsyncWebsocketConsumer
+from django.conf import settings
+from urllib.parse import parse_qs
+from channels.db import database_sync_to_async
+from django.utils import timezone
+
+class ChatConsumer(AsyncWebsocketConsumer):
+ async def connect(self):
+ query_string = self.scope['query_string'].decode('utf-8')
+ params = parse_qs(query_string)
+ token = params.get('token', [None])[0]
+ if not token:
+ await self.close(code=4002)
+ return
+ try:
+ decoded_data = jwt.decode(token, settings.SECRET_KEY, algorithms=['HS256'])
+ self.user = await self.get_user(decoded_data['user_id'])
+ self.scope['user'] = self.user
+ except jwt.ExpiredSignatureError:
+ await self.close(code=4000)
+ return
+ except jwt.InvalidTokenError:
+ await self.close(code=4001)
+ return
+ except Exception as e:
+ print(f"Authentication error: {e}")
+ await self.close(code=4003)
+ return
+ self.conversation_id = self.scope['url_route']['kwargs']['conversation_id']
+ is_participant = await self.verify_participant(self.user.id, self.conversation_id)
+ if not is_participant:
+ await self.close(code=4004)
+ return
+ self.room_group_name = f'chat_{self.conversation_id}'
+ await self.channel_layer.group_add(
+ self.room_group_name,
+ self.channel_name
+ )
+ await self.accept()
+ user_data = await self.get_user_data(self.user)
+ await self.channel_layer.group_send(
+ self.room_group_name,
+ {
+ 'type': 'user_status',
+ 'user': user_data,
+ 'status': 'online',
+ }
+ )
+
+ async def receive(self, text_data):
+ try:
+ data = json.loads(text_data)
+ event_type = data.get('type')
+
+ if event_type == 'chat_message':
+ await self.handle_chat_message(data)
+
+ elif event_type == 'typing':
+ await self.handle_typing_indicator(data)
+
+ elif event_type == 'read_receipt':
+ await self.handle_read_receipt(data)
+
+ else:
+ await self.send_error("Unknown event type")
+
+ except json.JSONDecodeError:
+ await self.send_error("Invalid JSON")
+ except Exception as e:
+ print(f"Error in receive: {e}")
+ await self.send_error("Internal error")
+
+ async def disconnect(self, close_code):
+ if hasattr(self, 'room_group_name') and hasattr(self, 'user'):
+ user_data = await self.get_user_data(self.user)
+ await self.channel_layer.group_send(
+ self.room_group_name,
+ {
+ 'type': 'user_status',
+ 'user': user_data,
+ 'status': 'offline',
+ }
+ )
+ await self.channel_layer.group_discard(
+ self.room_group_name,
+ self.channel_name
+ )
+
+ async def handle_chat_message(self, data):
+ message_content = data.get('message', '').strip()
+
+ if not message_content:
+ await self.send_error("Message cannot be empty")
+ return
+
+ if len(message_content) > 5000:
+ await self.send_error("Message too long")
+ return
+
+ try:
+ is_participant = await self.verify_participant(self.user.id, self.conversation_id)
+ if not is_participant:
+ await self.send_error("You are not a participant")
+ return
+ conversation = await self.get_conversation(self.conversation_id)
+ if not conversation:
+ await self.send_error("Conversation not found")
+ return
+ message = await self.save_message(conversation, self.user, message_content)
+ user_data = await self.get_user_data(self.user)
+ await self.channel_layer.group_send(
+ self.room_group_name,
+ {
+ 'type': 'chat_message',
+ 'message_id': message.id,
+ 'message': message.content,
+ 'user': user_data,
+ 'timestamp': message.timestamp.isoformat(),
+ }
+ )
+ except Exception as e:
+ print(f"Error handling chat message: {e}")
+ await self.send_error("Failed to send message")
+
+ async def handle_typing_indicator(self, data):
+ is_typing = data.get('is_typing', False)
+
+ try:
+ user_data = await self.get_user_data(self.user)
+ await self.channel_layer.group_send(
+ self.room_group_name,
+ {
+ 'type': 'typing_indicator',
+ 'user': user_data,
+ 'is_typing': is_typing,
+ 'sender_channel': self.channel_name,
+ }
+ )
+ except Exception as e:
+ print(f"Error handling typing indicator: {e}")
+
+ async def handle_read_receipt(self, data):
+ message_id = data.get('message_id')
+
+ if not message_id:
+ return
+
+ try:
+ await self.mark_message_read(message_id, self.user.id)
+ await self.channel_layer.group_send(
+ self.room_group_name,
+ {
+ 'type': 'read_receipt',
+ 'message_id': message_id,
+ 'user_id': self.user.id,
+ }
+ )
+ except Exception as e:
+ print(f"Error handling read receipt: {e}")
+
+ async def chat_message(self, event):
+ await self.send(text_data=json.dumps({
+ 'type': 'chat_message',
+ 'message_id': event['message_id'],
+ 'message': event['message'],
+ 'user': event['user'],
+ 'timestamp': event['timestamp'],
+ }))
+
+ async def typing_indicator(self, event):
+ if event.get('sender_channel') != self.channel_name:
+ await self.send(text_data=json.dumps({
+ 'type': 'typing',
+ 'user': event['user'],
+ 'is_typing': event['is_typing'],
+ }))
+
+ async def user_status(self, event):
+ await self.send(text_data=json.dumps({
+ 'type': 'user_status',
+ 'user': event['user'],
+ 'status': event['status'],
+ }))
+
+ async def read_receipt(self, event):
+ await self.send(text_data=json.dumps({
+ 'type': 'read_receipt',
+ 'message_id': event['message_id'],
+ 'user_id': event['user_id'],
+ }))
+
+ async def send_error(self, message):
+ await self.send(text_data=json.dumps({
+ 'type': 'error',
+ 'message': message
+ }))
+
+ @database_sync_to_async
+ def get_user(self, user_id):
+ from django.contrib.auth import get_user_model
+ User = get_user_model()
+ try:
+ return User.objects.get(id=user_id)
+ except User.DoesNotExist:
+ return None
+
+ @database_sync_to_async
+ def get_user_data(self, user):
+ from .serializers import UserListSerializer
+ return UserListSerializer(user).data
+
+ @database_sync_to_async
+ def get_conversation(self, conversation_id):
+ from .models import Conversation
+ try:
+ return Conversation.objects.get(id=conversation_id)
+ except Conversation.DoesNotExist:
+ return None
+
+ @database_sync_to_async
+ def verify_participant(self, user_id, conversation_id):
+ from .models import Conversation
+ try:
+ conversation = Conversation.objects.get(id=conversation_id)
+ return conversation.participants.filter(id=user_id).exists()
+ except Conversation.DoesNotExist:
+ return False
+
+ @database_sync_to_async
+ def save_message(self, conversation, user, content):
+ from .models import Message
+ return Message.objects.create(
+ conversation=conversation,
+ sender=user,
+ content=content
+ )
+
+ @database_sync_to_async
+ def mark_message_read(self, message_id, user_id):
+ from .models import Message
+ try:
+ message = Message.objects.get(id=message_id)
+ if message.sender.id != user_id:
+ message.is_read = True
+ message.save(update_fields=['is_read'])
+ return True
+ except Message.DoesNotExist:
+ return False
\ No newline at end of file
diff --git a/auth/chat/migrations/0001_initial.py b/auth/chat/migrations/0001_initial.py
new file mode 100644
index 0000000..824f26c
--- /dev/null
+++ b/auth/chat/migrations/0001_initial.py
@@ -0,0 +1,58 @@
+# Generated by Django 5.2.7 on 2025-10-24 11:43
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='Conversation',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('name', models.CharField(blank=True, max_length=255, null=True)),
+ ('is_group', models.BooleanField(default=False)),
+ ('created_at', models.DateTimeField(auto_now_add=True)),
+ ('updated_at', models.DateTimeField(auto_now=True)),
+ ('participants', models.ManyToManyField(related_name='conversations', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['-updated_at'],
+ },
+ ),
+ migrations.CreateModel(
+ name='Message',
+ fields=[
+ ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
+ ('content', models.TextField()),
+ ('timestamp', models.DateTimeField(auto_now_add=True)),
+ ('is_read', models.BooleanField(default=False)),
+ ('edited_at', models.DateTimeField(blank=True, null=True)),
+ ('conversation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='messages', to='chat.conversation')),
+ ('sender', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_messages', to=settings.AUTH_USER_MODEL)),
+ ],
+ options={
+ 'ordering': ['timestamp'],
+ },
+ ),
+ migrations.AddIndex(
+ model_name='conversation',
+ index=models.Index(fields=['-updated_at'], name='chat_conver_updated_1f6ffe_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='message',
+ index=models.Index(fields=['conversation', 'timestamp'], name='chat_messag_convers_cd68de_idx'),
+ ),
+ migrations.AddIndex(
+ model_name='message',
+ index=models.Index(fields=['conversation', '-timestamp'], name='chat_messag_convers_dca7ce_idx'),
+ ),
+ ]
diff --git a/auth/chat/migrations/__init__.py b/auth/chat/migrations/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/auth/chat/models.py b/auth/chat/models.py
new file mode 100644
index 0000000..29a20f4
--- /dev/null
+++ b/auth/chat/models.py
@@ -0,0 +1,71 @@
+from django.db import models
+from account.models import User
+from django.db.models import Prefetch
+
+class ConversationManager(models.Manager):
+ def get_queryset(self):
+ return super().get_queryset().prefetch_related(
+ Prefetch('participants', queryset=User.objects.only('id', 'email'))
+ )
+ def for_user(self, user):
+ return self.get_queryset().filter(participants=user)
+
+class Conversation(models.Model):
+ participants = models.ManyToManyField(User, related_name='conversations')
+ name = models.CharField(max_length=255, null=True, blank=True)
+ is_group = models.BooleanField(default=False)
+ created_at = models.DateTimeField(auto_now_add=True)
+ updated_at = models.DateTimeField(auto_now=True)
+ objects = ConversationManager()
+ class Meta:
+ ordering = ['-updated_at']
+ indexes = [
+ models.Index(fields=['-updated_at']),
+ ]
+
+ def __str__(self):
+ if self.name:
+ return f'{self.name}'
+ participant_emails = ", ".join([user.email for user in self.participants.all()[:3]])
+ return f'Conversation with {participant_emails}'
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ if self.participants.count() > 2:
+ self.is_group = True
+ super().save(update_fields=['is_group'])
+
+ @property
+ def last_message(self):
+ return self.messages.order_by('-timestamp').first()
+
+ def is_participant(self, user):
+ return self.participants.filter(id=user.id).exists()
+
+
+class Message(models.Model):
+ conversation = models.ForeignKey(
+ Conversation,
+ on_delete=models.CASCADE,
+ related_name='messages'
+ )
+ sender = models.ForeignKey(User, on_delete=models.CASCADE, related_name='sent_messages')
+ content = models.TextField()
+ timestamp = models.DateTimeField(auto_now_add=True)
+ is_read = models.BooleanField(default=False)
+ edited_at = models.DateTimeField(null=True, blank=True)
+
+ class Meta:
+ ordering = ['timestamp']
+ indexes = [
+ models.Index(fields=['conversation', 'timestamp']),
+ models.Index(fields=['conversation', '-timestamp']),
+ ]
+
+ def __str__(self):
+ preview = self.content[:50] + "..." if len(self.content) > 50 else self.content
+ return f'Message from {self.sender.email}: {preview}'
+
+ def save(self, *args, **kwargs):
+ super().save(*args, **kwargs)
+ self.conversation.save(update_fields=['updated_at'])
\ No newline at end of file
diff --git a/auth/chat/routing.py b/auth/chat/routing.py
new file mode 100644
index 0000000..27c9fcd
--- /dev/null
+++ b/auth/chat/routing.py
@@ -0,0 +1,6 @@
+from django.urls import re_path
+from . import consumers
+
+websocket_urlpatterns = [
+ re_path(r'ws/chat/(?P\d+)/$', consumers.ChatConsumer.as_asgi()),
+]
\ No newline at end of file
diff --git a/auth/chat/serializers.py b/auth/chat/serializers.py
new file mode 100644
index 0000000..4d8e3e9
--- /dev/null
+++ b/auth/chat/serializers.py
@@ -0,0 +1,125 @@
+from rest_framework import serializers
+from account.models import User
+from .models import Conversation, Message
+from django.db import models
+from django.db.models import Count, Q
+
+
+class UserListSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = User
+ fields = ('id', 'email')
+ read_only_fields = ('id', 'email')
+
+class MessageSerializer(serializers.ModelSerializer):
+ sender = UserListSerializer(read_only=True)
+
+ class Meta:
+ model = Message
+ fields = ('id', 'conversation', 'sender', 'content', 'timestamp', 'is_read', 'edited_at')
+ read_only_fields = ('id', 'sender', 'timestamp', 'conversation', 'is_read', 'edited_at')
+
+class CreateMessageSerializer(serializers.ModelSerializer):
+ class Meta:
+ model = Message
+ fields = ('content',)
+
+ def validate_content(self, value):
+ if not value or not value.strip():
+ raise serializers.ValidationError("Message content cannot be empty")
+ if len(value) > 5000:
+ raise serializers.ValidationError("Message too long (max 5000 characters)")
+ return value.strip()
+
+class ConversationSerializer(serializers.ModelSerializer):
+ participants = UserListSerializer(many=True, read_only=True)
+ last_message = serializers.SerializerMethodField()
+ unread_count = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Conversation
+ fields = (
+ 'id',
+ 'name',
+ 'is_group',
+ 'participants',
+ 'created_at',
+ 'updated_at',
+ 'last_message',
+ 'unread_count'
+ )
+ read_only_fields = ('id', 'created_at', 'updated_at', 'is_group')
+
+ def get_last_message(self, obj):
+ last_msg = obj.messages.order_by('-timestamp').first()
+ if last_msg:
+ return {
+ 'id': last_msg.id,
+ 'content': last_msg.content[:100],
+ 'sender': UserListSerializer(last_msg.sender).data,
+ 'timestamp': last_msg.timestamp
+ }
+ return None
+
+ def get_unread_count(self, obj):
+ request = self.context.get('request')
+ if request and request.user:
+ return obj.messages.filter(is_read=False).exclude(sender=request.user).count()
+ return 0
+
+
+class ConversationDetailSerializer(serializers.ModelSerializer):
+ participants = UserListSerializer(many=True, read_only=True)
+ messages = serializers.SerializerMethodField()
+
+ class Meta:
+ model = Conversation
+ fields = ('id', 'name', 'is_group', 'participants', 'created_at', 'messages')
+ read_only_fields = ('id', 'created_at', 'is_group')
+
+ def get_messages(self, obj):
+ messages = obj.messages.order_by('-timestamp')[:50]
+ return MessageSerializer(messages, many=True).data
+
+
+class CreateConversationSerializer(serializers.Serializer):
+ participant_ids = serializers.ListField(
+ child=serializers.IntegerField(),
+ min_length=1,
+ max_length=50,
+ help_text="List of user IDs to add to conversation"
+ )
+ name = serializers.CharField(
+ max_length=255,
+ required=False,
+ allow_blank=True,
+ help_text="Optional name for group conversations"
+ )
+
+ def validate_participant_ids(self, value):
+ unique_ids = list(set(value))
+ existing_users = User.objects.filter(id__in=unique_ids).count()
+ if existing_users != len(unique_ids):
+ raise serializers.ValidationError("One or more users do not exist")
+ return unique_ids
+
+ def create(self, validated_data):
+ participant_ids = validated_data.pop('participant_ids')
+ name = validated_data.get('name', '')
+ request_user = self.context['request'].user
+ if request_user.id not in participant_ids:
+ participant_ids.append(request_user.id)
+ existing_conv = None
+ if len(participant_ids) == 2:
+ existing_conv = Conversation.objects.filter(
+ participants__id=participant_ids[0]).filter(
+ participants__id=participant_ids[1]).annotate(
+ participant_count=Count('participants')).filter(
+ participant_count=2).first()
+
+ if existing_conv:
+ return existing_conv
+
+ conversation = Conversation.objects.create(name=name,is_group=(len(participant_ids) > 2))
+ conversation.participants.set(participant_ids)
+ return conversation
\ No newline at end of file
diff --git a/auth/chat/tests.py b/auth/chat/tests.py
new file mode 100644
index 0000000..7ce503c
--- /dev/null
+++ b/auth/chat/tests.py
@@ -0,0 +1,3 @@
+from django.test import TestCase
+
+# Create your tests here.
diff --git a/auth/chat/urls.py b/auth/chat/urls.py
new file mode 100644
index 0000000..0acf535
--- /dev/null
+++ b/auth/chat/urls.py
@@ -0,0 +1,16 @@
+from django.urls import path
+from .views import (
+ ConversationListCreateView,
+ ConversationDetailView,
+ MessageListCreateView,
+ MessageRetrieveUpdateDestroyView
+)
+
+app_name = 'chat'
+
+urlpatterns = [
+ path('conversations/', ConversationListCreateView.as_view(),name='conversation-list-create'),
+ path('conversations//',ConversationDetailView.as_view(), name='conversation-detail'),
+ path('conversations//messages/', MessageListCreateView.as_view(), name='message-list-create'),
+ path('conversations//messages//', MessageRetrieveUpdateDestroyView.as_view(), name='message-detail'),
+]
\ No newline at end of file
diff --git a/auth/chat/views.py b/auth/chat/views.py
new file mode 100644
index 0000000..e4e8f92
--- /dev/null
+++ b/auth/chat/views.py
@@ -0,0 +1,319 @@
+from rest_framework import generics, status
+from rest_framework.response import Response
+from rest_framework.permissions import IsAuthenticated
+from django.shortcuts import get_object_or_404
+from rest_framework.exceptions import PermissionDenied, ValidationError
+from drf_spectacular.utils import extend_schema, OpenApiParameter, OpenApiExample
+from drf_spectacular.types import OpenApiTypes
+from django.db.models import Count, Q
+from account.models import User
+from .models import Conversation, Message
+from .serializers import (
+ ConversationSerializer,
+ MessageSerializer,
+ CreateMessageSerializer,
+ CreateConversationSerializer,
+ ConversationDetailSerializer
+)
+
+class ConversationListCreateView(generics.ListCreateAPIView):
+ permission_classes = [IsAuthenticated]
+ def get_serializer_class(self):
+ if self.request.method == 'POST':
+ return CreateConversationSerializer
+ return ConversationSerializer
+
+ def get_queryset(self):
+ return Conversation.objects.filter(
+ participants=self.request.user
+ ).prefetch_related('participants', 'messages').annotate(
+ message_count=Count('messages')
+ ).order_by('-updated_at')
+
+ @extend_schema(
+ summary="List user's conversations",
+ description="Get all conversations where the authenticated user is a participant, ordered by most recent activity",
+ responses={
+ 200: ConversationSerializer(many=True),
+ 401: OpenApiTypes.OBJECT,
+ },
+ tags=['Chat - Conversations']
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Create a conversation",
+ description="Create a new conversation (DM or group). For DM between 2 users, returns existing conversation if it already exists.",
+ request=CreateConversationSerializer,
+ examples=[
+ OpenApiExample(
+ 'Direct Message',
+ value={
+ 'participant_ids': [2],
+ },
+ request_only=True,
+ ),
+ OpenApiExample(
+ 'Group Chat',
+ value={
+ 'participant_ids': [2, 3, 4],
+ 'name': 'Trip Planning Group'
+ },
+ request_only=True,
+ ),
+ ],
+ responses={
+ 201: ConversationSerializer,
+ 400: OpenApiTypes.OBJECT,
+ 401: OpenApiTypes.OBJECT,
+ },
+ tags=['Chat - Conversations']
+ )
+ def post(self, request, *args, **kwargs):
+ serializer = self.get_serializer(data=request.data)
+ serializer.is_valid(raise_exception=True)
+ conversation = serializer.save()
+ response_serializer = ConversationSerializer(
+ conversation,
+ context={'request': request}
+ )
+ return Response(response_serializer.data, status=status.HTTP_201_CREATED)
+
+
+class ConversationDetailView(generics.RetrieveDestroyAPIView):
+ permission_classes = [IsAuthenticated]
+ serializer_class = ConversationDetailSerializer
+
+ def get_queryset(self):
+ return Conversation.objects.filter(participants=self.request.user)
+
+ @extend_schema(
+ summary="Get conversation details",
+ description="Get detailed information about a conversation including participants and recent messages",
+ responses={
+ 200: ConversationDetailSerializer,
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not a participant",
+ 404: "Not Found",
+ },
+ tags=['Chat - Conversations']
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Leave conversation",
+ description="Remove yourself from a conversation. If you're the last participant, the conversation will be deleted.",
+ responses={
+ 204: "Successfully left conversation",
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not a participant",
+ 404: "Not Found",
+ },
+ tags=['Chat - Conversations']
+ )
+ def delete(self, request, *args, **kwargs):
+ conversation = self.get_object()
+ conversation.participants.remove(request.user)
+ if conversation.participants.count() == 0:
+ conversation.delete()
+ return Response(status=status.HTTP_204_NO_CONTENT)
+
+
+class MessageListCreateView(generics.ListCreateAPIView):
+ permission_classes = [IsAuthenticated]
+
+ def get_queryset(self):
+ conversation_id = self.kwargs['conversation_id']
+ conversation = self.get_conversation(conversation_id)
+ return Message.objects.filter(
+ conversation=conversation
+ ).select_related('sender').order_by('timestamp')
+
+ def get_serializer_class(self):
+ if self.request.method == 'POST':
+ return CreateMessageSerializer
+ return MessageSerializer
+
+ @extend_schema(
+ summary="List messages",
+ description="Get all messages from a conversation. User must be a participant.",
+ parameters=[
+ OpenApiParameter(
+ name='conversation_id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Conversation ID',
+ required=True
+ )
+ ],
+ responses={
+ 200: MessageSerializer(many=True),
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not a participant",
+ 404: "Not Found",
+ },
+ tags=['Chat - Messages']
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Send message",
+ description="Send a new message to the conversation. User must be a participant.",
+ parameters=[
+ OpenApiParameter(
+ name='conversation_id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Conversation ID',
+ required=True
+ )
+ ],
+ request=CreateMessageSerializer,
+ examples=[
+ OpenApiExample(
+ 'Text Message',
+ value={'content': 'What time should we meet for the trip?'},
+ request_only=True,
+ ),
+ ],
+ responses={
+ 201: MessageSerializer,
+ 400: OpenApiTypes.OBJECT,
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not a participant",
+ 404: "Not Found",
+ },
+ tags=['Chat - Messages']
+ )
+ def post(self, request, *args, **kwargs):
+ return super().post(request, *args, **kwargs)
+
+ def perform_create(self, serializer):
+ conversation_id = self.kwargs['conversation_id']
+ conversation = self.get_conversation(conversation_id)
+ serializer.save(sender=self.request.user, conversation=conversation)
+
+ def get_conversation(self, conversation_id):
+ conversation = get_object_or_404(Conversation, id=conversation_id)
+ if not conversation.is_participant(self.request.user):
+ raise PermissionDenied('You are not a participant of this conversation')
+ return conversation
+
+
+class MessageRetrieveUpdateDestroyView(generics.RetrieveUpdateDestroyAPIView):
+ permission_classes = [IsAuthenticated]
+ serializer_class = MessageSerializer
+
+ def get_queryset(self):
+ conversation_id = self.kwargs['conversation_id']
+ conversation = get_object_or_404(Conversation, id=conversation_id)
+ if not conversation.is_participant(self.request.user):
+ raise PermissionDenied('You are not a participant of this conversation')
+
+ return Message.objects.filter(conversation=conversation)
+
+ @extend_schema(
+ summary="Get message details",
+ description="Retrieve a specific message from a conversation",
+ parameters=[
+ OpenApiParameter(
+ name='conversation_id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Conversation ID',
+ required=True
+ ),
+ OpenApiParameter(
+ name='id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Message ID',
+ required=True
+ )
+ ],
+ responses={
+ 200: MessageSerializer,
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not a participant",
+ 404: "Not Found",
+ },
+ tags=['Chat - Messages']
+ )
+ def get(self, request, *args, **kwargs):
+ return super().get(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Edit message",
+ description="Edit your own message content. Only the sender can edit their messages.",
+ parameters=[
+ OpenApiParameter(
+ name='conversation_id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Conversation ID',
+ required=True
+ ),
+ OpenApiParameter(
+ name='id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Message ID',
+ required=True
+ )
+ ],
+ request=CreateMessageSerializer,
+ responses={
+ 200: MessageSerializer,
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not the sender",
+ 404: "Not Found",
+ },
+ tags=['Chat - Messages']
+ )
+ def patch(self, request, *args, **kwargs):
+ return super().patch(request, *args, **kwargs)
+
+ @extend_schema(
+ summary="Delete message",
+ description="Delete your own message. Only the sender can delete their messages.",
+ parameters=[
+ OpenApiParameter(
+ name='conversation_id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Conversation ID',
+ required=True
+ ),
+ OpenApiParameter(
+ name='id',
+ type=OpenApiTypes.INT,
+ location=OpenApiParameter.PATH,
+ description='Message ID',
+ required=True
+ )
+ ],
+ responses={
+ 204: "Message deleted",
+ 401: OpenApiTypes.OBJECT,
+ 403: "Forbidden - Not the sender",
+ 404: "Not Found",
+ },
+ tags=['Chat - Messages']
+ )
+ def delete(self, request, *args, **kwargs):
+ return super().delete(request, *args, **kwargs)
+
+ def perform_update(self, serializer):
+ if serializer.instance.sender != self.request.user:
+ raise PermissionDenied('You can only edit your own messages')
+
+ from django.utils import timezone
+ serializer.save(edited_at=timezone.now())
+
+ def perform_destroy(self, instance):
+ if instance.sender != self.request.user:
+ raise PermissionDenied('You can only delete your own messages')
+ instance.delete()
\ No newline at end of file
diff --git a/auth/db.sqlite3 b/auth/db.sqlite3
index de2c61d..180563e 100644
Binary files a/auth/db.sqlite3 and b/auth/db.sqlite3 differ
diff --git a/auth/requirements.txt b/auth/requirements.txt
index d54ae3c..1c5c706 100644
Binary files a/auth/requirements.txt and b/auth/requirements.txt differ