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""" - - - - -
-
-

Welcome to TripSync!

-
-
-

Email Verification

-

Hello,

-

Thank you for registering with TripSync - Your Travel Planning Companion!

-

To verify your email address, please use the following OTP code:

- -
-
{otp}
-
- -

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""" - - - - -
-
-

Password Reset Request

-
-
-

Reset Your Password

-

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.

- -
-

🔒 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