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})