# 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