Source code for matching.services

# 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')