Source code for users.services

# business logic for user auth and account management
import logging

from django.conf import settings
from django.contrib.auth import authenticate as django_authenticate
from django.utils import timezone

from .email_utils import send_verification_email
from .models import User

logger = logging.getLogger(__name__)


[docs] class UserServiceError(Exception): """ Base exception for all user service 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 UserServiceError('Something went wrong', status=400) """ def __init__(self, message, status=400): super().__init__(message) self.status = status
[docs] class NotFound(UserServiceError): """ Raised when a requested user account cannot be found. Specialization of :exc:`UserServiceError` that always sets ``status=404``. :param message: Human-readable description of the missing resource. Defaults to ``'User not found'``. :type message: str Example:: raise NotFound('User not found') """ def __init__(self, message='User not found'): super().__init__(message, status=404)
[docs] class Conflict(UserServiceError): """ Raised when a user operation conflicts with the current system state. Specialization of :exc:`UserServiceError` that always sets ``status=409``. Typical use case: attempting to register with an email address that is already in use. :param message: Human-readable description of the conflict. :type message: str Example:: raise Conflict('Email already exists') """ def __init__(self, message): super().__init__(message, status=409)
[docs] class Unauthorized(UserServiceError): """ Raised when authentication credentials are missing or invalid. Specialization of :exc:`UserServiceError` that always sets ``status=401``. :param message: Human-readable description of the auth failure. Defaults to ``'Invalid credentials'``. :type message: str Example:: raise Unauthorized() """ def __init__(self, message='Invalid credentials'): super().__init__(message, status=401)
[docs] class Forbidden(UserServiceError): """ Raised when an authenticated user attempts an action they are not permitted to perform. Specialization of :exc:`UserServiceError` that always sets ``status=403``. :param message: Human-readable description of the authorization failure. :type message: str Example:: raise Forbidden('Email not verified') """ def __init__(self, message): super().__init__(message, status=403)
[docs] def register_user(email, password, role='MENTEE'): """ Create a new user account and dispatch a verification email. Handles the following edge cases before creating the account: - If an account with ``email`` already exists and its grace period has **expired**, it is hard-deleted and registration proceeds. - If an account exists and is within its grace period (or is not soft-deleted at all), a :exc:`Conflict` is raised. In **test mode** (``settings.SEND_EMAILS = False``) the account is automatically marked as verified and no email is sent. In production, a verification token is generated and dispatched via :func:`~users.email_utils.send_verification_email`. A failed email send is logged as a warning but does not prevent the account from being created. :param email: UCLA email address for the new account. :type email: str :param password: Plain-text password; hashed before storage. :type password: str :param role: Initial role for the account. Defaults to ``'MENTEE'``. :type role: str :returns: The newly created :class:`~users.models.User` instance. :rtype: users.models.User :raises Conflict: If a non-expired account already exists for ``email`` (HTTP 409). :raises ValueError: Propagated from :meth:`~users.models.UserManager.create_user` if ``email`` is not a valid UCLA address. Example:: >>> user = register_user('jdoe@ucla.edu', 'secret123', role='MENTOR') """ try: user = User.objects.get(email=email) if user.is_deleted and user.permanent_deletion_date: if timezone.now() > user.permanent_deletion_date: user.delete() else: raise Conflict('Email already exists') else: raise Conflict('Email already exists') except User.DoesNotExist: pass user = User.objects.create_user(email=email, password=password, role=role) if not settings.SEND_EMAILS: User.objects.filter(email=user.email).update(is_verified=True) user.refresh_from_db() logger.info(f"TEST MODE: Auto-verified user: {email}") return user user.generate_verification_token() email_sent = send_verification_email(user) if not email_sent: logger.warning(f"Failed to send verification email to {email}.") return user
[docs] def authenticate_user(request, email, password): """ Verify credentials and return the authenticated user. Delegates to Django's ``authenticate()`` backend, then enforces an additional email-verification check. Does not open a session — callers are responsible for calling ``login()`` if a session is required. :param request: The current HTTP request, forwarded to Django's auth backend. :type request: django.http.HttpRequest :param email: The user's UCLA email address. :type email: str :param password: The user's plain-text password. :type password: str :returns: The authenticated :class:`~users.models.User` instance. :rtype: users.models.User :raises Unauthorized: If Django's auth backend returns ``None``, indicating invalid credentials (HTTP 401). :raises Forbidden: If the account exists but the email address has not yet been verified (HTTP 403). Example:: >>> user = authenticate_user(request, 'jdoe@ucla.edu', 'secret123') """ user = django_authenticate(request, email=email, password=password) if user is None: raise Unauthorized() if not user.is_verified: raise Forbidden("Email not verified") return user
[docs] def complete_survey(user, survey_data): """ Apply onboarding survey responses to a user's profile. Thin delegation to :meth:`~users.models.User.complete_survey`, which enforces the field whitelist and role-safety rules defined on the model. :param user: The user completing the survey. :type user: users.models.User :param survey_data: Dictionary of survey field names to their submitted values. :type survey_data: dict :raises ValidationError: Propagated from the model if any submitted value fails field-level validation. Example:: >>> complete_survey(user, {'firstName': 'Jane', 'year': 1}) """ user.complete_survey(survey_data)
[docs] def request_deletion(user, confirmation_text): """ Initiate a soft-delete of the user's account after confirming intent. Requires the caller to supply the user's own email address as ``confirmation_text``. On success, delegates to :meth:`~users.models.User.request_deletion`, which sets the grace period and cleans up matches, scores, and chat messages. :param user: The user requesting deletion. :type user: users.models.User :param confirmation_text: Text submitted by the user to confirm intent. Must exactly match ``user.email``. :type confirmation_text: str :raises UserServiceError: If ``confirmation_text`` does not match ``user.email`` (HTTP 400). Example:: >>> request_deletion(user, 'jdoe@ucla.edu') """ if confirmation_text != user.email: raise UserServiceError( f'Please type "{user.email}" to confirm deletion', status=400, ) user.request_deletion()
[docs] def cancel_deletion(user): """ Cancel a pending account deletion for an already-authenticated user. Verifies that a deletion is actually pending before delegating to :meth:`~users.models.User.cancel_deletion`. :param user: The authenticated user cancelling their deletion request. :type user: users.models.User :raises UserServiceError: If the account is not currently pending deletion (HTTP 400), or if the grace period has already elapsed (HTTP 409). Example:: >>> cancel_deletion(request.user) """ if not user.is_deleted: raise UserServiceError('No deletion pending', status=400) if not user.cancel_deletion(): raise UserServiceError('Grace period expired', status=409)
[docs] def cancel_deletion_by_credentials(request, email, password): """ Authenticate by credentials and cancel a pending account deletion. Intended for users who have been logged out but want to recover their account within the grace period. Authenticates via :func:`authenticate_user` then applies the same deletion-state checks as :func:`cancel_deletion`. :param request: The current HTTP request, forwarded to the auth backend. :type request: django.http.HttpRequest :param email: The user's UCLA email address. :type email: str :param password: The user's plain-text password. :type password: str :returns: The recovered :class:`~users.models.User` instance with deletion fields cleared. :rtype: users.models.User :raises Unauthorized: If credentials are invalid (HTTP 401). :raises Forbidden: If the email address is unverified (HTTP 403). :raises UserServiceError: If no deletion is pending (HTTP 400), or if the grace period has already elapsed (HTTP 409). Example:: >>> user = cancel_deletion_by_credentials(request, 'jdoe@ucla.edu', 'secret123') """ user = authenticate_user(request, email, password) if not user.is_deleted: raise UserServiceError('No deletion pending', status=400) if not user.cancel_deletion(): raise UserServiceError('Grace period expired', status=409) return user