# business logic for mentor matching operations
from django.db import models
from chat.models import Message
from users.models import User
from .models import Score
from .serializers import MAX_MENTEES, serialize_mentor
[docs]
class MatchingError(Exception):
"""
Base exception for all matching-layer errors.
Carries an HTTP ``status`` code alongside the message so that views can
return the appropriate response status without additional branching logic.
:param message: Human-readable description of the error.
:type message: str
:param status: HTTP status code to return to the client. Defaults to ``400``.
:type status: int
Example::
raise MatchingError('Something went wrong', status=400)
"""
def __init__(self, message, status=400):
super().__init__(message)
self.status = status
[docs]
class NotFound(MatchingError):
"""
Raised when a required resource (user, mentor, mentee) cannot be found.
Specialization of :exc:`MatchingError` that always sets ``status=404``.
:param message: Human-readable description of the missing resource.
:type message: str
Example::
raise NotFound('Mentor not found')
"""
def __init__(self, message):
super().__init__(message, status=404)
[docs]
class Conflict(MatchingError):
"""
Raised when a matching operation conflicts with the current system state.
Specialization of :exc:`MatchingError` that always sets ``status=409``.
Typical use case: attempting to add a mentee to a mentor who is already
at full capacity.
:param message: Human-readable description of the conflict.
:type message: str
Example::
raise Conflict('This mentor already has two mentees.')
"""
def __init__(self, message):
super().__init__(message, status=409)
[docs]
class Forbidden(MatchingError):
"""
Raised when a user attempts an action they are not authorized to perform.
Specialization of :exc:`MatchingError` that always sets ``status=403``.
:param message: Human-readable description of the authorization failure.
:type message: str
Example::
raise Forbidden('You do not have permission to perform this action.')
"""
def __init__(self, message):
super().__init__(message, status=403)
[docs]
def get_ranked_matches(mentee):
"""
Return a ranked list of compatible mentors for the given mentee.
Queries all :class:`~matching.models.Score` rows for the mentee, resolves
each to a :class:`~users.models.User`, and filters out:
- Mentors on the mentee's ``blacklisted_mentors`` list.
- Mentors whose ``current_mentees`` roster is already at capacity
(``>= MAX_MENTEES``).
- Score rows whose ``mentor_email`` no longer corresponds to an existing
user (silently skipped).
The remaining mentors are sorted **primary** by score descending,
**secondary** by ``date_joined`` ascending as a tiebreaker (earlier
joiners are preferred).
:param mentee: The mentee user for whom matches are being retrieved.
:type mentee: users.models.User
:returns: List of serialized mentor dicts, each including a ``score``
field, ordered from best to worst match.
:rtype: list[dict]
Example::
>>> matches = get_ranked_matches(mentee_user)
>>> matches[0]['score']
0.92
"""
blacklist = mentee.blacklisted_mentors or []
scores = Score.objects.filter(mentee_email=mentee.email).order_by('-score')
matches = []
for score_obj in scores:
try:
mentor = User.objects.get(email=score_obj.mentor_email)
except User.DoesNotExist:
continue
if mentor.email in blacklist:
continue
if len(mentor.current_mentees) >= MAX_MENTEES:
continue
matches.append(serialize_mentor(mentor, score=score_obj.score))
matches.sort(key=lambda x: (-x['score'], x['date_joined']))
return matches
[docs]
def select_mentor(mentee, mentor_email):
"""
Match a mentee with their chosen mentor.
Validates that the mentee is not already matched, that the target user
exists and holds the ``MENTOR`` role, and that the mentor's roster is not
full. On success, updates ``isMatched`` and ``matchedMentorEmail`` on the
mentee and appends the mentee's email to the mentor's ``current_mentees``.
:param mentee: The mentee initiating the match request.
:type mentee: users.models.User
:param mentor_email: Email address of the mentor to match with.
:type mentor_email: str
:returns: The mentor ``User`` instance after the match is recorded.
:rtype: users.models.User
:raises MatchingError: If the mentee is already matched (HTTP 400), or if
the resolved user is not a mentor (HTTP 400).
:raises NotFound: If no user exists for ``mentor_email`` (HTTP 404).
:raises Conflict: If the mentor's roster is already at capacity (HTTP 409).
Example::
>>> mentor = select_mentor(mentee_user, 'mentor@ucla.edu')
"""
if mentee.isMatched:
raise MatchingError('You are already matched with a mentor')
mentor = _get_user_or_raise(mentor_email, 'Mentor')
if mentor.role != 'MENTOR':
raise MatchingError('Selected user is not a mentor')
if len(mentor.current_mentees) >= MAX_MENTEES:
raise Conflict('This mentor already has two mentees.')
mentee.isMatched = True
mentee.matchedMentorEmail = mentor.email
mentee.save()
mentor.current_mentees.append(mentee.email)
mentor.save()
return mentor
[docs]
def mentee_unmatch(mentee, mentor_email):
"""
Remove the match between a mentee and their current mentor.
Performs the following operations in order:
1. Verifies the mentor exists and is the mentee's current match.
2. Clears ``isMatched`` and ``matchedMentorEmail`` on the mentee.
3. Removes the mentee from the mentor's ``current_mentees`` roster.
4. Blacklists the mentor on the mentee's account via
:meth:`~users.models.User.blacklist_mentor` to prevent re-matching.
5. Hard-deletes all chat messages between the pair via
:func:`_delete_messages_between`.
:param mentee: The mentee requesting the unmatch.
:type mentee: users.models.User
:param mentor_email: Email address of the mentor to unmatch from.
:type mentor_email: str
:raises MatchingError: If the mentee is not currently matched with the
specified mentor (HTTP 400).
:raises NotFound: If no user exists for ``mentor_email`` (HTTP 404).
Example::
>>> mentee_unmatch(mentee_user, 'mentor@ucla.edu')
"""
mentor = _get_user_or_raise(mentor_email, 'Mentor')
if mentee.matchedMentorEmail != mentor.email:
raise MatchingError('You are not matched with this mentor')
mentee.isMatched = False
mentee.matchedMentorEmail = ''
mentee.save()
if mentee.email in mentor.current_mentees:
mentor.current_mentees.remove(mentee.email)
mentor.save()
mentee.blacklist_mentor(mentor.email)
_delete_messages_between(mentee.email, mentor.email)
[docs]
def mentor_unmatch(mentor, mentee_email):
"""
Remove a specific mentee from a mentor's active roster.
Performs the following operations in order:
1. Verifies the mentee exists and is on the mentor's current roster.
2. Delegates roster cleanup to :meth:`~users.models.User.remove_mentee`,
which handles the database update and in-memory refresh internally.
3. Clears ``isMatched`` and ``matchedMentorEmail`` on the mentee.
4. Blacklists the mentor on the mentee's account via
:meth:`~users.models.User.blacklist_mentor` to prevent re-matching.
5. Hard-deletes all chat messages between the pair via
:func:`_delete_messages_between`.
:param mentor: The mentor initiating the unmatch.
:type mentor: users.models.User
:param mentee_email: Email address of the mentee to remove.
:type mentee_email: str
:raises MatchingError: If the specified mentee is not on the mentor's
current roster (HTTP 400).
:raises NotFound: If no user exists for ``mentee_email`` (HTTP 404).
Example::
>>> mentor_unmatch(mentor_user, 'mentee@g.ucla.edu')
"""
mentee = _get_user_or_raise(mentee_email, 'Mentee')
if mentee_email not in (mentor.current_mentees or []):
raise MatchingError('You are not matched with this mentee')
mentor.remove_mentee(mentee_email)
mentee.isMatched = False
mentee.matchedMentorEmail = ''
mentee.save()
mentee.blacklist_mentor(mentor.email)
_delete_messages_between(mentee_email, mentor.email)
def _delete_messages_between(email1, email2):
"""
Hard-delete all chat messages exchanged between two users.
Issues a single bulk ``DELETE`` query covering both directions of the
conversation (``email1 → email2`` and ``email2 → email1``). Called as
a cleanup step during unmatch operations to ensure no chat history
persists after a match is dissolved.
:param email1: Email address of one participant.
:type email1: str
:param email2: Email address of the other participant.
:type email2: str
.. note::
This is an internal helper. Call via :func:`mentee_unmatch` or
:func:`mentor_unmatch` rather than directly.
"""
Message.objects.filter(
models.Q(sender_email=email1, receiver_email=email2) |
models.Q(sender_email=email2, receiver_email=email1)
).delete()
def _get_user_or_raise(email, label='User'):
"""
Fetch a ``User`` by email or raise :exc:`NotFound`.
Centralizes the common ``User.objects.get`` / ``DoesNotExist`` pattern
used throughout the matching service layer, keeping call sites concise
and ensuring a consistent HTTP 404 error shape across all operations.
:param email: The email address to look up.
:type email: str
:param label: Human-readable name for the resource type, used in the
error message. Defaults to ``'User'``.
:type label: str
:returns: The ``User`` instance matching ``email``.
:rtype: users.models.User
:raises NotFound: If no ``User`` exists for the given ``email`` (HTTP 404).
.. note::
This is an internal helper; call via service functions rather than
directly from views.
Example::
>>> mentor = _get_user_or_raise('mentor@ucla.edu', 'Mentor')
"""
try:
return User.objects.get(email=email)
except User.DoesNotExist:
raise NotFound(f'{label} not found')