Source code for users.models

import logging
import uuid
from datetime import timedelta

from django.conf import settings
from django.contrib.auth.models import (AbstractBaseUser, BaseUserManager,
                                        PermissionsMixin)
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils import timezone

from chat.models import Message

logger = logging.getLogger(__name__)

MAX_MENTEES = 2
UCLA_DOMAINS = ('@ucla.edu', '@g.ucla.edu')


def _is_valid_ucla_email(email):
    """
    Check whether an email address belongs to a recognized UCLA domain.

    :param email: The email address to validate.
    :type email: str
    :returns: ``True`` if the email ends with a UCLA domain, ``False`` otherwise.
    :rtype: bool

    Example::

        >>> _is_valid_ucla_email('jdoe@ucla.edu')
        True
        >>> _is_valid_ucla_email('jdoe@gmail.com')
        False
    """
    return any(email.endswith(domain) for domain in UCLA_DOMAINS)


[docs] class UserManager(BaseUserManager): """ Custom manager for the :class:`User` model. Provides helper methods for creating standard users and superusers, enforcing UCLA email validation and normalization on all accounts. """
[docs] def create_user(self, email, password=None, **extra_fields): """ Create and persist a standard (non-superuser) ``User`` instance. Normalizes the email to lowercase and strips whitespace before saving. Raises ``ValueError`` if the email is missing or not a valid UCLA address. :param email: The user's UCLA email address. :type email: str :param password: The user's plain-text password. Hashed before storage. :type password: str, optional :param extra_fields: Additional model field values (e.g. ``firstName``, ``role``). :returns: The newly created and saved ``User`` instance. :rtype: User :raises ValueError: If ``email`` is empty or not a recognized UCLA domain. Example:: >>> user = User.objects.create_user('jdoe@ucla.edu', 'secret123') """ if not email: raise ValueError('Users must have an email address') email = self.normalize_email(email.strip().lower()) if not _is_valid_ucla_email(email): raise ValueError('Please use a valid UCLA email address') user = self.model(email=email, **extra_fields) user.set_password(password) user.save(using=self._db) return user
[docs] def create_superuser(self, email, password=None, **extra_fields): """ Create and persist a superuser ``User`` instance with admin privileges. Defaults ``is_staff``, ``is_superuser``, and ``role`` to their administrative values before delegating to :meth:`create_user`. :param email: The superuser's UCLA email address. :type email: str :param password: The superuser's plain-text password. :type password: str, optional :param extra_fields: Additional model field overrides. :returns: The newly created superuser ``User`` instance. :rtype: User :raises ValueError: If ``email`` is empty or not a recognized UCLA domain. """ extra_fields.setdefault('is_staff', True) extra_fields.setdefault('is_superuser', True) extra_fields.setdefault('role', 'ADMIN') return self.create_user(email, password, **extra_fields)
[docs] class User(AbstractBaseUser, PermissionsMixin): """ Custom user model for the BruinBridge platform. Replaces Django's default ``User`` with a UCLA-email-gated account that supports mentor/mentee matching, soft deletion, email verification, and password reset workflows. Authentication is performed via ``email`` instead of ``username``. Attributes: userID (UUIDField): Immutable public identifier for the user. email (EmailField): Unique UCLA email address; used as the login credential. firstName (CharField): User's given name. lastName (CharField): User's family name. role (CharField): One of ``MENTEE``, ``MENTOR``, or ``ADMIN`` (see :class:`Role`). profilePictureUrl (URLField): URL pointing to the user's avatar image. year (IntegerField): Academic year (0–5). major (JSONField): List of declared majors. minor (JSONField): List of declared minors. international (BooleanField): Flag for international student status. commuter (BooleanField): Flag for commuter status. firstgen (BooleanField): Flag for first-generation college student status. outofstate (BooleanField): Flag for out-of-state student status. transfer (BooleanField): Flag for transfer student status. otherBackground (CharField): Free-text description of additional background. hobbies (CharField): User-provided list of hobbies. clubs (CharField): User-provided list of club memberships. goals (CharField): User-provided academic or career goals. isMatched (BooleanField): Whether the user currently has an active match. matchedMentorEmail (EmailField): Email of the mentor currently matched to this mentee. blacklisted_mentors (JSONField): List of mentor emails the mentee has blocked. current_mentees (JSONField): List of mentee emails currently assigned to this mentor. is_deleted (BooleanField): Soft-deletion flag; account is hidden but not yet purged. deletion_requested_date (DateTimeField): When the soft-deletion was requested. permanent_deletion_date (DateTimeField): When the account will be hard-deleted. is_verified (BooleanField): Whether the user's email address has been verified. email_verification_token (CharField): Active one-time email verification token. email_verification_sent_at (DateTimeField): When the verification email was dispatched. password_reset_token (CharField): Active one-time password reset token. password_reset_sent_at (DateTimeField): When the password reset email was dispatched. is_active (BooleanField): Django internal; ``False`` disables login. is_staff (BooleanField): Django internal; grants admin-site access. date_joined (DateTimeField): Timestamp of account creation. """
[docs] class Role(models.TextChoices): """ Enumeration of valid user roles on the platform. :cvar MENTEE: A student seeking guidance from a mentor. :cvar MENTOR: A student providing guidance to mentees. :cvar ADMIN: A platform administrator with elevated privileges. """ MENTEE = 'MENTEE', 'Mentee' MENTOR = 'MENTOR', 'Mentor' ADMIN = 'ADMIN', 'Admin'
# Core identity userID = models.UUIDField(default=uuid.uuid4, editable=False, unique=True) email = models.EmailField(unique=True, max_length=255) firstName = models.CharField(max_length=255, blank=True, default='') lastName = models.CharField(max_length=255, blank=True, default='') role = models.CharField( max_length=10, choices=Role.choices, default=Role.MENTEE ) profilePictureUrl = models.URLField(blank=True, default="") year = models.IntegerField( default=0, validators=[MinValueValidator(0), MaxValueValidator(5)] ) major = models.JSONField(default=list, blank=True) minor = models.JSONField(default=list, blank=True) # Background flags international = models.BooleanField(default=False) commuter = models.BooleanField(default=False) firstgen = models.BooleanField(default=False) outofstate = models.BooleanField(default=False) transfer = models.BooleanField(default=False) otherBackground = models.CharField(max_length=1000, blank=True, default='') # Profile text hobbies = models.CharField(max_length=1000, blank=True, default='') clubs = models.CharField(max_length=1000, blank=True, default='') goals = models.CharField(max_length=1000, blank=True, default='') # Matching — mentee side isMatched = models.BooleanField(default=False) matchedMentorEmail = models.EmailField(max_length=255, blank=True) blacklisted_mentors = models.JSONField(default=list, blank=True) # Matching — mentor side current_mentees = models.JSONField(default=list, blank=True) # Soft deletion is_deleted = models.BooleanField(default=False) deletion_requested_date = models.DateTimeField(null=True, blank=True) permanent_deletion_date = models.DateTimeField(null=True, blank=True) # Email verification is_verified = models.BooleanField(default=False) email_verification_token = models.CharField(max_length=255, null=True, blank=True) email_verification_sent_at = models.DateTimeField(null=True, blank=True) # Password reset password_reset_token = models.CharField(max_length=255, null=True, blank=True) password_reset_sent_at = models.DateTimeField(null=True, blank=True) # Django internals is_active = models.BooleanField(default=True) is_staff = models.BooleanField(default=False) date_joined = models.DateTimeField(auto_now_add=True) objects = UserManager() USERNAME_FIELD = 'email' REQUIRED_FIELDS = [] class Meta: db_table = 'bruinbridge_users' def __str__(self): """ Return a human-readable string representation of the user. :returns: The user's email address. :rtype: str """ return self.email
[docs] def clean(self): """ Normalize and validate model fields before saving. Strips whitespace and lowercases ``email``, then asserts it belongs to a recognized UCLA domain. :raises ValidationError: If ``email`` is not a valid UCLA address. """ self.email = self.email.strip().lower() super().clean() if self.email and not _is_valid_ucla_email(self.email): raise ValidationError({'email': 'Please use a valid UCLA email address'})
[docs] def save(self, *args, **kwargs): """ Run full model validation then persist the instance. Calls :meth:`full_clean` before every save to ensure email normalization and UCLA domain constraints are always enforced. :param args: Positional arguments forwarded to ``super().save()``. :param kwargs: Keyword arguments forwarded to ``super().save()``. :raises ValidationError: If any field fails validation in :meth:`clean`. """ self.full_clean() super().save(*args, **kwargs)
#: Fields that may be set via :meth:`update_profile` or :meth:`complete_survey`. #: Any field absent from this tuple will be silently ignored, protecting #: sensitive fields (e.g. ``email``, ``password``) from bulk updates. SURVEY_FIELDS = ( 'firstName', 'lastName', 'year', 'major', 'minor', 'hobbies', 'clubs', 'goals', 'otherBackground', 'international', 'commuter', 'firstgen', 'outofstate', 'transfer', 'profilePictureUrl', ) #: Roles that a user is permitted to self-assign. ``ADMIN`` is intentionally excluded. SAFE_ROLES = {Role.MENTOR, Role.MENTEE}
[docs] def update_profile(self, **kwargs): """ Update permitted profile fields with the supplied values. Only fields listed in :attr:`SURVEY_FIELDS` are written. Role changes are accepted only for :attr:`SAFE_ROLES` (``MENTOR`` / ``MENTEE``); any attempt to assign ``ADMIN`` is silently dropped and logged as a warning. :param kwargs: Mapping of field names to new values. :raises ValidationError: Propagated from :meth:`save` if a written value fails model-level validation. Example:: >>> user.update_profile(firstName='Jane', year=2) """ for key, value in kwargs.items(): if key == 'role': if value not in self.SAFE_ROLES: logger.warning(f"Blocked attempt to set role to {value} from user {self.email}") continue if key in self.SURVEY_FIELDS or key == 'role': setattr(self, key, value) self.save()
[docs] def complete_survey(self, survey_data): """ Persist the user's initial onboarding survey responses. Delegates entirely to :meth:`update_profile`, applying the same field whitelist and role-safety rules. :param survey_data: Dictionary of survey field names to their submitted values. :type survey_data: dict :raises ValidationError: Propagated from :meth:`update_profile` if a value fails model-level validation. .. note:: This method may be redundant if :meth:`update_profile` already satisfies all survey update requirements. """ self.update_profile(**survey_data)
[docs] def change_role(self, new_role): """ Switch the user's role between ``MENTEE`` and ``MENTOR``. Before applying the role change, all existing matches, compatibility scores, and chat messages are cleaned up to prevent stale data from persisting across roles. :param new_role: The target role. Must be ``'MENTEE'`` or ``'MENTOR'``. :type new_role: str :raises ValueError: If ``new_role`` is not ``MENTEE`` or ``MENTOR``, or if the user already holds ``new_role``. Example:: >>> user.change_role('MENTOR') """ if new_role not in (self.Role.MENTEE, self.Role.MENTOR): raise ValueError('Role must be MENTEE or MENTOR') if new_role == self.role: raise ValueError('You already have this role') self._cleanup_matches_before_deletion() self._delete_scores() self._delete_chat_messages() User.objects.filter(email=self.email).update(role=new_role) self.refresh_from_db()
[docs] def blacklist_mentor(self, mentor_email): """ Add a mentor's email to this mentee's blacklist. Prevents the blacklisted mentor from being matched to this mentee in future matching runs. Does nothing if the mentor is already blacklisted. :param mentor_email: The email address of the mentor to blacklist. :type mentor_email: str Example:: >>> mentee.blacklist_mentor('mentor@ucla.edu') """ if self.blacklisted_mentors is None: self.blacklisted_mentors = [] if mentor_email not in self.blacklisted_mentors: self.blacklisted_mentors.append(mentor_email) User.objects.filter(email=self.email).update( blacklisted_mentors=self.blacklisted_mentors ) self.refresh_from_db()
[docs] def add_mentee(self, mentee_email): """ Assign a mentee to this mentor's active roster. Performs the following checks before adding: - Caller must have the ``MENTOR`` role. - Mentor roster must not already be at capacity (``MAX_MENTEES``). - Mentee must not already be on the roster. - Mentee must exist in the database. - This mentor must not be on the mentee's blacklist. On success, updates ``isMatched`` for both parties. The mentor's ``isMatched`` flag is set to ``True`` only when the roster reaches full capacity. :param mentee_email: Email address of the mentee to add. :type mentee_email: str :returns: ``True`` if the mentee was successfully added, ``False`` if any pre-condition was not met. :rtype: bool Example:: >>> mentor.add_mentee('mentee@g.ucla.edu') True """ if self.role != 'MENTOR': return False if self.current_mentees is None: self.current_mentees = [] if len(self.current_mentees) >= MAX_MENTEES: return False if mentee_email in self.current_mentees: return False try: mentee = User.objects.get(email=mentee_email) except User.DoesNotExist: return False if self.email in (mentee.blacklisted_mentors or []): return False self.current_mentees.append(mentee_email) is_full = len(self.current_mentees) >= MAX_MENTEES User.objects.filter(email=self.email).update( current_mentees=self.current_mentees, isMatched=is_full, ) User.objects.filter(email=mentee_email).update(isMatched=True) self.refresh_from_db() return True
[docs] def remove_mentee(self, mentee_email): """ Remove a mentee from this mentor's active roster. Clears ``isMatched`` for both the mentor and the removed mentee. Does nothing if ``mentee_email`` is not currently on the roster. :param mentee_email: Email address of the mentee to remove. :type mentee_email: str Example:: >>> mentor.remove_mentee('mentee@g.ucla.edu') """ if self.current_mentees is None: self.current_mentees = [] if mentee_email not in self.current_mentees: return self.current_mentees.remove(mentee_email) User.objects.filter(email=self.email).update( current_mentees=self.current_mentees, isMatched=False, ) User.objects.filter(email=mentee_email).update(isMatched=False) self.refresh_from_db()
[docs] def request_deletion(self): """ Initiate a soft-delete of the account with a configurable grace period. Sets ``is_deleted`` to ``True`` and schedules ``permanent_deletion_date`` as ``now + ACCOUNT_RECOVERY_GRACE_PERIOD`` hours (sourced from ``settings.ACCOUNT_RECOVERY_GRACE_PERIOD``). Also unlinks all active matches, removes compatibility scores, and deletes chat history. The account remains recoverable via :meth:`cancel_deletion` until ``permanent_deletion_date`` is reached. """ now = timezone.now() grace_period = timedelta(hours=settings.ACCOUNT_RECOVERY_GRACE_PERIOD) self.is_deleted = True self.deletion_requested_date = now self.permanent_deletion_date = now + grace_period self._cleanup_matches_before_deletion() self._delete_scores() self._delete_chat_messages() User.objects.filter(email=self.email).update( is_deleted=True, deletion_requested_date=self.deletion_requested_date, permanent_deletion_date=self.permanent_deletion_date, ) self.refresh_from_db()
[docs] def cancel_deletion(self): """ Restore a soft-deleted account within the grace period. Clears ``is_deleted``, ``deletion_requested_date``, and ``permanent_deletion_date``. Returns ``False`` without making changes if the grace period has already elapsed. :returns: ``True`` if the account was successfully restored, ``False`` if the grace period has expired. :rtype: bool Example:: >>> user.cancel_deletion() True """ if self.permanent_deletion_date and timezone.now() > self.permanent_deletion_date: return False User.objects.filter(email=self.email).update( is_deleted=False, deletion_requested_date=None, permanent_deletion_date=None, ) self.refresh_from_db() return True
def _cleanup_matches_before_deletion(self): """ Remove all matching relationships for this user prior to deletion or role change. **For mentees:** removes this mentee from every mentor's roster and clears ``matchedMentorEmail`` / ``isMatched`` on the mentee record. **For mentors:** removes this mentor from the blacklists of all mentees, unmatches every currently assigned mentee, and empties ``current_mentees`` on this mentor record. .. note:: This is an internal helper; call via :meth:`request_deletion` or :meth:`change_role` rather than directly. """ if self.role == 'MENTEE': mentors = User.objects.filter(role='MENTOR') for mentor in mentors: if self.email in (mentor.current_mentees or []): mentor.remove_mentee(self.email) User.objects.filter(email=self.email).update( matchedMentorEmail='', isMatched=False ) elif self.role == 'MENTOR': mentees_with_blacklist = User.objects.filter(role='MENTEE') for mentee in mentees_with_blacklist: if self.email in (mentee.blacklisted_mentors or []): new_blacklist = [email for email in mentee.blacklisted_mentors if email != self.email] User.objects.filter(email=mentee.email).update( blacklisted_mentors=new_blacklist ) for mentee_email in list(self.current_mentees or []): try: mentee = User.objects.get(email=mentee_email) if mentee.matchedMentorEmail == self.email: User.objects.filter(email=mentee_email).update( matchedMentorEmail='', isMatched=False, ) self.remove_mentee(mentee_email) except User.DoesNotExist: continue def _delete_scores(self): """ Hard-delete all ``Score`` records associated with this user. Removes rows from ``matching.Score`` where this user appears as either ``mentee_email`` or ``mentor_email``. .. note:: This is an internal helper; call via :meth:`request_deletion` or :meth:`change_role` rather than directly. """ from matching.models import Score Score.objects.filter( models.Q(mentee_email=self.email) | models.Q(mentor_email=self.email) ).delete() def _delete_chat_messages(self): """ Hard-delete all ``Message`` records sent or received by this user. Removes rows from ``chat.Message`` where this user appears as either ``sender_email`` or ``receiver_email``. .. note:: This is an internal helper; call via :meth:`request_deletion` or :meth:`change_role` rather than directly. """ Message.objects.filter( models.Q(sender_email=self.email) | models.Q(receiver_email=self.email) ).delete()
[docs] @staticmethod def permanently_delete_expired_accounts(): """ Hard-delete all accounts whose grace period has elapsed. Queries for ``User`` records where ``is_deleted=True`` and ``permanent_deletion_date`` is in the past, marks each as ``is_active=False``, then performs a hard database delete. Intended to be called periodically by a scheduled task (e.g. a Celery beat job or management command). :returns: The number of accounts permanently deleted. :rtype: int Example:: >>> deleted = User.permanently_delete_expired_accounts() >>> print(f'{deleted} accounts purged.') """ expired_users = User.objects.filter( is_deleted=True, permanent_deletion_date__lte=timezone.now() ) deleted_count = 0 for user in expired_users: User.objects.filter(email=user.email).update(is_active=False) user.delete() deleted_count += 1 return deleted_count
[docs] def generate_verification_token(self): """ Generate, persist, and return a fresh email verification token. Creates a UUID4 token, records the current timestamp as ``email_verification_sent_at``, and writes both fields to the database without triggering a full model save. :returns: The newly generated verification token string. :rtype: str Example:: >>> token = user.generate_verification_token() >>> send_verification_email(user.email, token) """ self.email_verification_token = str(uuid.uuid4()) self.email_verification_sent_at = timezone.now() User.objects.filter(email=self.email).update( email_verification_token=self.email_verification_token, email_verification_sent_at=self.email_verification_sent_at ) self.refresh_from_db() return self.email_verification_token
[docs] def verify_email(self, token): """ Validate an email verification token and mark the account as verified. Checks that ``token`` matches ``email_verification_token`` and that the token was issued within the window defined by ``settings.EMAIL_VERIFICATION_TOKEN_EXPIRATION`` (hours). On success, sets ``is_verified=True`` and clears both token fields. :param token: The verification token submitted by the user. :type token: str :returns: ``True`` if verification succeeded, ``False`` if the token was missing, mismatched, or expired. :rtype: bool Example:: >>> user.verify_email('3f2e1a...') True """ if self.email_verification_token != token: logger.info(f"Failed verification for user {self.email}: token mismatch") return False if not self.email_verification_sent_at: return False expiration_time = timedelta(hours=settings.EMAIL_VERIFICATION_TOKEN_EXPIRATION) if timezone.now() > self.email_verification_sent_at + expiration_time: logger.info(f"Verification token expired for user {self.email}") return False User.objects.filter(email=self.email).update( is_verified=True, email_verification_token=None, email_verification_sent_at=None ) self.refresh_from_db() return True
[docs] def generate_password_reset_token(self): """ Generate, persist, and return a fresh password reset token. Creates a UUID4 token, records the current timestamp as ``password_reset_sent_at``, and writes both fields to the database without triggering a full model save. :returns: The newly generated password reset token string. :rtype: str Example:: >>> token = user.generate_password_reset_token() >>> send_reset_email(user.email, token) """ self.password_reset_token = str(uuid.uuid4()) self.password_reset_sent_at = timezone.now() User.objects.filter(email=self.email).update( password_reset_token=self.password_reset_token, password_reset_sent_at=self.password_reset_sent_at ) self.refresh_from_db() return self.password_reset_token
[docs] def reset_password_with_token(self, token, new_password): """ Validate a password reset token and apply a new password. Checks that ``token`` matches ``password_reset_token`` and that the token was issued within the window defined by ``settings.PASSWORD_RESET_TOKEN_EXPIRATION`` (hours). On success, hashes and saves ``new_password``, then clears both token fields. :param token: The password reset token submitted by the user. :type token: str :param new_password: The new plain-text password to set. :type new_password: str :returns: ``True`` if the password was successfully reset, ``False`` if the token was missing, mismatched, or expired. :rtype: bool Example:: >>> user.reset_password_with_token('a1b2c3...', 'newSecurePass!') True """ if self.password_reset_token != token: logger.info(f"Failed password reset for user {self.email}: token mismatch") return False if not self.password_reset_sent_at: return False expiration_time = timedelta(hours=settings.PASSWORD_RESET_TOKEN_EXPIRATION) if timezone.now() > self.password_reset_sent_at + expiration_time: logger.info(f"Password reset token expired for user {self.email}") return False self.set_password(new_password) User.objects.filter(email=self.email).update( password_reset_token=None, password_reset_sent_at=None ) self.save() return True