Source code for chat.views

import json

from asgiref.sync import async_to_sync
from channels.layers import get_channel_layer
from django.http import JsonResponse
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods

from users.models import User
from users.serializers import full_name

from .models import Message


[docs] def has_active_match(user): """ Determine whether a user currently has at least one active match. Checks match state based on role, bypassing the ``isMatched`` flag alone to guard against stale flag values: - **MENTEE** — requires both ``isMatched=True`` and a non-empty ``matchedMentorEmail``. - **MENTOR** — requires a non-empty ``current_mentees`` list. :param user: The user whose match state is being evaluated. :type user: users.models.User :returns: ``True`` if the user has at least one active match, ``False`` for any other role or if match fields are absent. :rtype: bool Example:: >>> has_active_match(mentee_user) True >>> has_active_match(unmatched_mentor) False """ if user.role == 'MENTEE': return user.isMatched and bool(user.matchedMentorEmail) elif user.role == 'MENTOR': return bool(user.current_mentees) return False
[docs] def validate_match_relationship(user, partner_email): """ Verify that ``user`` is permitted to chat with the given partner. Enforces the same match-relationship rules used in ``matching/views.py``: - **MENTEE** — may only chat with the mentor stored in ``matchedMentorEmail``, and that mentor must hold the ``MENTOR`` role. - **MENTOR** — may only chat with emails present in ``current_mentees``, and each must hold the ``MENTEE`` role. :param user: The authenticated user initiating the chat action. :type user: users.models.User :param partner_email: Email address of the intended chat partner. :type partner_email: str :returns: A three-tuple of: - ``is_valid`` (bool) — ``True`` if the relationship is permitted. - ``error_message`` (str or None) — Human-readable reason for rejection, or ``None`` on success. - ``partner_user`` (:class:`~users.models.User` or None) — The resolved partner instance on success, or ``None`` on failure. :rtype: tuple[bool, str | None, users.models.User | None] Example:: >>> is_valid, error, partner = validate_match_relationship(mentee, 'mentor@ucla.edu') >>> if not is_valid: ... return JsonResponse({'error': error}, status=403) """ try: partner = User.objects.get(email=partner_email) except User.DoesNotExist: return False, 'Partner not found', None if user.role == 'MENTEE': if user.isMatched and user.matchedMentorEmail == partner_email and partner.role == 'MENTOR': return True, None, partner return False, 'Unauthorized', None elif user.role == 'MENTOR': if partner_email in (user.current_mentees or []) and partner.role == 'MENTEE': return True, None, partner return False, 'Unauthorized', None return False, 'Invalid user role', None
[docs] def get_room_name(email1, email2): """ Derive a deterministic Django Channels room name for two participants. Sorts the two email addresses lexicographically before building the name so that ``get_room_name(A, B)`` and ``get_room_name(B, A)`` always return the same string. ``@`` and ``.`` characters are replaced to satisfy channel-layer naming constraints. :param email1: Email address of one participant. :type email1: str :param email2: Email address of the other participant. :type email2: str :returns: A channel-layer-safe room name string, e.g. ``'chat_jdoe_at_g_dot_ucla_dot_edu_mentor_at_ucla_dot_edu'``. :rtype: str Example:: >>> get_room_name('b@ucla.edu', 'a@ucla.edu') == get_room_name('a@ucla.edu', 'b@ucla.edu') True """ emails = sorted([email1, email2]) return f"chat_{emails[0]}_{emails[1]}".replace('@', '_at_').replace('.', '_dot_')
[docs] @csrf_exempt @require_http_methods(["POST"]) def send_message(request): """ Send a text message from the authenticated user to their matched partner. Persists the message via :class:`~chat.models.Message` and attempts a real-time broadcast to the relevant Django Channels group. If the WebSocket broadcast fails the message is still saved and a success response is returned — the failure is silently swallowed to avoid disrupting the REST flow. **Pre-conditions:** both sender and receiver must be actively matched (validated by :func:`has_active_match` and :func:`validate_match_relationship`). **Post-conditions:** the :class:`~chat.models.Message` row is written to the database and, on a successful broadcast, the payload appears in the recipient's open chat window via WebSocket. :param request: The incoming HTTP POST request containing a JSON body with ``receiver_email`` and ``content``. Must belong to an authenticated user. :type request: django.http.HttpRequest :returns: - HTTP 200 with ``success``, ``message_id``, and ``timestamp`` on success. - HTTP 400 if ``receiver_email`` is absent, ``content`` is empty or exceeds 5 000 characters, or the body is not valid JSON. - HTTP 401 if the request is unauthenticated. - HTTP 403 if the sender has no active match or the recipient is not the sender's matched partner. - HTTP 500 for unexpected server errors. :rtype: django.http.JsonResponse **Request body** (JSON):: { "receiver_email": "mentor@ucla.edu", "content": "Hi, can we meet this week?" } **Success response** (HTTP 200):: { "success": true, "message_id": "42", "timestamp": "2024-01-15T10:30:00+00:00" } """ if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) try: data = json.loads(request.body) receiver_email = data.get('receiver_email') content = data.get('content', '').strip() if not receiver_email: return JsonResponse({'error': 'Receiver email is required'}, status=400) if not content: return JsonResponse({'error': 'Message content cannot be empty'}, status=400) if len(content) > 5000: return JsonResponse({'error': 'Message too long (max 5000 characters)'}, status=400) if not has_active_match(request.user): return JsonResponse({'error': 'Unauthorized'}, status=403) is_valid, error, partner = validate_match_relationship(request.user, receiver_email) if not is_valid: return JsonResponse({'error': error}, status=403) message = Message.objects.create( sender_email=request.user.email, receiver_email=receiver_email, content=content ) try: channel_layer = get_channel_layer() room_name = get_room_name(request.user.email, receiver_email) async_to_sync(channel_layer.group_send)( room_name, { 'type': 'chat_message_broadcast', 'message_id': str(message.pk), 'sender_email': request.user.email, 'sender_name': full_name(request.user), 'content': content, 'timestamp': message.timestamp.isoformat(), } ) except Exception: # WebSocket broadcast failed — message is already persisted, # so the failure is swallowed to preserve REST response integrity. pass return JsonResponse({ 'success': True, 'message_id': str(message.pk), 'timestamp': message.timestamp.isoformat() }) except json.JSONDecodeError: return JsonResponse({'error': 'Invalid JSON format'}, status=400) except Exception as e: return JsonResponse({'error': str(e)}, status=500)
[docs] @csrf_exempt @require_http_methods(["GET"]) def get_chat_history(request, partner_email): """ Return paginated chat history between the authenticated user and a matched partner. Messages are fetched newest-first from :meth:`Message.get_conversation <chat.models.Message.get_conversation>` and then reversed before serialization so the response list is in chronological (oldest-first) order, matching typical chat UI expectations. A ``has_more`` flag indicates whether a subsequent page is available. :param request: The incoming HTTP GET request. Accepts optional ``limit`` and ``offset`` query parameters. Must belong to an authenticated, actively matched user. :type request: django.http.HttpRequest :param partner_email: Email address of the chat partner, supplied as a URL path parameter. :type partner_email: str :returns: - HTTP 200 with ``messages``, ``partner`` profile data, and ``has_more`` on success. - HTTP 401 if the request is unauthenticated. - HTTP 403 if the user has no active match or is not matched with ``partner_email``. :rtype: django.http.JsonResponse **Query parameters:** - ``limit`` — Maximum messages to return. Capped at ``100``. Defaults to ``50``. - ``offset`` — Number of messages to skip for pagination. Defaults to ``0``. **Success response** (HTTP 200):: { "messages": [ { "id": "1", "sender_email": "mentor@ucla.edu", "sender_name": "John Smith", "content": "Welcome!", "timestamp": "2024-01-15T09:00:00+00:00", "is_read": true, "is_mine": false } ], "partner": { "email": "mentor@ucla.edu", "name": "John Smith", "major": ["Computer Science"], ... }, "has_more": false } """ if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) if not has_active_match(request.user): return JsonResponse({'error': 'Unauthorized'}, status=403) is_valid, error, partner = validate_match_relationship(request.user, partner_email) if not is_valid: return JsonResponse({'error': error}, status=403) limit = min(int(request.GET.get('limit', 50)), 100) offset = int(request.GET.get('offset', 0)) messages = Message.get_conversation( request.user.email, partner_email, limit=limit, offset=offset ) sender_names = { request.user.email: full_name(request.user), partner.email: full_name(partner), } message_list = [{ 'id': str(msg.pk), 'sender_email': msg.sender_email, 'sender_name': sender_names.get(msg.sender_email, msg.sender_email.split('@')[0]), 'content': msg.content, 'timestamp': msg.timestamp.isoformat(), 'is_read': msg.is_read, 'is_mine': msg.sender_email == request.user.email, } for msg in reversed(list(messages))] return JsonResponse({ 'messages': message_list, 'partner': { 'email': partner.email, 'name': full_name(partner), 'major': partner.major, 'minor': partner.minor, 'year': partner.year, 'role': partner.role, 'hobbies': partner.hobbies, 'clubs': partner.clubs, 'goals': partner.goals, 'profilePictureUrl': partner.profilePictureUrl, }, 'has_more': len(message_list) == limit, })
[docs] @csrf_exempt @require_http_methods(["GET"]) def get_unread_counts(request): """ Return per-partner unread message counts for the authenticated user. Behaviour differs by role: - **MENTEE** — returns a single entry keyed by ``matchedMentorEmail``, if matched. - **MENTOR** — returns one entry per email in ``current_mentees``. :param request: The incoming HTTP GET request. Must belong to an authenticated user. :type request: django.http.HttpRequest :returns: - HTTP 200 with ``counts`` (dict mapping partner email → unread integer) and ``total`` (sum of all counts) on success. - HTTP 401 if the request is unauthenticated. :rtype: django.http.JsonResponse **Success response** (HTTP 200):: { "counts": { "mentor@ucla.edu": 3 }, "total": 3 } """ if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) counts = {} if request.user.role == 'MENTEE' and request.user.isMatched: mentor_email = request.user.matchedMentorEmail if mentor_email: counts[mentor_email] = Message.get_unread_count( receiver_email=request.user.email, sender_email=mentor_email ) elif request.user.role == 'MENTOR': for mentee_email in (request.user.current_mentees or []): counts[mentee_email] = Message.get_unread_count( receiver_email=request.user.email, sender_email=mentee_email ) return JsonResponse({ 'counts': counts, 'total': sum(counts.values()), })
[docs] @csrf_exempt @require_http_methods(["POST"]) def mark_messages_read(request, partner_email): """ Mark all unread messages from a matched partner as read. Delegates to :meth:`Message.mark_as_read <chat.models.Message.mark_as_read>`, which issues a single bulk ``UPDATE`` query. The match relationship is validated before any write is performed. :param request: The incoming HTTP POST request. No body is required. Must belong to an authenticated, actively matched user. :type request: django.http.HttpRequest :param partner_email: Email address of the partner whose messages should be marked as read, supplied as a URL path parameter. :type partner_email: str :returns: - HTTP 200 with ``marked_read`` (number of rows updated) on success. - HTTP 401 if the request is unauthenticated. - HTTP 403 if the user has no active match or is not matched with ``partner_email``. :rtype: django.http.JsonResponse **Success response** (HTTP 200):: { "marked_read": 4 } """ if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) if not has_active_match(request.user): return JsonResponse({'error': 'Unauthorized'}, status=403) is_valid, error, _ = validate_match_relationship(request.user, partner_email) if not is_valid: return JsonResponse({'error': error}, status=403) count = Message.mark_as_read( receiver_email=request.user.email, sender_email=partner_email ) return JsonResponse({'marked_read': count})
[docs] @csrf_exempt @require_http_methods(["GET"]) def get_chat_partners(request): """ Return all chat partners for the authenticated user with previews and unread counts. Behaviour differs by role: - **MENTEE** — returns at most one partner (the matched mentor). - **MENTOR** — returns one entry per email in ``current_mentees``. Each partner entry includes a truncated last-message preview and an unread count. Partners with no message history have ``lastMessage`` and ``lastMessageTime`` set to ``null``. The list is sorted by ``lastMessageTime`` descending (most recently active first); partners with no messages sort to the end. Stale emails in ``matchedMentorEmail`` or ``current_mentees`` that no longer correspond to an existing ``User`` are silently skipped. :param request: The incoming HTTP GET request. Must belong to an authenticated user. :type request: django.http.HttpRequest :returns: - HTTP 200 with a ``partners`` list on success. The list may be empty if the user has no current matches or all partner records have been deleted. - HTTP 401 if the request is unauthenticated. :rtype: django.http.JsonResponse **Success response** (HTTP 200):: { "partners": [ { "email": "mentor@ucla.edu", "name": "John Smith", "role": "MENTOR", "major": ["Computer Science"], "minor": [], "year": 3, "hobbies": "chess", "clubs": "ACM", "goals": "...", "profilePictureUrl": "https://...", "lastMessage": "See you tomorrow!", "lastMessageTime": "2024-01-15T10:30:00+00:00", "unreadCount": 2 } ] } """ if not request.user.is_authenticated: return JsonResponse({'error': 'Authentication required'}, status=401) partners = [] if request.user.role == 'MENTEE' and request.user.isMatched: mentor_email = request.user.matchedMentorEmail if mentor_email: try: mentor = User.objects.get(email=mentor_email) last_msg = Message.objects.filter( sender_email__in=[request.user.email, mentor_email], receiver_email__in=[request.user.email, mentor_email] ).order_by('-timestamp').first() unread = Message.get_unread_count( receiver_email=request.user.email, sender_email=mentor_email ) partners.append({ 'email': mentor.email, 'name': full_name(mentor), 'major': mentor.major, 'minor': mentor.minor, 'year': mentor.year, 'role': 'MENTOR', 'hobbies': mentor.hobbies, 'clubs': mentor.clubs, 'goals': mentor.goals, 'profilePictureUrl': mentor.profilePictureUrl, 'lastMessage': last_msg.content[:50] if last_msg else None, 'lastMessageTime': last_msg.timestamp.isoformat() if last_msg else None, 'unreadCount': unread, }) except User.DoesNotExist: pass elif request.user.role == 'MENTOR': for mentee_email in (request.user.current_mentees or []): try: mentee = User.objects.get(email=mentee_email) last_msg = Message.objects.filter( sender_email__in=[request.user.email, mentee_email], receiver_email__in=[request.user.email, mentee_email] ).order_by('-timestamp').first() unread = Message.get_unread_count( receiver_email=request.user.email, sender_email=mentee_email ) partners.append({ 'email': mentee.email, 'name': full_name(mentee), 'major': mentee.major, 'minor': mentee.minor, 'year': mentee.year, 'role': 'MENTEE', 'hobbies': mentee.hobbies, 'clubs': mentee.clubs, 'goals': mentee.goals, 'profilePictureUrl': mentee.profilePictureUrl, 'lastMessage': last_msg.content[:50] if last_msg else None, 'lastMessageTime': last_msg.timestamp.isoformat() if last_msg else None, 'unreadCount': unread, }) except User.DoesNotExist: pass partners.sort(key=lambda p: p['lastMessageTime'] or '', reverse=True) return JsonResponse({'partners': partners})