Merge pull request #5383 from edx/will/account-and-profile-rebase
Add Django apps for student account and profile
This commit is contained in:
@@ -26,7 +26,7 @@ from django.utils import timezone
|
||||
from django.contrib.auth.models import User
|
||||
from django.contrib.auth.hashers import make_password
|
||||
from django.contrib.auth.signals import user_logged_in, user_logged_out
|
||||
from django.db import models, IntegrityError
|
||||
from django.db import models, IntegrityError, transaction
|
||||
from django.db.models import Count
|
||||
from django.dispatch import receiver, Signal
|
||||
from django.core.exceptions import ObjectDoesNotExist
|
||||
@@ -278,6 +278,59 @@ class UserProfile(models.Model):
|
||||
self.set_meta(meta)
|
||||
self.save()
|
||||
|
||||
@transaction.commit_on_success
|
||||
def update_name(self, new_name):
|
||||
"""Update the user's name, storing the old name in the history.
|
||||
|
||||
Implicitly saves the model.
|
||||
If the new name is not the same as the old name, do nothing.
|
||||
|
||||
Arguments:
|
||||
new_name (unicode): The new full name for the user.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
if self.name == new_name:
|
||||
return
|
||||
|
||||
if self.name:
|
||||
meta = self.get_meta()
|
||||
if 'old_names' not in meta:
|
||||
meta['old_names'] = []
|
||||
meta['old_names'].append([self.name, u"", datetime.now(UTC).isoformat()])
|
||||
self.set_meta(meta)
|
||||
|
||||
self.name = new_name
|
||||
self.save()
|
||||
|
||||
@transaction.commit_on_success
|
||||
def update_email(self, new_email):
|
||||
"""Update the user's email and save the change in the history.
|
||||
|
||||
Implicitly saves the model.
|
||||
If the new email is the same as the old email, do not update the history.
|
||||
|
||||
Arguments:
|
||||
new_email (unicode): The new email for the user.
|
||||
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
if self.user.email == new_email:
|
||||
return
|
||||
|
||||
meta = self.get_meta()
|
||||
if 'old_emails' not in meta:
|
||||
meta['old_emails'] = []
|
||||
meta['old_emails'].append([self.user.email, datetime.now(UTC).isoformat()])
|
||||
self.set_meta(meta)
|
||||
self.save()
|
||||
|
||||
self.user.email = new_email
|
||||
self.user.save()
|
||||
|
||||
|
||||
class UserSignupSource(models.Model):
|
||||
"""
|
||||
@@ -342,6 +395,23 @@ class PendingEmailChange(models.Model):
|
||||
new_email = models.CharField(blank=True, max_length=255, db_index=True)
|
||||
activation_key = models.CharField(('activation key'), max_length=32, unique=True, db_index=True)
|
||||
|
||||
def request_change(self, email):
|
||||
"""Request a change to a user's email.
|
||||
|
||||
Implicitly saves the pending email change record.
|
||||
|
||||
Arguments:
|
||||
email (unicode): The proposed new email for the user.
|
||||
|
||||
Returns:
|
||||
unicode: The activation code to confirm the change.
|
||||
|
||||
"""
|
||||
self.new_email = email
|
||||
self.activation_key = uuid.uuid4().hex
|
||||
self.save()
|
||||
return self.activation_key
|
||||
|
||||
|
||||
EVENT_NAME_ENROLLMENT_ACTIVATED = 'edx.course.enrollment.activated'
|
||||
EVENT_NAME_ENROLLMENT_DEACTIVATED = 'edx.course.enrollment.deactivated'
|
||||
|
||||
@@ -75,10 +75,12 @@ from . import provider
|
||||
AUTH_ENTRY_KEY = 'auth_entry'
|
||||
AUTH_ENTRY_DASHBOARD = 'dashboard'
|
||||
AUTH_ENTRY_LOGIN = 'login'
|
||||
AUTH_ENTRY_PROFILE = 'profile'
|
||||
AUTH_ENTRY_REGISTER = 'register'
|
||||
_AUTH_ENTRY_CHOICES = frozenset([
|
||||
AUTH_ENTRY_DASHBOARD,
|
||||
AUTH_ENTRY_LOGIN,
|
||||
AUTH_ENTRY_PROFILE,
|
||||
AUTH_ENTRY_REGISTER
|
||||
])
|
||||
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
|
||||
@@ -335,15 +337,17 @@ def parse_query_params(strategy, response, *args, **kwargs):
|
||||
'is_login': auth_entry == AUTH_ENTRY_LOGIN,
|
||||
# Whether the auth pipeline entered from /register.
|
||||
'is_register': auth_entry == AUTH_ENTRY_REGISTER,
|
||||
# Whether the auth pipeline entered from /profile.
|
||||
'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
@partial.partial
|
||||
def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_register=None, user=None, *args, **kwargs):
|
||||
def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboard=None, is_login=None, is_profile=None, is_register=None, user=None, *args, **kwargs):
|
||||
"""Dispatches user to views outside the pipeline if necessary."""
|
||||
|
||||
# We're deliberately verbose here to make it clear what the intended
|
||||
# dispatch behavior is for the three pipeline entry points, given the
|
||||
# dispatch behavior is for the four pipeline entry points, given the
|
||||
# current state of the pipeline. Keep in mind the pipeline is re-entrant
|
||||
# and values will change on repeated invocations (for example, the first
|
||||
# time through the login flow the user will be None so we dispatch to the
|
||||
@@ -358,7 +362,7 @@ def redirect_to_supplementary_form(strategy, details, response, uid, is_dashboar
|
||||
user_unset = user is None
|
||||
dispatch_to_login = is_login and (user_unset or user_inactive)
|
||||
|
||||
if is_dashboard:
|
||||
if is_dashboard or is_profile:
|
||||
return
|
||||
|
||||
if dispatch_to_login:
|
||||
@@ -373,7 +377,8 @@ def login_analytics(*args, **kwargs):
|
||||
|
||||
action_to_event_name = {
|
||||
'is_login': 'edx.bi.user.account.authenticated',
|
||||
'is_dashboard': 'edx.bi.user.account.linked'
|
||||
'is_dashboard': 'edx.bi.user.account.linked',
|
||||
'is_profile': 'edx.bi.user.account.linked',
|
||||
}
|
||||
|
||||
# Note: we assume only one of the `action` kwargs (is_dashboard, is_login) to be
|
||||
|
||||
@@ -51,6 +51,8 @@ _MIDDLEWARE_CLASSES = (
|
||||
'third_party_auth.middleware.ExceptionMiddleware',
|
||||
)
|
||||
_SOCIAL_AUTH_LOGIN_REDIRECT_URL = '/dashboard'
|
||||
_SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL = '/profile'
|
||||
_SOCIAL_AUTH_DISCONNECT_REDIRECT_URL = '/profile'
|
||||
|
||||
|
||||
def _merge_auth_info(django_settings, auth_info):
|
||||
@@ -95,6 +97,11 @@ def _set_global_settings(django_settings):
|
||||
# Where to send the user once social authentication is successful.
|
||||
django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL = _SOCIAL_AUTH_LOGIN_REDIRECT_URL
|
||||
|
||||
# Change redirects to the profile page if we enable the new dashboard.
|
||||
if django_settings.FEATURES.get('ENABLE_NEW_DASHBOARD', ''):
|
||||
django_settings.SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL = _SOCIAL_AUTH_NEW_ASSOCIATION_REDIRECT_URL
|
||||
django_settings.SOCIAL_AUTH_DISCONNECT_REDIRECT_URL = _SOCIAL_AUTH_DISCONNECT_REDIRECT_URL
|
||||
|
||||
# Inject our customized auth pipeline. All auth backends must work with
|
||||
# this pipeline.
|
||||
django_settings.SOCIAL_AUTH_PIPELINE = (
|
||||
|
||||
@@ -13,6 +13,7 @@ _SETTINGS_MAP = {
|
||||
'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS,
|
||||
'MIDDLEWARE_CLASSES': _ORIGINAL_MIDDLEWARE_CLASSES,
|
||||
'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS,
|
||||
'FEATURES': {},
|
||||
}
|
||||
|
||||
|
||||
|
||||
0
common/djangoapps/user_api/api/__init__.py
Normal file
0
common/djangoapps/user_api/api/__init__.py
Normal file
417
common/djangoapps/user_api/api/account.py
Normal file
417
common/djangoapps/user_api/api/account.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""Python API for user accounts.
|
||||
|
||||
Account information includes a student's username, password, and email
|
||||
address, but does NOT include user profile information (i.e., demographic
|
||||
information and preferences).
|
||||
|
||||
"""
|
||||
from django.db import transaction, IntegrityError
|
||||
from django.core.validators import validate_email, validate_slug, ValidationError
|
||||
from user_api.models import User, UserProfile, Registration, PendingEmailChange
|
||||
from user_api.helpers import intercept_errors
|
||||
|
||||
|
||||
USERNAME_MIN_LENGTH = 2
|
||||
USERNAME_MAX_LENGTH = 30
|
||||
|
||||
EMAIL_MIN_LENGTH = 3
|
||||
EMAIL_MAX_LENGTH = 254
|
||||
|
||||
PASSWORD_MIN_LENGTH = 2
|
||||
PASSWORD_MAX_LENGTH = 75
|
||||
|
||||
|
||||
class AccountRequestError(Exception):
|
||||
"""There was a problem with the request to the account API. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountInternalError(Exception):
|
||||
"""An internal error occurred in the account API. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountUserAlreadyExists(AccountRequestError):
|
||||
"""User with the same username and/or email already exists. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountUsernameAlreadyExists(AccountUserAlreadyExists):
|
||||
"""An account already exists with the requested username. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountEmailAlreadyExists(AccountUserAlreadyExists):
|
||||
"""An account already exists with the requested email. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountUsernameInvalid(AccountRequestError):
|
||||
"""The requested username is not in a valid format. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountEmailInvalid(AccountRequestError):
|
||||
"""The requested email is not in a valid format. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountPasswordInvalid(AccountRequestError):
|
||||
"""The requested password is not in a valid format. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountUserNotFound(AccountRequestError):
|
||||
"""The requested user does not exist. """
|
||||
pass
|
||||
|
||||
|
||||
class AccountNotAuthorized(AccountRequestError):
|
||||
"""The user is not authorized to perform the requested action. """
|
||||
pass
|
||||
|
||||
|
||||
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
|
||||
@transaction.commit_on_success
|
||||
def create_account(username, password, email):
|
||||
"""Create a new user account.
|
||||
|
||||
This will implicitly create an empty profile for the user.
|
||||
|
||||
WARNING: This function does NOT yet implement all the features
|
||||
in `student/views.py`. Until it does, please use this method
|
||||
ONLY for tests of the account API, not in production code.
|
||||
In particular, these are currently missing:
|
||||
|
||||
* 3rd party auth
|
||||
* External auth (shibboleth)
|
||||
* Complex password policies (ENFORCE_PASSWORD_POLICY)
|
||||
|
||||
In addition, we assume that some functionality is handled
|
||||
at higher layers:
|
||||
|
||||
* Analytics events
|
||||
* Activation email
|
||||
* Terms of service / honor code checking
|
||||
* Recording demographic info (use profile API)
|
||||
* Auto-enrollment in courses (if invited via instructor dash)
|
||||
|
||||
Args:
|
||||
username (unicode): The username for the new account.
|
||||
password (unicode): The user's password.
|
||||
email (unicode): The email address associated with the account.
|
||||
|
||||
Returns:
|
||||
unicode: an activation key for the account.
|
||||
|
||||
Raises:
|
||||
AccountUserAlreadyExists
|
||||
AccountUsernameInvalid
|
||||
AccountEmailInvalid
|
||||
AccountPasswordInvalid
|
||||
|
||||
"""
|
||||
# Validate the username, password, and email
|
||||
# This will raise an exception if any of these are not in a valid format.
|
||||
_validate_username(username)
|
||||
_validate_password(password, username)
|
||||
_validate_email(email)
|
||||
|
||||
# Create the user account, setting them to "inactive" until they activate their account.
|
||||
user = User(username=username, email=email, is_active=False)
|
||||
user.set_password(password)
|
||||
|
||||
try:
|
||||
user.save()
|
||||
except IntegrityError:
|
||||
raise AccountUserAlreadyExists
|
||||
|
||||
# Create a registration to track the activation process
|
||||
# This implicitly saves the registration.
|
||||
registration = Registration()
|
||||
registration.register(user)
|
||||
|
||||
# Create an empty user profile with default values
|
||||
UserProfile(user=user).save()
|
||||
|
||||
# Return the activation key, which the caller should send to the user
|
||||
return registration.activation_key
|
||||
|
||||
|
||||
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
|
||||
def account_info(username):
|
||||
"""Retrieve information about a user's account.
|
||||
|
||||
Arguments:
|
||||
username (unicode): The username associated with the account.
|
||||
|
||||
Returns:
|
||||
dict: User's account information, if the user was found.
|
||||
None: The user does not exist.
|
||||
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
return None
|
||||
else:
|
||||
return {
|
||||
u'username': username,
|
||||
u'email': user.email,
|
||||
u'is_active': user.is_active,
|
||||
}
|
||||
|
||||
|
||||
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
|
||||
def activate_account(activation_key):
|
||||
"""Activate a user's account.
|
||||
|
||||
Args:
|
||||
activation_key (unicode): The activation key the user received via email.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
AccountNotAuthorized
|
||||
|
||||
"""
|
||||
try:
|
||||
registration = Registration.objects.get(activation_key=activation_key)
|
||||
except Registration.DoesNotExist:
|
||||
raise AccountNotAuthorized
|
||||
else:
|
||||
# This implicitly saves the registration
|
||||
registration.activate()
|
||||
|
||||
|
||||
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
|
||||
def request_email_change(username, new_email, password):
|
||||
"""Request an email change.
|
||||
|
||||
Users must confirm the change before we update their information.
|
||||
|
||||
Args:
|
||||
username (unicode): The username associated with the account.
|
||||
new_email (unicode): The user's new email address.
|
||||
password (unicode): The password the user entered to authorize the change.
|
||||
|
||||
Returns:
|
||||
unicode: an activation key for the account.
|
||||
|
||||
Raises:
|
||||
AccountUserNotFound
|
||||
AccountEmailAlreadyExists
|
||||
AccountEmailInvalid
|
||||
AccountNotAuthorized
|
||||
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(username=username)
|
||||
except User.DoesNotExist:
|
||||
raise AccountUserNotFound
|
||||
|
||||
# Check the user's credentials
|
||||
if not user.check_password(password):
|
||||
raise AccountNotAuthorized
|
||||
|
||||
# Validate the email, raising an exception if it is not in the correct format
|
||||
_validate_email(new_email)
|
||||
|
||||
# Verify that no active account has taken the email in between
|
||||
# the request and the activation.
|
||||
# We'll check again before confirming and persisting the change,
|
||||
# but if the email is already taken by an active account, we should
|
||||
# let the user know as soon as possible.
|
||||
if User.objects.filter(email=new_email, is_active=True).exists():
|
||||
raise AccountEmailAlreadyExists
|
||||
|
||||
try:
|
||||
pending_change = PendingEmailChange.objects.get(user=user)
|
||||
except PendingEmailChange.DoesNotExist:
|
||||
pending_change = PendingEmailChange(user=user)
|
||||
|
||||
# Update the change (re-using the same record if it already exists)
|
||||
# This will generate a new activation key and save the record.
|
||||
return pending_change.request_change(new_email)
|
||||
|
||||
|
||||
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
|
||||
@transaction.commit_on_success
|
||||
def confirm_email_change(activation_key):
|
||||
"""Confirm an email change.
|
||||
|
||||
Users can confirm the change by providing an activation key
|
||||
they received via email.
|
||||
|
||||
Args:
|
||||
activation_key (unicode): The activation key the user received
|
||||
when he/she requested the email change.
|
||||
|
||||
Returns:
|
||||
Tuple: (old_email, new_email)
|
||||
|
||||
Raises:
|
||||
AccountNotAuthorized: The activation code is invalid.
|
||||
AccountEmailAlreadyExists: Someone else has already taken the email address.
|
||||
AccountInternalError
|
||||
|
||||
"""
|
||||
|
||||
try:
|
||||
# Activation key has a uniqueness constraint, so we're guaranteed to get
|
||||
# at most one pending change.
|
||||
pending_change = PendingEmailChange.objects.select_related('user').get(
|
||||
activation_key=activation_key
|
||||
)
|
||||
except PendingEmailChange.DoesNotExist:
|
||||
# If there are no changes, then the activation key is invalid
|
||||
raise AccountNotAuthorized
|
||||
else:
|
||||
old_email = pending_change.user.email
|
||||
new_email = pending_change.new_email
|
||||
|
||||
# Verify that no one else has taken the email in between
|
||||
# the request and the activation.
|
||||
# In our production database, email has a uniqueness constraint,
|
||||
# so there is no danger of a race condition here.
|
||||
if User.objects.filter(email=new_email).exists():
|
||||
raise AccountEmailAlreadyExists
|
||||
|
||||
# Update the email history (in the user profile)
|
||||
try:
|
||||
profile = UserProfile.objects.get(user=pending_change.user)
|
||||
except UserProfile.DoesNotExist:
|
||||
raise AccountInternalError(
|
||||
"No profile exists for the user '{username}'".format(
|
||||
username=pending_change.user.username
|
||||
)
|
||||
)
|
||||
else:
|
||||
profile.update_email(new_email)
|
||||
|
||||
# Delete the pending change, so that the activation code
|
||||
# will be single-use
|
||||
pending_change.delete()
|
||||
|
||||
# Return the old and new email
|
||||
# This allows the caller of the function to notify users at both
|
||||
# the new and old email, which is necessary for security reasons.
|
||||
return (old_email, new_email)
|
||||
|
||||
|
||||
def _validate_username(username):
|
||||
"""Validate the username.
|
||||
|
||||
Arguments:
|
||||
username (unicode): The proposed username.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
AccountUsernameInvalid
|
||||
|
||||
"""
|
||||
if not isinstance(username, basestring):
|
||||
raise AccountUsernameInvalid(u"Username must be a string")
|
||||
|
||||
if len(username) < USERNAME_MIN_LENGTH:
|
||||
raise AccountUsernameInvalid(
|
||||
u"Username '{username}' must be at least {min} characters long".format(
|
||||
username=username,
|
||||
min=USERNAME_MIN_LENGTH
|
||||
)
|
||||
)
|
||||
if len(username) > USERNAME_MAX_LENGTH:
|
||||
raise AccountUsernameInvalid(
|
||||
u"Username '{username}' must be at most {max} characters long".format(
|
||||
username=username,
|
||||
max=USERNAME_MAX_LENGTH
|
||||
)
|
||||
)
|
||||
try:
|
||||
validate_slug(username)
|
||||
except ValidationError:
|
||||
raise AccountUsernameInvalid(
|
||||
u"Username '{username}' must contain only A-Z, a-z, 0-9, -, or _ characters"
|
||||
)
|
||||
|
||||
|
||||
def _validate_password(password, username):
|
||||
"""Validate the format of the user's password.
|
||||
|
||||
Passwords cannot be the same as the username of the account,
|
||||
so we take `username` as an argument.
|
||||
|
||||
Arguments:
|
||||
password (unicode): The proposed password.
|
||||
username (unicode): The username associated with the user's account.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
AccountPasswordInvalid
|
||||
|
||||
"""
|
||||
if not isinstance(password, basestring):
|
||||
raise AccountPasswordInvalid(u"Password must be a string")
|
||||
|
||||
if len(password) < PASSWORD_MIN_LENGTH:
|
||||
raise AccountPasswordInvalid(
|
||||
u"Password must be at least {min} characters long".format(
|
||||
min=PASSWORD_MIN_LENGTH
|
||||
)
|
||||
)
|
||||
|
||||
if len(password) > PASSWORD_MAX_LENGTH:
|
||||
raise AccountPasswordInvalid(
|
||||
u"Password must be at most {max} characters long".format(
|
||||
max=PASSWORD_MAX_LENGTH
|
||||
)
|
||||
)
|
||||
|
||||
if password == username:
|
||||
raise AccountPasswordInvalid(u"Password cannot be the same as the username")
|
||||
|
||||
|
||||
def _validate_email(email):
|
||||
"""Validate the format of the email address.
|
||||
|
||||
Arguments:
|
||||
email (unicode): The proposed email.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
AccountEmailInvalid
|
||||
|
||||
"""
|
||||
if not isinstance(email, basestring):
|
||||
raise AccountEmailInvalid(u"Email must be a string")
|
||||
|
||||
if len(email) < EMAIL_MIN_LENGTH:
|
||||
raise AccountEmailInvalid(
|
||||
u"Email '{email}' must be at least {min} characters long".format(
|
||||
email=email,
|
||||
min=EMAIL_MIN_LENGTH
|
||||
)
|
||||
)
|
||||
|
||||
if len(email) > EMAIL_MAX_LENGTH:
|
||||
raise AccountEmailInvalid(
|
||||
u"Email '{email}' must be at most {max} characters long".format(
|
||||
email=email,
|
||||
max=EMAIL_MAX_LENGTH
|
||||
)
|
||||
)
|
||||
|
||||
try:
|
||||
validate_email(email)
|
||||
except ValidationError:
|
||||
raise AccountEmailInvalid(
|
||||
u"Email '{email}' format is not valid".format(email=email)
|
||||
)
|
||||
|
||||
132
common/djangoapps/user_api/api/profile.py
Normal file
132
common/djangoapps/user_api/api/profile.py
Normal file
@@ -0,0 +1,132 @@
|
||||
"""Python API for user profiles.
|
||||
|
||||
Profile information includes a student's demographic information and preferences,
|
||||
but does NOT include basic account information such as username, password, and
|
||||
email address.
|
||||
|
||||
"""
|
||||
from user_api.models import UserProfile
|
||||
from user_api.helpers import intercept_errors
|
||||
|
||||
|
||||
class ProfileRequestError(Exception):
|
||||
""" The request to the API was not valid. """
|
||||
pass
|
||||
|
||||
|
||||
class ProfileUserNotFound(ProfileRequestError):
|
||||
""" The requested user does not exist. """
|
||||
pass
|
||||
|
||||
|
||||
class ProfileInvalidField(ProfileRequestError):
|
||||
""" The proposed value for a field is not in a valid format. """
|
||||
|
||||
def __init__(self, field, value):
|
||||
self.field = field
|
||||
self.value = value
|
||||
|
||||
def __str__(self):
|
||||
return u"Invalid value '{value}' for profile field '{field}'".format(
|
||||
value=self.value,
|
||||
field=self.field
|
||||
)
|
||||
|
||||
|
||||
class ProfileInternalError(Exception):
|
||||
""" An error occurred in an API call. """
|
||||
pass
|
||||
|
||||
|
||||
FULL_NAME_MAX_LENGTH = 255
|
||||
|
||||
|
||||
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
|
||||
def profile_info(username):
|
||||
"""Retrieve a user's profile information
|
||||
|
||||
Searches either by username or email.
|
||||
|
||||
At least one of the keyword args must be provided.
|
||||
|
||||
Arguments:
|
||||
username (unicode): The username of the account to retrieve.
|
||||
|
||||
Returns:
|
||||
dict: If profile information was found.
|
||||
None: If the provided username did not match any profiles.
|
||||
|
||||
"""
|
||||
try:
|
||||
profile = UserProfile.objects.get(user__username=username)
|
||||
except UserProfile.DoesNotExist:
|
||||
return None
|
||||
|
||||
profile_dict = {
|
||||
u'username': profile.user.username,
|
||||
u'email': profile.user.email,
|
||||
u'full_name': profile.name,
|
||||
}
|
||||
|
||||
return profile_dict
|
||||
|
||||
|
||||
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
|
||||
def update_profile(username, full_name=None):
|
||||
"""Update a user's profile.
|
||||
|
||||
Args:
|
||||
username (unicode): The username associated with the account.
|
||||
|
||||
Keyword Arguments:
|
||||
full_name (unicode): If provided, set the user's full name to this value.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
Raises:
|
||||
ProfileRequestError: If there is no profile matching the provided username.
|
||||
|
||||
"""
|
||||
try:
|
||||
profile = UserProfile.objects.get(user__username=username)
|
||||
except UserProfile.DoesNotExist:
|
||||
raise ProfileUserNotFound
|
||||
|
||||
if full_name is not None:
|
||||
name_length = len(full_name)
|
||||
if name_length > FULL_NAME_MAX_LENGTH or name_length == 0:
|
||||
raise ProfileInvalidField("full_name", full_name)
|
||||
else:
|
||||
profile.update_name(full_name)
|
||||
|
||||
|
||||
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
|
||||
def preference_info(username, preference_name):
|
||||
"""Retrieve information about a user's preferences.
|
||||
|
||||
Arguments:
|
||||
username (unicode): The username of the account to retrieve.
|
||||
preference_name (unicode): The name of the preference to retrieve.
|
||||
|
||||
Returns:
|
||||
The JSON-deserialized value.
|
||||
|
||||
"""
|
||||
pass
|
||||
|
||||
|
||||
@intercept_errors(ProfileInternalError, ignore_errors=[ProfileRequestError])
|
||||
def update_preference(username, preference_name, preference_value):
|
||||
"""Update a user's preference.
|
||||
|
||||
Arguments:
|
||||
username (unicode): The username of the account to retrieve.
|
||||
preference_name (unicode): The name of the preference to set.
|
||||
preference_value (JSON-serializable): The new value for the preference.
|
||||
|
||||
Returns:
|
||||
None
|
||||
|
||||
"""
|
||||
pass
|
||||
56
common/djangoapps/user_api/helpers.py
Normal file
56
common/djangoapps/user_api/helpers.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""
|
||||
Helper functions for the account/profile Python APIs.
|
||||
This is NOT part of the public API.
|
||||
"""
|
||||
from functools import wraps
|
||||
import logging
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def intercept_errors(api_error, ignore_errors=[]):
|
||||
"""
|
||||
Function decorator that intercepts exceptions
|
||||
and translates them into API-specific errors (usually an "internal" error).
|
||||
|
||||
This allows callers to gracefully handle unexpected errors from the API.
|
||||
|
||||
This method will also log all errors and function arguments to make
|
||||
it easier to track down unexpected errors.
|
||||
|
||||
Arguments:
|
||||
api_error (Exception): The exception to raise if an unexpected error is encountered.
|
||||
|
||||
Keyword Arguments:
|
||||
ignore_errors (iterable): List of errors to ignore. By default, intercept every error.
|
||||
|
||||
Returns:
|
||||
function
|
||||
|
||||
"""
|
||||
def _decorator(func):
|
||||
@wraps(func)
|
||||
def _wrapped(*args, **kwargs):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except Exception as ex:
|
||||
# Raise the original exception if it's in our list of "ignored" errors
|
||||
for ignored in ignore_errors:
|
||||
if isinstance(ex, ignored):
|
||||
raise
|
||||
|
||||
# Otherwise, log the error and raise the API-specific error
|
||||
msg = (
|
||||
u"An unexpected error occurred when calling '{func_name}' "
|
||||
u"with arguments '{args}' and keyword arguments '{kwargs}': "
|
||||
u"{exception}"
|
||||
).format(
|
||||
func_name=func.func_name,
|
||||
args=args,
|
||||
kwargs=kwargs,
|
||||
exception=repr(ex)
|
||||
)
|
||||
LOGGER.exception(msg)
|
||||
raise api_error(msg)
|
||||
return _wrapped
|
||||
return _decorator
|
||||
@@ -4,6 +4,14 @@ from django.db import models
|
||||
|
||||
from xmodule_django.models import CourseKeyField
|
||||
|
||||
# Currently, the "student" app is responsible for
|
||||
# accounts, profiles, enrollments, and the student dashboard.
|
||||
# We are trying to move some of this functionality into separate apps,
|
||||
# but currently the rest of the system assumes that "student" defines
|
||||
# certain models. For now we will leave the models in "student" and
|
||||
# create an alias in "user_api".
|
||||
from student.models import UserProfile, Registration, PendingEmailChange # pylint:disable=unused-import
|
||||
|
||||
|
||||
class UserPreference(models.Model):
|
||||
"""A user's preference, stored as generic text to be processed by client"""
|
||||
|
||||
276
common/djangoapps/user_api/tests/test_account_api.py
Normal file
276
common/djangoapps/user_api/tests/test_account_api.py
Normal file
@@ -0,0 +1,276 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for the account API. """
|
||||
|
||||
import unittest
|
||||
from nose.tools import raises
|
||||
import ddt
|
||||
from dateutil.parser import parse as parse_datetime
|
||||
from django.conf import settings
|
||||
from django.test import TestCase
|
||||
from user_api.api import account as account_api
|
||||
from user_api.models import UserProfile
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class AccountApiTest(TestCase):
|
||||
|
||||
USERNAME = u"frank-underwood"
|
||||
PASSWORD = u"ṕáśśẃőŕd"
|
||||
EMAIL = u"frank+underwood@example.com"
|
||||
|
||||
INVALID_USERNAMES = [
|
||||
None,
|
||||
u"",
|
||||
u"a",
|
||||
u"a" * (account_api.USERNAME_MAX_LENGTH + 1),
|
||||
u"invalid_symbol_@",
|
||||
u"invalid-unicode_fŕáńḱ",
|
||||
]
|
||||
|
||||
INVALID_EMAILS = [
|
||||
None,
|
||||
u"",
|
||||
u"a",
|
||||
"no_domain",
|
||||
"no+domain",
|
||||
"@",
|
||||
"@domain.com",
|
||||
"test@no_extension",
|
||||
|
||||
# Long email -- subtract the length of the @domain
|
||||
# except for one character (so we exceed the max length limit)
|
||||
u"{user}@example.com".format(
|
||||
user=(u'e' * (account_api.EMAIL_MAX_LENGTH - 11))
|
||||
)
|
||||
]
|
||||
|
||||
INVALID_PASSWORDS = [
|
||||
None,
|
||||
u"",
|
||||
u"a",
|
||||
u"a" * (account_api.PASSWORD_MAX_LENGTH + 1)
|
||||
]
|
||||
|
||||
def test_activate_account(self):
|
||||
# Create the account, which is initially inactive
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account = account_api.account_info(self.USERNAME)
|
||||
self.assertEqual(account, {
|
||||
'username': self.USERNAME,
|
||||
'email': self.EMAIL,
|
||||
'is_active': False
|
||||
})
|
||||
|
||||
# Activate the account and verify that it is now active
|
||||
account_api.activate_account(activation_key)
|
||||
account = account_api.account_info(self.USERNAME)
|
||||
self.assertTrue(account['is_active'])
|
||||
|
||||
def test_change_email(self):
|
||||
# Request an email change
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"new+email@example.com", self.PASSWORD
|
||||
)
|
||||
|
||||
# Verify that the email has not yet changed
|
||||
account = account_api.account_info(self.USERNAME)
|
||||
self.assertEqual(account['email'], self.EMAIL)
|
||||
|
||||
# Confirm the change, using the activation code
|
||||
old_email, new_email = account_api.confirm_email_change(activation_key)
|
||||
self.assertEqual(old_email, self.EMAIL)
|
||||
self.assertEqual(new_email, u"new+email@example.com")
|
||||
|
||||
# Verify that the email is changed
|
||||
account = account_api.account_info(self.USERNAME)
|
||||
self.assertEqual(account['email'], u"new+email@example.com")
|
||||
|
||||
def test_confirm_email_change_repeat(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"new+email@example.com", self.PASSWORD
|
||||
)
|
||||
|
||||
# Confirm the change once
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
# Confirm the change again
|
||||
# The activation code should be single-use
|
||||
# so this should raise an error.
|
||||
with self.assertRaises(account_api.AccountNotAuthorized):
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
def test_create_account_duplicate_username(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
with self.assertRaises(account_api.AccountUserAlreadyExists):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, 'different+email@example.com')
|
||||
|
||||
# Email uniqueness constraints were introduced in a database migration,
|
||||
# which we disable in the unit tests to improve the speed of the test suite.
|
||||
@unittest.skipUnless(settings.SOUTH_TESTS_MIGRATE, "South migrations required")
|
||||
def test_create_account_duplicate_email(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
with self.assertRaises(account_api.AccountUserAlreadyExists):
|
||||
account_api.create_account("different_user", self.PASSWORD, self.EMAIL)
|
||||
|
||||
def test_username_too_long(self):
|
||||
long_username = 'e' * (account_api.USERNAME_MAX_LENGTH + 1)
|
||||
with self.assertRaises(account_api.AccountUsernameInvalid):
|
||||
account_api.create_account(long_username, self.PASSWORD, self.EMAIL)
|
||||
|
||||
def test_account_info_no_user(self):
|
||||
self.assertIs(account_api.account_info("does_not_exist"), None)
|
||||
|
||||
@raises(account_api.AccountEmailInvalid)
|
||||
@ddt.data(*INVALID_EMAILS)
|
||||
def test_create_account_invalid_email(self, invalid_email):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, invalid_email)
|
||||
|
||||
@raises(account_api.AccountPasswordInvalid)
|
||||
@ddt.data(*INVALID_PASSWORDS)
|
||||
def test_create_account_invalid_password(self, invalid_password):
|
||||
account_api.create_account(self.USERNAME, invalid_password, self.EMAIL)
|
||||
|
||||
@raises(account_api.AccountPasswordInvalid)
|
||||
def test_create_account_username_password_equal(self):
|
||||
# Username and password cannot be the same
|
||||
account_api.create_account(self.USERNAME, self.USERNAME, self.EMAIL)
|
||||
|
||||
@raises(account_api.AccountRequestError)
|
||||
@ddt.data(*INVALID_USERNAMES)
|
||||
def test_create_account_invalid_username(self, invalid_username):
|
||||
account_api.create_account(invalid_username, self.PASSWORD, self.EMAIL)
|
||||
|
||||
@raises(account_api.AccountNotAuthorized)
|
||||
def test_activate_account_invalid_key(self):
|
||||
account_api.activate_account(u"invalid")
|
||||
|
||||
@raises(account_api.AccountUserNotFound)
|
||||
def test_request_email_change_no_user(self):
|
||||
account_api.request_email_change(u"no_such_user", self.EMAIL, self.PASSWORD)
|
||||
|
||||
@ddt.data(*INVALID_EMAILS)
|
||||
def test_request_email_change_invalid_email(self, invalid_email):
|
||||
# Create an account with a valid email address
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Attempt to change the account to an invalid email
|
||||
with self.assertRaises(account_api.AccountEmailInvalid):
|
||||
account_api.request_email_change(self.USERNAME, invalid_email, self.PASSWORD)
|
||||
|
||||
def test_request_email_change_already_exists(self):
|
||||
# Create two accounts, both activated
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
activation_key = account_api.create_account(u"another_user", u"password", u"another+user@example.com")
|
||||
account_api.activate_account(activation_key)
|
||||
|
||||
# Try to change the first user's email to the same as the second user's
|
||||
with self.assertRaises(account_api.AccountEmailAlreadyExists):
|
||||
account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
|
||||
|
||||
def test_request_email_change_duplicates_unactivated_account(self):
|
||||
# Create two accounts, but the second account is inactive
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
account_api.create_account(u"another_user", u"password", u"another+user@example.com")
|
||||
|
||||
# Try to change the first user's email to the same as the second user's
|
||||
# Since the second user has not yet activated, this should succeed.
|
||||
account_api.request_email_change(self.USERNAME, u"another+user@example.com", self.PASSWORD)
|
||||
|
||||
def test_request_email_change_same_address(self):
|
||||
# Create and activate the account
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
|
||||
# Try to change the email address to the current address
|
||||
with self.assertRaises(account_api.AccountEmailAlreadyExists):
|
||||
account_api.request_email_change(self.USERNAME, self.EMAIL, self.PASSWORD)
|
||||
|
||||
def test_request_email_change_wrong_password(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Use the wrong password
|
||||
with self.assertRaises(account_api.AccountNotAuthorized):
|
||||
account_api.request_email_change(self.USERNAME, u"new+email@example.com", u"wrong password")
|
||||
|
||||
def test_confirm_email_change_invalid_activation_key(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account_api.request_email_change(self.USERNAME, u"new+email@example.com", self.PASSWORD)
|
||||
|
||||
with self.assertRaises(account_api.AccountNotAuthorized):
|
||||
account_api.confirm_email_change(u"invalid")
|
||||
|
||||
def test_confirm_email_change_no_request_pending(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
def test_confirm_email_already_exists(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Request a change
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"new+email@example.com", self.PASSWORD
|
||||
)
|
||||
|
||||
# Another use takes the email before we confirm the change
|
||||
account_api.create_account(u"other_user", u"password", u"new+email@example.com")
|
||||
|
||||
# When we try to confirm our change, we get an error because the email is taken
|
||||
with self.assertRaises(account_api.AccountEmailAlreadyExists):
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
# Verify that the email was NOT changed
|
||||
self.assertEqual(account_api.account_info(self.USERNAME)['email'], self.EMAIL)
|
||||
|
||||
def test_confirm_email_no_user_profile(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"new+email@example.com", self.PASSWORD
|
||||
)
|
||||
|
||||
# This should never happen, but just in case...
|
||||
UserProfile.objects.get(user__username=self.USERNAME).delete()
|
||||
|
||||
with self.assertRaises(account_api.AccountInternalError):
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
def test_record_email_change_history(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Change the email once
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"new+email@example.com", self.PASSWORD
|
||||
)
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
# Verify that the old email appears in the history
|
||||
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
|
||||
self.assertEqual(len(meta['old_emails']), 1)
|
||||
email, timestamp = meta['old_emails'][0]
|
||||
self.assertEqual(email, self.EMAIL)
|
||||
self._assert_is_datetime(timestamp)
|
||||
|
||||
# Change the email again
|
||||
activation_key = account_api.request_email_change(
|
||||
self.USERNAME, u"another_new+email@example.com", self.PASSWORD
|
||||
)
|
||||
account_api.confirm_email_change(activation_key)
|
||||
|
||||
# Verify that both emails appear in the history
|
||||
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
|
||||
self.assertEqual(len(meta['old_emails']), 2)
|
||||
email, timestamp = meta['old_emails'][1]
|
||||
self.assertEqual(email, "new+email@example.com")
|
||||
self._assert_is_datetime(timestamp)
|
||||
|
||||
def _assert_is_datetime(self, timestamp):
|
||||
if not timestamp:
|
||||
return False
|
||||
try:
|
||||
parse_datetime(timestamp)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
66
common/djangoapps/user_api/tests/test_helpers.py
Normal file
66
common/djangoapps/user_api/tests/test_helpers.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
Tests for helper functions.
|
||||
"""
|
||||
import mock
|
||||
from django.test import TestCase
|
||||
from nose.tools import raises
|
||||
from user_api.helpers import intercept_errors
|
||||
|
||||
|
||||
class FakeInputException(Exception):
|
||||
"""Fake exception that should be intercepted. """
|
||||
pass
|
||||
|
||||
|
||||
class FakeOutputException(Exception):
|
||||
"""Fake exception that should be raised. """
|
||||
pass
|
||||
|
||||
|
||||
@intercept_errors(FakeOutputException, ignore_errors=[ValueError])
|
||||
def intercepted_function(raise_error=None):
|
||||
"""Function used to test the intercept error decorator.
|
||||
|
||||
Keyword Arguments:
|
||||
raise_error (Exception): If provided, raise this exception.
|
||||
|
||||
"""
|
||||
if raise_error is not None:
|
||||
raise raise_error
|
||||
|
||||
|
||||
class InterceptErrorsTest(TestCase):
|
||||
"""
|
||||
Tests for the decorator that intercepts errors.
|
||||
"""
|
||||
|
||||
@raises(FakeOutputException)
|
||||
def test_intercepts_errors(self):
|
||||
intercepted_function(raise_error=FakeInputException)
|
||||
|
||||
def test_ignores_no_error(self):
|
||||
intercepted_function()
|
||||
|
||||
@raises(ValueError)
|
||||
def test_ignores_expected_errors(self):
|
||||
intercepted_function(raise_error=ValueError)
|
||||
|
||||
@mock.patch('user_api.helpers.LOGGER')
|
||||
def test_logs_errors(self, mock_logger):
|
||||
expected_log_msg = (
|
||||
u"An unexpected error occurred when calling 'intercepted_function' "
|
||||
u"with arguments '()' and "
|
||||
u"keyword arguments '{'raise_error': <class 'user_api.tests.test_helpers.FakeInputException'>}': "
|
||||
u"FakeInputException()"
|
||||
)
|
||||
|
||||
# Verify that the raised exception has the error message
|
||||
try:
|
||||
intercepted_function(raise_error=FakeInputException)
|
||||
except FakeOutputException as ex:
|
||||
self.assertEqual(ex.message, expected_log_msg)
|
||||
|
||||
# Verify that the error logger is called
|
||||
# This will include the stack trace for the original exception
|
||||
# because it's called with log level "ERROR"
|
||||
mock_logger.exception.assert_called_once_with(expected_log_msg)
|
||||
90
common/djangoapps/user_api/tests/test_profile_api.py
Normal file
90
common/djangoapps/user_api/tests/test_profile_api.py
Normal file
@@ -0,0 +1,90 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for the profile API. """
|
||||
|
||||
from django.test import TestCase
|
||||
import ddt
|
||||
from nose.tools import raises
|
||||
from dateutil.parser import parse as parse_datetime
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
from user_api.models import UserProfile
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class ProfileApiTest(TestCase):
|
||||
|
||||
USERNAME = u"frank-underwood"
|
||||
PASSWORD = u"ṕáśśẃőŕd"
|
||||
EMAIL = u"frank+underwood@example.com"
|
||||
|
||||
def test_create_profile(self):
|
||||
# Create a new account, which should have an empty profile by default.
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Retrieve the profile, expecting default values
|
||||
profile = profile_api.profile_info(username=self.USERNAME)
|
||||
self.assertEqual(profile, {
|
||||
'username': self.USERNAME,
|
||||
'email': self.EMAIL,
|
||||
'full_name': u'',
|
||||
})
|
||||
|
||||
def test_update_full_name(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
profile_api.update_profile(self.USERNAME, full_name=u"ȻħȺɍłɇs")
|
||||
profile = profile_api.profile_info(username=self.USERNAME)
|
||||
self.assertEqual(profile['full_name'], u"ȻħȺɍłɇs")
|
||||
|
||||
@raises(profile_api.ProfileInvalidField)
|
||||
@ddt.data('', 'a' * profile_api.FULL_NAME_MAX_LENGTH + 'a')
|
||||
def test_update_full_name_invalid(self, invalid_name):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
profile_api.update_profile(self.USERNAME, full_name=invalid_name)
|
||||
|
||||
@raises(profile_api.ProfileUserNotFound)
|
||||
def test_update_profile_no_user(self):
|
||||
profile_api.update_profile(self.USERNAME, full_name="test")
|
||||
|
||||
def test_retrieve_profile_no_user(self):
|
||||
profile = profile_api.profile_info("does not exist")
|
||||
self.assertIs(profile, None)
|
||||
|
||||
def test_record_name_change_history(self):
|
||||
account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
|
||||
# Change the name once
|
||||
# Since the original name was an empty string, expect that the list
|
||||
# of old names is empty
|
||||
profile_api.update_profile(self.USERNAME, full_name="new name")
|
||||
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
|
||||
self.assertEqual(meta, {})
|
||||
|
||||
# Change the name again and expect the new name is stored in the history
|
||||
profile_api.update_profile(self.USERNAME, full_name="another new name")
|
||||
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
|
||||
|
||||
self.assertEqual(len(meta['old_names']), 1)
|
||||
name, rationale, timestamp = meta['old_names'][0]
|
||||
self.assertEqual(name, "new name")
|
||||
self.assertEqual(rationale, u"")
|
||||
self._assert_is_datetime(timestamp)
|
||||
|
||||
# Change the name a third time and expect both names are stored in the history
|
||||
profile_api.update_profile(self.USERNAME, full_name="yet another new name")
|
||||
meta = UserProfile.objects.get(user__username=self.USERNAME).get_meta()
|
||||
|
||||
self.assertEqual(len(meta['old_names']), 2)
|
||||
name, rationale, timestamp = meta['old_names'][1]
|
||||
self.assertEqual(name, "another new name")
|
||||
self.assertEqual(rationale, u"")
|
||||
self._assert_is_datetime(timestamp)
|
||||
|
||||
def _assert_is_datetime(self, timestamp):
|
||||
if not timestamp:
|
||||
return False
|
||||
try:
|
||||
parse_datetime(timestamp)
|
||||
except ValueError:
|
||||
return False
|
||||
else:
|
||||
return True
|
||||
0
lms/djangoapps/student_account/__init__.py
Normal file
0
lms/djangoapps/student_account/__init__.py
Normal file
0
lms/djangoapps/student_account/test/__init__.py
Normal file
0
lms/djangoapps/student_account/test/__init__.py
Normal file
204
lms/djangoapps/student_account/test/test_views.py
Normal file
204
lms/djangoapps/student_account/test/test_views.py
Normal file
@@ -0,0 +1,204 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for student account views. """
|
||||
|
||||
from urllib import urlencode
|
||||
from mock import patch
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from util.testing import UrlResetMixin
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class StudentAccountViewTest(UrlResetMixin, TestCase):
|
||||
""" Tests for the student account views. """
|
||||
|
||||
USERNAME = u"heisenberg"
|
||||
ALTERNATE_USERNAME = u"walt"
|
||||
PASSWORD = u"ḅḷüëṡḳÿ"
|
||||
OLD_EMAIL = u"walter@graymattertech.com"
|
||||
NEW_EMAIL = u"walt@savewalterwhite.com"
|
||||
|
||||
INVALID_EMAILS = [
|
||||
None,
|
||||
u"",
|
||||
u"a",
|
||||
"no_domain",
|
||||
"no+domain",
|
||||
"@",
|
||||
"@domain.com",
|
||||
"test@no_extension",
|
||||
|
||||
# Long email -- subtract the length of the @domain
|
||||
# except for one character (so we exceed the max length limit)
|
||||
u"{user}@example.com".format(
|
||||
user=(u'e' * (account_api.EMAIL_MAX_LENGTH - 11))
|
||||
)
|
||||
]
|
||||
|
||||
INVALID_KEY = u"123abc"
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
|
||||
def setUp(self):
|
||||
super(StudentAccountViewTest, self).setUp()
|
||||
|
||||
# Create/activate a new account
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.OLD_EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
|
||||
# Login
|
||||
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
def _change_email(self, new_email, password):
|
||||
"""Request to change the user's email. """
|
||||
data = {}
|
||||
|
||||
if new_email is not None:
|
||||
data['new_email'] = new_email
|
||||
if password is not None:
|
||||
# We can't pass a Unicode object to urlencode, so we encode the Unicode object
|
||||
data['password'] = password.encode('utf-8')
|
||||
|
||||
response = self.client.put(
|
||||
path=reverse('email_change_request'),
|
||||
data=urlencode(data),
|
||||
content_type='application/x-www-form-urlencoded'
|
||||
)
|
||||
|
||||
return response
|
||||
|
||||
def test_index(self):
|
||||
response = self.client.get(reverse('account_index'))
|
||||
self.assertContains(response, "Student Account")
|
||||
|
||||
def test_email_change_request_handler(self):
|
||||
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
|
||||
self.assertEquals(response.status_code, 204)
|
||||
|
||||
# Verify that the email associated with the account remains unchanged
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['email'], self.OLD_EMAIL)
|
||||
|
||||
def test_email_change_wrong_password(self):
|
||||
response = self._change_email(self.NEW_EMAIL, "wrong password")
|
||||
self.assertEqual(response.status_code, 401)
|
||||
|
||||
def test_email_change_request_internal_error(self):
|
||||
# Patch account API to raise an internal error when an email change is requested
|
||||
with patch('student_account.views.account_api.request_email_change') as mock_call:
|
||||
mock_call.side_effect = account_api.AccountUserNotFound
|
||||
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
|
||||
|
||||
self.assertEquals(response.status_code, 500)
|
||||
|
||||
def test_email_change_request_email_taken_by_active_account(self):
|
||||
# Create/activate a second user with the new email
|
||||
activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
|
||||
# Request to change the original user's email to the email now used by the second user
|
||||
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
|
||||
self.assertEquals(response.status_code, 409)
|
||||
|
||||
def test_email_change_request_email_taken_by_inactive_account(self):
|
||||
# Create a second user with the new email, but don't active them
|
||||
account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
|
||||
|
||||
# Request to change the original user's email to the email used by the inactive user
|
||||
response = self._change_email(self.NEW_EMAIL, self.PASSWORD)
|
||||
self.assertEquals(response.status_code, 204)
|
||||
|
||||
@ddt.data(*INVALID_EMAILS)
|
||||
def test_email_change_request_email_invalid(self, invalid_email):
|
||||
# Request to change the user's email to an invalid address
|
||||
response = self._change_email(invalid_email, self.PASSWORD)
|
||||
self.assertEquals(response.status_code, 400)
|
||||
|
||||
def test_email_change_confirmation_handler(self):
|
||||
# Get an email change activation key
|
||||
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
|
||||
|
||||
# Follow the link sent in the confirmation email
|
||||
response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
|
||||
self.assertContains(response, "Email change successful")
|
||||
|
||||
# Verify that the email associated with the account has changed
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['email'], self.NEW_EMAIL)
|
||||
|
||||
def test_email_change_confirmation_invalid_key(self):
|
||||
# Visit the confirmation page with an invalid key
|
||||
response = self.client.get(reverse('email_change_confirm', kwargs={'key': self.INVALID_KEY}))
|
||||
self.assertContains(response, "Something went wrong")
|
||||
|
||||
# Verify that the email associated with the account has not changed
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['email'], self.OLD_EMAIL)
|
||||
|
||||
def test_email_change_confirmation_email_already_exists(self):
|
||||
# Get an email change activation key
|
||||
email_activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
|
||||
|
||||
# Create/activate a second user with the new email
|
||||
account_activation_key = account_api.create_account(self.ALTERNATE_USERNAME, self.PASSWORD, self.NEW_EMAIL)
|
||||
account_api.activate_account(account_activation_key)
|
||||
|
||||
# Follow the link sent to the original user
|
||||
response = self.client.get(reverse('email_change_confirm', kwargs={'key': email_activation_key}))
|
||||
self.assertContains(response, "address you wanted to use is already used")
|
||||
|
||||
# Verify that the email associated with the original account has not changed
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['email'], self.OLD_EMAIL)
|
||||
|
||||
def test_email_change_confirmation_internal_error(self):
|
||||
# Get an email change activation key
|
||||
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.PASSWORD)
|
||||
|
||||
# Patch account API to return an internal error
|
||||
with patch('student_account.views.account_api.confirm_email_change') as mock_call:
|
||||
mock_call.side_effect = account_api.AccountInternalError
|
||||
response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
|
||||
|
||||
self.assertContains(response, "Something went wrong")
|
||||
|
||||
def test_change_email_request_missing_email_param(self):
|
||||
response = self._change_email(None, self.PASSWORD)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_change_email_request_missing_password_param(self):
|
||||
response = self._change_email(self.OLD_EMAIL, None)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
@ddt.data(
|
||||
('get', 'account_index'),
|
||||
('put', 'email_change_request')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_require_login(self, method, url_name):
|
||||
# Access the page while logged out
|
||||
self.client.logout()
|
||||
url = reverse(url_name)
|
||||
response = getattr(self.client, method)(url, follow=True)
|
||||
|
||||
# Should have been redirected to the login page
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertIn('accounts/login?next=', response.redirect_chain[0][0])
|
||||
|
||||
@ddt.data(
|
||||
('get', 'account_index'),
|
||||
('put', 'email_change_request')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_require_http_method(self, correct_method, url_name):
|
||||
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
|
||||
url = reverse(url_name)
|
||||
|
||||
for method in wrong_methods:
|
||||
response = getattr(self.client, method)(url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
8
lms/djangoapps/student_account/urls.py
Normal file
8
lms/djangoapps/student_account/urls.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
urlpatterns = patterns(
|
||||
'student_account.views',
|
||||
url(r'^$', 'index', name='account_index'),
|
||||
url(r'^email_change_request$', 'email_change_request_handler', name='email_change_request'),
|
||||
url(r'^email_change_confirm/(?P<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'),
|
||||
)
|
||||
183
lms/djangoapps/student_account/views.py
Normal file
183
lms/djangoapps/student_account/views.py
Normal file
@@ -0,0 +1,183 @@
|
||||
""" Views for a student's account information. """
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import (
|
||||
QueryDict, HttpResponse,
|
||||
HttpResponseBadRequest, HttpResponseServerError
|
||||
)
|
||||
from django.core.mail import send_mail
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from edxmako.shortcuts import render_to_response, render_to_string
|
||||
from microsite_configuration import microsite
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def index(request):
|
||||
"""Render the account info page.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the index page was sent successfully
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
GET /account
|
||||
|
||||
"""
|
||||
return render_to_response(
|
||||
'student_account/index.html', {
|
||||
'disable_courseware_js': True,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['PUT'])
|
||||
@ensure_csrf_cookie
|
||||
def email_change_request_handler(request):
|
||||
"""Handle a request to change the user's email address.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 204 if the confirmation email was sent successfully
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 400 if the format of the new email is incorrect
|
||||
HttpResponse: 401 if the provided password (in the form) is incorrect
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
HttpResponse: 409 if the provided email is already in use
|
||||
HttpResponse: 500 if the user to which the email change will be applied
|
||||
does not exist
|
||||
|
||||
Example usage:
|
||||
|
||||
PUT /account/email_change_request
|
||||
|
||||
"""
|
||||
put = QueryDict(request.body)
|
||||
user = request.user
|
||||
password = put.get('password')
|
||||
|
||||
username = user.username
|
||||
old_email = profile_api.profile_info(username)['email']
|
||||
new_email = put.get('new_email')
|
||||
|
||||
if new_email is None:
|
||||
return HttpResponseBadRequest("Missing param 'new_email'")
|
||||
if password is None:
|
||||
return HttpResponseBadRequest("Missing param 'password'")
|
||||
|
||||
try:
|
||||
key = account_api.request_email_change(username, new_email, password)
|
||||
except account_api.AccountUserNotFound:
|
||||
return HttpResponseServerError()
|
||||
except account_api.AccountEmailAlreadyExists:
|
||||
return HttpResponse(status=409)
|
||||
except account_api.AccountEmailInvalid:
|
||||
return HttpResponseBadRequest()
|
||||
except account_api.AccountNotAuthorized:
|
||||
return HttpResponse(status=401)
|
||||
|
||||
context = {
|
||||
'key': key,
|
||||
'old_email': old_email,
|
||||
'new_email': new_email,
|
||||
}
|
||||
|
||||
subject = render_to_string('student_account/emails/email_change_request/subject_line.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
message = render_to_string('student_account/emails/email_change_request/message_body.txt', context)
|
||||
|
||||
from_address = microsite.get_value(
|
||||
'email_from_address',
|
||||
settings.DEFAULT_FROM_EMAIL
|
||||
)
|
||||
|
||||
# Email new address
|
||||
send_mail(subject, message, from_address, [new_email])
|
||||
|
||||
# A 204 is intended to allow input for actions to take place
|
||||
# without causing a change to the user agent's active document view.
|
||||
return HttpResponse(status=204)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def email_change_confirmation_handler(request, key):
|
||||
"""Complete a change of the user's email address.
|
||||
|
||||
This is called when the activation link included in the confirmation
|
||||
email is clicked.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if the email change is successful, the activation key
|
||||
is invalid, the new email is already in use, or the
|
||||
user to which the email change will be applied does
|
||||
not exist
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
GET /account/email_change_confirm/{key}
|
||||
|
||||
"""
|
||||
try:
|
||||
old_email, new_email = account_api.confirm_email_change(key)
|
||||
except account_api.AccountNotAuthorized:
|
||||
return render_to_response(
|
||||
'student_account/email_change_failed.html', {
|
||||
'disable_courseware_js': True,
|
||||
'error': 'key_invalid',
|
||||
}
|
||||
)
|
||||
except account_api.AccountEmailAlreadyExists:
|
||||
return render_to_response(
|
||||
'student_account/email_change_failed.html', {
|
||||
'disable_courseware_js': True,
|
||||
'error': 'email_used',
|
||||
}
|
||||
)
|
||||
except account_api.AccountInternalError:
|
||||
return render_to_response(
|
||||
'student_account/email_change_failed.html', {
|
||||
'disable_courseware_js': True,
|
||||
'error': 'internal',
|
||||
}
|
||||
)
|
||||
|
||||
context = {
|
||||
'old_email': old_email,
|
||||
'new_email': new_email,
|
||||
}
|
||||
|
||||
subject = render_to_string('student_account/emails/email_change_confirmation/subject_line.txt', context)
|
||||
subject = ''.join(subject.splitlines())
|
||||
message = render_to_string('student_account/emails/email_change_confirmation/message_body.txt', context)
|
||||
|
||||
from_address = microsite.get_value(
|
||||
'email_from_address',
|
||||
settings.DEFAULT_FROM_EMAIL
|
||||
)
|
||||
|
||||
# Notify both old and new emails of the change
|
||||
send_mail(subject, message, from_address, [old_email, new_email])
|
||||
|
||||
return render_to_response(
|
||||
'student_account/email_change_successful.html', {
|
||||
'disable_courseware_js': True,
|
||||
}
|
||||
)
|
||||
0
lms/djangoapps/student_profile/__init__.py
Normal file
0
lms/djangoapps/student_profile/__init__.py
Normal file
0
lms/djangoapps/student_profile/test/__init__.py
Normal file
0
lms/djangoapps/student_profile/test/__init__.py
Normal file
113
lms/djangoapps/student_profile/test/test_views.py
Normal file
113
lms/djangoapps/student_profile/test/test_views.py
Normal file
@@ -0,0 +1,113 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
""" Tests for student profile views. """
|
||||
|
||||
from urllib import urlencode
|
||||
from mock import patch
|
||||
import ddt
|
||||
from django.test import TestCase
|
||||
from django.conf import settings
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
from util.testing import UrlResetMixin
|
||||
from user_api.api import account as account_api
|
||||
from user_api.api import profile as profile_api
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class StudentProfileViewTest(UrlResetMixin, TestCase):
|
||||
""" Tests for the student profile views. """
|
||||
|
||||
USERNAME = u"heisenberg"
|
||||
PASSWORD = u"ḅḷüëṡḳÿ"
|
||||
EMAIL = u"walt@savewalterwhite.com"
|
||||
FULL_NAME = u"𝖂𝖆𝖑𝖙𝖊𝖗 𝖂𝖍𝖎𝖙𝖊"
|
||||
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
|
||||
def setUp(self):
|
||||
super(StudentProfileViewTest, self).setUp()
|
||||
|
||||
# Create/activate a new account
|
||||
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
|
||||
account_api.activate_account(activation_key)
|
||||
|
||||
# Login
|
||||
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
|
||||
self.assertTrue(result)
|
||||
|
||||
def test_index(self):
|
||||
response = self.client.get(reverse('profile_index'))
|
||||
self.assertContains(response, "Student Profile")
|
||||
|
||||
def test_name_change_handler(self):
|
||||
# Verify that the name on the account is blank
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['full_name'], '')
|
||||
|
||||
response = self._change_name(self.FULL_NAME)
|
||||
self.assertEquals(response.status_code, 204)
|
||||
|
||||
# Verify that the name on the account has been changed
|
||||
profile_info = profile_api.profile_info(self.USERNAME)
|
||||
self.assertEquals(profile_info['full_name'], self.FULL_NAME)
|
||||
|
||||
def test_name_change_invalid(self):
|
||||
# Name cannot be an empty string
|
||||
response = self._change_name('')
|
||||
self.assertEquals(response.status_code, 400)
|
||||
|
||||
def test_name_change_missing_params(self):
|
||||
response = self._change_name(None)
|
||||
self.assertEquals(response.status_code, 400)
|
||||
|
||||
@patch('student_profile.views.profile_api.update_profile')
|
||||
def test_name_change_internal_error(self, mock_call):
|
||||
# This can't happen if the user is logged in, but test it anyway
|
||||
mock_call.side_effect = profile_api.ProfileUserNotFound
|
||||
response = self._change_name(self.FULL_NAME)
|
||||
self.assertEqual(response.status_code, 500)
|
||||
|
||||
@ddt.data(
|
||||
('get', 'profile_index'),
|
||||
('put', 'name_change')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_require_login(self, method, url_name):
|
||||
# Access the page while logged out
|
||||
self.client.logout()
|
||||
url = reverse(url_name)
|
||||
response = getattr(self.client, method)(url, follow=True)
|
||||
|
||||
# Should have been redirected to the login page
|
||||
self.assertEqual(len(response.redirect_chain), 1)
|
||||
self.assertIn('accounts/login?next=', response.redirect_chain[0][0])
|
||||
|
||||
@ddt.data(
|
||||
('get', 'profile_index'),
|
||||
('put', 'name_change')
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_require_http_method(self, correct_method, url_name):
|
||||
wrong_methods = {'get', 'put', 'post', 'head', 'options', 'delete'} - {correct_method}
|
||||
url = reverse(url_name)
|
||||
|
||||
for method in wrong_methods:
|
||||
response = getattr(self.client, method)(url)
|
||||
self.assertEqual(response.status_code, 405)
|
||||
|
||||
def _change_name(self, new_name):
|
||||
"""Request a name change.
|
||||
|
||||
Returns:
|
||||
HttpResponse
|
||||
|
||||
"""
|
||||
data = {}
|
||||
if new_name is not None:
|
||||
# We can't pass a Unicode object to urlencode, so we encode the Unicode object
|
||||
data['new_name'] = new_name.encode('utf-8')
|
||||
|
||||
return self.client.put(
|
||||
path=reverse('name_change'),
|
||||
data=urlencode(data),
|
||||
content_type= 'application/x-www-form-urlencoded'
|
||||
)
|
||||
7
lms/djangoapps/student_profile/urls.py
Normal file
7
lms/djangoapps/student_profile/urls.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.conf.urls import patterns, url
|
||||
|
||||
urlpatterns = patterns(
|
||||
'student_profile.views',
|
||||
url(r'^$', 'index', name='profile_index'),
|
||||
url(r'^name_change$', 'name_change_handler', name='name_change'),
|
||||
)
|
||||
81
lms/djangoapps/student_profile/views.py
Normal file
81
lms/djangoapps/student_profile/views.py
Normal file
@@ -0,0 +1,81 @@
|
||||
""" Views for a student's profile information. """
|
||||
|
||||
from django.http import (
|
||||
QueryDict, HttpResponse,
|
||||
HttpResponseBadRequest, HttpResponseServerError
|
||||
)
|
||||
from django_future.csrf import ensure_csrf_cookie
|
||||
from django.contrib.auth.decorators import login_required
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from edxmako.shortcuts import render_to_response
|
||||
from user_api.api import profile as profile_api
|
||||
from third_party_auth import pipeline
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['GET'])
|
||||
def index(request):
|
||||
"""Render the profile info page.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 200 if successful
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
|
||||
Example usage:
|
||||
|
||||
GET /profile
|
||||
|
||||
"""
|
||||
user = request.user
|
||||
|
||||
return render_to_response(
|
||||
'student_profile/index.html', {
|
||||
'disable_courseware_js': True,
|
||||
'provider_user_states': pipeline.get_provider_user_states(user),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@login_required
|
||||
@require_http_methods(['PUT'])
|
||||
@ensure_csrf_cookie
|
||||
def name_change_handler(request):
|
||||
"""Change the user's name.
|
||||
|
||||
Args:
|
||||
request (HttpRequest)
|
||||
|
||||
Returns:
|
||||
HttpResponse: 204 if successful
|
||||
HttpResponse: 302 if not logged in (redirect to login page)
|
||||
HttpResponse: 400 if the provided name is invalid
|
||||
HttpResponse: 405 if using an unsupported HTTP method
|
||||
HttpResponse: 500 if an unexpected error occurs.
|
||||
|
||||
Example usage:
|
||||
|
||||
PUT /profile/name_change
|
||||
|
||||
"""
|
||||
put = QueryDict(request.body)
|
||||
|
||||
username = request.user.username
|
||||
new_name = put.get('new_name')
|
||||
|
||||
if new_name is None:
|
||||
return HttpResponseBadRequest("Missing param 'new_name'")
|
||||
|
||||
try:
|
||||
profile_api.update_profile(username, full_name=new_name)
|
||||
except profile_api.ProfileInvalidField:
|
||||
return HttpResponseBadRequest()
|
||||
except profile_api.ProfileUserNotFound:
|
||||
return HttpResponseServerError()
|
||||
|
||||
# A 204 is intended to allow input for actions to take place
|
||||
# without causing a change to the user agent's active document view.
|
||||
return HttpResponse(status=204)
|
||||
@@ -294,6 +294,9 @@ FEATURES = {
|
||||
# Video Abstraction Layer used to allow video teams to manage video assets
|
||||
# independently of courseware. https://github.com/edx/edx-val
|
||||
'ENABLE_VIDEO_ABSTRACTION_LAYER_API': False,
|
||||
|
||||
# Enable the new dashboard, account, and profile pages
|
||||
'ENABLE_NEW_DASHBOARD': False,
|
||||
}
|
||||
|
||||
# Ignore static asset files on import which match this pattern
|
||||
@@ -973,11 +976,25 @@ courseware_js = (
|
||||
sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/modules/**/*.js'))
|
||||
)
|
||||
|
||||
main_vendor_js = [
|
||||
|
||||
# Before a student accesses courseware, we do not
|
||||
# need many of the JS dependencies. This includes
|
||||
# only the dependencies used everywhere in the LMS
|
||||
# (including the dashboard/account/profile pages)
|
||||
# Currently, this partially duplicates the "main vendor"
|
||||
# JavaScript file, so only one of the two should be included
|
||||
# on a page at any time.
|
||||
# In the future, we will likely refactor this to use
|
||||
# RequireJS and an optimizer.
|
||||
base_vendor_js = [
|
||||
'js/vendor/jquery.min.js',
|
||||
'js/vendor/jquery.cookie.js',
|
||||
]
|
||||
|
||||
main_vendor_js = base_vendor_js + [
|
||||
'js/vendor/require.js',
|
||||
'js/RequireJS-namespace-undefine.js',
|
||||
'js/vendor/json2.js',
|
||||
'js/vendor/jquery.min.js',
|
||||
'js/vendor/jquery-ui.min.js',
|
||||
'js/vendor/jquery.cookie.js',
|
||||
'js/vendor/jquery.qtip.min.js',
|
||||
@@ -1010,6 +1027,12 @@ open_ended_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/open_end
|
||||
notes_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/notes/**/*.js'))
|
||||
instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/instructor_dashboard/**/*.js'))
|
||||
|
||||
# JavaScript used by the student account and profile pages
|
||||
# These are not courseware, so they do not need many of the courseware-specific
|
||||
# JavaScript modules.
|
||||
student_account_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_account/**/*.js'))
|
||||
student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js'))
|
||||
|
||||
PIPELINE_CSS = {
|
||||
'style-vendor': {
|
||||
'source_filenames': [
|
||||
@@ -1090,9 +1113,6 @@ common_js = set(rooted_glob(COMMON_ROOT / 'static', 'coffee/src/**/*.js')) - set
|
||||
project_js = set(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/**/*.js')) - set(courseware_js + discussion_js + staff_grading_js + open_ended_js + notes_js + instructor_dash_js)
|
||||
|
||||
|
||||
|
||||
# test_order: Determines the position of this chunk of javascript on
|
||||
# the jasmine test page
|
||||
PIPELINE_JS = {
|
||||
'application': {
|
||||
|
||||
@@ -1108,53 +1128,54 @@ PIPELINE_JS = {
|
||||
'js/src/ie_shim.js',
|
||||
],
|
||||
'output_filename': 'js/lms-application.js',
|
||||
|
||||
'test_order': 1,
|
||||
},
|
||||
'courseware': {
|
||||
'source_filenames': courseware_js,
|
||||
'output_filename': 'js/lms-courseware.js',
|
||||
'test_order': 2,
|
||||
},
|
||||
'base_vendor': {
|
||||
'source_filenames': base_vendor_js,
|
||||
'output_filename': 'js/lms-base-vendor.js',
|
||||
},
|
||||
'main_vendor': {
|
||||
'source_filenames': main_vendor_js,
|
||||
'output_filename': 'js/lms-main_vendor.js',
|
||||
'test_order': 0,
|
||||
},
|
||||
'module-descriptor-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static/', 'xmodule/descriptors/js/*.js'),
|
||||
'output_filename': 'js/lms-module-descriptors.js',
|
||||
'test_order': 8,
|
||||
},
|
||||
'module-js': {
|
||||
'source_filenames': rooted_glob(COMMON_ROOT / 'static', 'xmodule/modules/js/*.js'),
|
||||
'output_filename': 'js/lms-modules.js',
|
||||
'test_order': 3,
|
||||
},
|
||||
'discussion': {
|
||||
'source_filenames': discussion_js,
|
||||
'output_filename': 'js/discussion.js',
|
||||
'test_order': 4,
|
||||
},
|
||||
'staff_grading': {
|
||||
'source_filenames': staff_grading_js,
|
||||
'output_filename': 'js/staff_grading.js',
|
||||
'test_order': 5,
|
||||
},
|
||||
'open_ended': {
|
||||
'source_filenames': open_ended_js,
|
||||
'output_filename': 'js/open_ended.js',
|
||||
'test_order': 6,
|
||||
},
|
||||
'notes': {
|
||||
'source_filenames': notes_js,
|
||||
'output_filename': 'js/notes.js',
|
||||
'test_order': 7
|
||||
},
|
||||
'instructor_dash': {
|
||||
'source_filenames': instructor_dash_js,
|
||||
'output_filename': 'js/instructor_dash.js',
|
||||
'test_order': 9,
|
||||
},
|
||||
'student_account': {
|
||||
'source_filenames': student_account_js,
|
||||
'output_filename': 'js/student_account.js'
|
||||
},
|
||||
'student_profile': {
|
||||
'source_filenames': student_profile_js,
|
||||
'output_filename': 'js/student_profile.js'
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1331,6 +1352,7 @@ INSTALLED_APPS = (
|
||||
'circuit',
|
||||
'courseware',
|
||||
'student',
|
||||
|
||||
'static_template_view',
|
||||
'staticbook',
|
||||
'track',
|
||||
|
||||
141
lms/static/js/student_account/account.js
Normal file
141
lms/static/js/student_account/account.js
Normal file
@@ -0,0 +1,141 @@
|
||||
var edx = edx || {};
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
edx.student = edx.student || {};
|
||||
|
||||
edx.student.account = (function() {
|
||||
var _fn = {
|
||||
init: function() {
|
||||
_fn.ajax.init();
|
||||
_fn.eventHandlers.init();
|
||||
},
|
||||
|
||||
eventHandlers: {
|
||||
init: function() {
|
||||
_fn.eventHandlers.submit();
|
||||
},
|
||||
|
||||
submit: function() {
|
||||
$('#email-change-form').submit( _fn.form.submit );
|
||||
}
|
||||
},
|
||||
|
||||
ajax: {
|
||||
init: function() {
|
||||
var csrftoken = _fn.cookie.get( 'csrftoken' );
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if ( settings.type === 'PUT' ) {
|
||||
xhr.setRequestHeader( 'X-CSRFToken', csrftoken );
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
put: function( url, data ) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'PUT',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cookie: {
|
||||
get: function( name ) {
|
||||
return $.cookie(name);
|
||||
}
|
||||
},
|
||||
|
||||
form: {
|
||||
isValid: true,
|
||||
|
||||
submit: function( event ) {
|
||||
var $email = $('#new-email'),
|
||||
$password = $('#password'),
|
||||
data = {
|
||||
new_email: $email.val(),
|
||||
password: $password.val()
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
_fn.form.validate( $('#email-change-form') );
|
||||
|
||||
if ( _fn.form.isValid ) {
|
||||
_fn.ajax.put( 'email_change_request', data );
|
||||
}
|
||||
},
|
||||
|
||||
validate: function( $form ) {
|
||||
_fn.form.isValid = true;
|
||||
$form.find('input').each( _fn.valid.input );
|
||||
}
|
||||
},
|
||||
|
||||
regex: {
|
||||
email: function() {
|
||||
// taken from http://parsleyjs.org/
|
||||
return /^((([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+(\.([a-z]|\d|[!#\$%&'\*\+\-\/=\?\^_`{\|}~]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])+)*)|((\x22)((((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(([\x01-\x08\x0b\x0c\x0e-\x1f\x7f]|\x21|[\x23-\x5b]|[\x5d-\x7e]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(\\([\x01-\x09\x0b\x0c\x0d-\x7f]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF]))))*(((\x20|\x09)*(\x0d\x0a))?(\x20|\x09)+)?(\x22)))@((([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|\d|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))\.)+(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])|(([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])([a-z]|\d|-|\.|_|~|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])*([a-z]|[\u00A0-\uD7FF\uF900-\uFDCF\uFDF0-\uFFEF])))$/i;
|
||||
}
|
||||
},
|
||||
|
||||
valid: {
|
||||
email: function( str ) {
|
||||
var valid = false,
|
||||
len = str ? str.length : 0,
|
||||
regex = _fn.regex.email();
|
||||
|
||||
if ( 0 < len && len < 254 ) {
|
||||
valid = regex.test( str );
|
||||
}
|
||||
|
||||
return valid;
|
||||
},
|
||||
|
||||
input: function() {
|
||||
var $el = $(this),
|
||||
validation = $el.data('validate'),
|
||||
value = $el.val(),
|
||||
valid = true;
|
||||
|
||||
|
||||
if ( validation && validation.length > 0 ) {
|
||||
$el.removeClass('error')
|
||||
.css('border-color', '#c8c8c8'); // temp. for development
|
||||
|
||||
// Required field
|
||||
if ( validation.indexOf('required') > -1 ) {
|
||||
valid = _fn.valid.required( value );
|
||||
}
|
||||
|
||||
// Email address
|
||||
if ( valid && validation.indexOf('email') > -1 ) {
|
||||
valid = _fn.valid.email( value );
|
||||
}
|
||||
|
||||
if ( !valid ) {
|
||||
$el.addClass('error')
|
||||
.css('border-color', '#f00'); // temp. for development
|
||||
_fn.form.isValid = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
required: function( str ) {
|
||||
return ( str && str.length > 0 ) ? true : false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
init: _fn.init
|
||||
};
|
||||
})();
|
||||
|
||||
edx.student.account.init();
|
||||
|
||||
})(jQuery);
|
||||
76
lms/static/js/student_profile/profile.js
Normal file
76
lms/static/js/student_profile/profile.js
Normal file
@@ -0,0 +1,76 @@
|
||||
var edx = edx || {};
|
||||
|
||||
(function($) {
|
||||
'use strict';
|
||||
|
||||
edx.student = edx.student || {};
|
||||
|
||||
edx.student.profile = (function() {
|
||||
|
||||
var _fn = {
|
||||
init: function() {
|
||||
_fn.ajax.init();
|
||||
_fn.eventHandlers.init();
|
||||
},
|
||||
|
||||
eventHandlers: {
|
||||
init: function() {
|
||||
_fn.eventHandlers.submit();
|
||||
},
|
||||
|
||||
submit: function() {
|
||||
$("#name-change-form").submit( _fn.form.submit );
|
||||
}
|
||||
},
|
||||
|
||||
form: {
|
||||
submit: function( event ) {
|
||||
var $newName = $('#new-name');
|
||||
var data = {
|
||||
new_name: $newName.val()
|
||||
};
|
||||
|
||||
event.preventDefault();
|
||||
_fn.ajax.put( 'name_change', data );
|
||||
}
|
||||
},
|
||||
|
||||
ajax: {
|
||||
init: function() {
|
||||
var csrftoken = _fn.cookie.get( 'csrftoken' );
|
||||
|
||||
$.ajaxSetup({
|
||||
beforeSend: function(xhr, settings) {
|
||||
if ( settings.type === 'PUT' ) {
|
||||
xhr.setRequestHeader( 'X-CSRFToken', csrftoken );
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
put: function( url, data ) {
|
||||
$.ajax({
|
||||
url: url,
|
||||
type: 'PUT',
|
||||
data: data
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
cookie: {
|
||||
get: function( name ) {
|
||||
return $.cookie(name);
|
||||
}
|
||||
},
|
||||
|
||||
};
|
||||
|
||||
return {
|
||||
init: _fn.init
|
||||
};
|
||||
|
||||
})();
|
||||
|
||||
edx.student.profile.init();
|
||||
|
||||
})(jQuery);
|
||||
@@ -59,7 +59,11 @@
|
||||
<%static:css group='style-app-extend1'/>
|
||||
<%static:css group='style-app-extend2'/>
|
||||
|
||||
<%static:js group='main_vendor'/>
|
||||
% if disable_courseware_js:
|
||||
<%static:js group='base_vendor'/>
|
||||
% else:
|
||||
<%static:js group='main_vendor'/>
|
||||
% endif
|
||||
|
||||
<%block name="headextra"/>
|
||||
|
||||
@@ -131,8 +135,10 @@
|
||||
<%include file="${footer_file}" />
|
||||
|
||||
<script>window.baseUrl = "${settings.STATIC_URL}";</script>
|
||||
<%static:js group='application'/>
|
||||
<%static:js group='module-js'/>
|
||||
% if not disable_courseware_js:
|
||||
<%static:js group='application'/>
|
||||
<%static:js group='module-js'/>
|
||||
% endif
|
||||
|
||||
<%block name="js_extra"/>
|
||||
|
||||
|
||||
30
lms/templates/student_account/email_change_failed.html
Normal file
30
lms/templates/student_account/email_change_failed.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<section class="container activation">
|
||||
<section class="message">
|
||||
<h1 class="invalid">${_("Email change failed.")}</h1>
|
||||
<hr class="horizontal-divider">
|
||||
|
||||
<p>
|
||||
% if error is 'key_invalid' or error is 'internal':
|
||||
${_("Something went wrong. Please contact {support} for help.").format(
|
||||
support="<a href='mailto:{support_email}'>{support_email}</a>".format(
|
||||
support_email=settings.TECH_SUPPORT_EMAIL
|
||||
)
|
||||
)}
|
||||
% elif error is 'email_used':
|
||||
${_("The email address you wanted to use is already used by another "
|
||||
"{platform_name} account.").format(platform_name=settings.PLATFORM_NAME)}
|
||||
% endif
|
||||
</p>
|
||||
|
||||
<p>
|
||||
${_("You can try again from the {link_start}account settings{link_end} page.").format(
|
||||
link_start="<a href='{url}'>".format(url=reverse('account_index')),
|
||||
link_end="</a>"
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
18
lms/templates/student_account/email_change_successful.html
Normal file
18
lms/templates/student_account/email_change_successful.html
Normal file
@@ -0,0 +1,18 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from django.core.urlresolvers import reverse %>
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<section class="container activation">
|
||||
<section class="message">
|
||||
<h1 class="valid">${_("Email change successful!")}</h1>
|
||||
<hr class="horizontal-divider">
|
||||
|
||||
<p>
|
||||
${_("You should see your new email address listed on the "
|
||||
"{link_start}account settings{link_end} page.").format(
|
||||
link_start="<a href='{url}'>".format(url=reverse('account_index')),
|
||||
link_end="</a>",
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</section>
|
||||
@@ -0,0 +1,19 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
## TODO: Get sign-off from Product on new copy, and think about
|
||||
## turning this into a large, multi-line message for i18n purposes.
|
||||
## Greeting
|
||||
${_("Hi there,")}
|
||||
|
||||
## Preamble
|
||||
${_("You successfully changed the email address associated with your"
|
||||
"{platform_name} account from {old_email} to {new_email}.").format(
|
||||
platform_name=settings.PLATFORM_NAME,
|
||||
old_email=old_email,
|
||||
new_email=new_email
|
||||
)
|
||||
}
|
||||
|
||||
## Farewell
|
||||
${_("Thanks,")}
|
||||
${_("- The edX Team")}
|
||||
@@ -0,0 +1,3 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("{platform_name} Email Change Successful").format(platform_name=settings.PLATFORM_NAME)}
|
||||
@@ -0,0 +1,32 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
## TODO: Get sign-off from Product on new copy, and think about
|
||||
## turning this into a large, multi-line message for i18n purposes.
|
||||
## Greeting
|
||||
${_("Hi there,")}
|
||||
|
||||
## Preamble
|
||||
${_("There was recently a request to change the email address associated "
|
||||
"with your {platform_name} account from {old_email} to {new_email}. "
|
||||
"If you requested this change, please confirm your new email address "
|
||||
"by following the link below:").format(
|
||||
platform_name=settings.PLATFORM_NAME,
|
||||
old_email=old_email,
|
||||
new_email=new_email
|
||||
)
|
||||
}
|
||||
|
||||
## Confirmation link
|
||||
% if is_secure:
|
||||
https://${site}/account/email_change_confirm/${key}
|
||||
% else:
|
||||
http://${site}/account/email_change_confirm/${key}
|
||||
% endif
|
||||
|
||||
## Closing
|
||||
${_("If you don't want to change the email address associated with your "
|
||||
"account, ignore this message.")}
|
||||
|
||||
## Farewell
|
||||
${_("Thanks,")}
|
||||
${_("- The edX Team")}
|
||||
@@ -0,0 +1,3 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
${_("{platform_name} Email Change Request").format(platform_name=settings.PLATFORM_NAME)}
|
||||
28
lms/templates/student_account/index.html
Normal file
28
lms/templates/student_account/index.html
Normal file
@@ -0,0 +1,28 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="pagetitle">${_("Student Account")}</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<%static:js group='student_account'/>
|
||||
</%block>
|
||||
|
||||
<h1>Student Account</h1>
|
||||
|
||||
<p>This is a placeholder for the student's account page.</p>
|
||||
|
||||
<form id="email-change-form" method="post">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
|
||||
<label for="new-email">${_('New Address')}</label>
|
||||
<input id="new-email" type="text" name="new-email" value="" placeholder="xsy@edx.org" data-validate="required email"/>
|
||||
|
||||
<label for="password">${_('Password')}</label>
|
||||
<input id="password" type="password" name="password" value="" data-validate="required"/>
|
||||
|
||||
<div class="submit-button">
|
||||
<input type="submit" id="email-change-submit" value="${_('Change My Email Address')}">
|
||||
</div>
|
||||
</form>
|
||||
68
lms/templates/student_profile/index.html
Normal file
68
lms/templates/student_profile/index.html
Normal file
@@ -0,0 +1,68 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
<%! from third_party_auth import pipeline %>
|
||||
<%namespace name='static' file='/static_content.html'/>
|
||||
|
||||
<%inherit file="../main.html" />
|
||||
|
||||
<%block name="pagetitle">${_("Student Profile")}</%block>
|
||||
|
||||
<%block name="js_extra">
|
||||
<%static:js group='student_profile'/>
|
||||
</%block>
|
||||
|
||||
<h1>Student Profile</h1>
|
||||
|
||||
<p>This is a placeholder for the student's profile page.</p>
|
||||
|
||||
<form id="name-change-form">
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
|
||||
<label for="new-name">${_('Full Name')}</label>
|
||||
<input id="new-name" type="text" name="new-name" value="" placeholder="Xsy" />
|
||||
|
||||
<div class="submit-button">
|
||||
<input type="submit" id="name-change-submit" value="${_('Change My Name')}">
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<li class="controls--account">
|
||||
<span class="title">
|
||||
## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
|
||||
${_("Connected Accounts")}
|
||||
</span>
|
||||
<span class="data">
|
||||
<span class="third-party-auth">
|
||||
% for state in provider_user_states:
|
||||
<div class="auth-provider">
|
||||
<div class="status">
|
||||
% if state.has_account:
|
||||
<i class="icon icon-link"></i> <span class="copy">${_('Linked')}</span>
|
||||
% else:
|
||||
<i class="icon icon-unlink"></i><span class="copy">${_('Not Linked')}</span>
|
||||
% endif
|
||||
</div>
|
||||
<span class="provider">${state.provider.NAME}</span>
|
||||
<span class="control">
|
||||
<form
|
||||
action="${pipeline.get_disconnect_url(state.provider.NAME)}"
|
||||
method="post"
|
||||
name="${state.get_unlink_form_name()}">
|
||||
% if state.has_account:
|
||||
<input type="hidden" name="csrfmiddlewaretoken" value="${csrf_token}">
|
||||
|
||||
<a href="#" onclick="document.${state.get_unlink_form_name()}.submit()">
|
||||
## Translators: clicking on this removes the link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
|
||||
${_("Unlink")}
|
||||
</a>
|
||||
% else:
|
||||
<a href="${pipeline.get_login_url(state.provider.NAME, pipeline.AUTH_ENTRY_PROFILE)}">
|
||||
## Translators: clicking on this creates a link between a user's edX account and their account with an external authentication provider (like Google or LinkedIn).
|
||||
${_("Link")}
|
||||
</a>
|
||||
% endif
|
||||
</form>
|
||||
</span>
|
||||
</div>
|
||||
% endfor
|
||||
</span>
|
||||
</li>
|
||||
@@ -537,6 +537,13 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
|
||||
url(r'', include('third_party_auth.urls')),
|
||||
)
|
||||
|
||||
# If enabled, expose the URLs for the new dashboard, account, and profile pages
|
||||
if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
|
||||
urlpatterns += (
|
||||
url(r'^profile/', include('student_profile.urls')),
|
||||
url(r'^account/', include('student_account.urls')),
|
||||
)
|
||||
|
||||
urlpatterns = patterns(*urlpatterns)
|
||||
|
||||
if settings.DEBUG:
|
||||
|
||||
Reference in New Issue
Block a user