diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 89e81d65292a018612f972bd54ab68fa62753d47..3745d407ea12024a1e8df8d0ce09fcb4e74d26de 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -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' diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index dc9d021e5e467155f8a5c79812ffc438f8d2a3aa..9d472c4ffb04cab0acf873f038ac518180157f47 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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 diff --git a/common/djangoapps/third_party_auth/settings.py b/common/djangoapps/third_party_auth/settings.py index 07e46a3bedeacf22f284ad2b4f8eb10f33b8c929..a9b80f1794569ec270dda204f102722929ac6f09 100644 --- a/common/djangoapps/third_party_auth/settings.py +++ b/common/djangoapps/third_party_auth/settings.py @@ -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 = ( diff --git a/common/djangoapps/third_party_auth/tests/test_settings.py b/common/djangoapps/third_party_auth/tests/test_settings.py index b75e4bc032ae25f61b1d6e516c1b86fe19b5d415..40babdbc1c72e38c72b79a3eebb0a2444ce7468c 100644 --- a/common/djangoapps/third_party_auth/tests/test_settings.py +++ b/common/djangoapps/third_party_auth/tests/test_settings.py @@ -13,6 +13,7 @@ _SETTINGS_MAP = { 'INSTALLED_APPS': _ORIGINAL_INSTALLED_APPS, 'MIDDLEWARE_CLASSES': _ORIGINAL_MIDDLEWARE_CLASSES, 'TEMPLATE_CONTEXT_PROCESSORS': _ORIGINAL_TEMPLATE_CONTEXT_PROCESSORS, + 'FEATURES': {}, } diff --git a/common/djangoapps/user_api/api/__init__.py b/common/djangoapps/user_api/api/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/common/djangoapps/user_api/api/account.py b/common/djangoapps/user_api/api/account.py new file mode 100644 index 0000000000000000000000000000000000000000..aa17e0bf9107c61dc87243600d0661cc2ee38c7f --- /dev/null +++ b/common/djangoapps/user_api/api/account.py @@ -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) + ) + diff --git a/common/djangoapps/user_api/api/profile.py b/common/djangoapps/user_api/api/profile.py new file mode 100644 index 0000000000000000000000000000000000000000..b9b4c934ffb3e279b7ab210c54ec1eb2aacadcb3 --- /dev/null +++ b/common/djangoapps/user_api/api/profile.py @@ -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 diff --git a/common/djangoapps/user_api/helpers.py b/common/djangoapps/user_api/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..1cae378786eda703fb9cb40859e158cf11d1952d --- /dev/null +++ b/common/djangoapps/user_api/helpers.py @@ -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 diff --git a/common/djangoapps/user_api/models.py b/common/djangoapps/user_api/models.py index e5a31db820c1a35662aa88fa058ae91e832c9e1e..3c49b43b9a60e14da0d702fe008021cc7ec04f41 100644 --- a/common/djangoapps/user_api/models.py +++ b/common/djangoapps/user_api/models.py @@ -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""" diff --git a/common/djangoapps/user_api/tests/test_account_api.py b/common/djangoapps/user_api/tests/test_account_api.py new file mode 100644 index 0000000000000000000000000000000000000000..c343749a6f7b1cf747a9b202c510becd6080ec48 --- /dev/null +++ b/common/djangoapps/user_api/tests/test_account_api.py @@ -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 diff --git a/common/djangoapps/user_api/tests/test_helpers.py b/common/djangoapps/user_api/tests/test_helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..7daf5c2a4b08d2127b505971eff90d9bfe04fcde --- /dev/null +++ b/common/djangoapps/user_api/tests/test_helpers.py @@ -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) diff --git a/common/djangoapps/user_api/tests/test_profile_api.py b/common/djangoapps/user_api/tests/test_profile_api.py new file mode 100644 index 0000000000000000000000000000000000000000..7421457a4433e5ad1f08eaa3e0bee7f20f30a888 --- /dev/null +++ b/common/djangoapps/user_api/tests/test_profile_api.py @@ -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 diff --git a/lms/djangoapps/student_account/__init__.py b/lms/djangoapps/student_account/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/student_account/test/__init__.py b/lms/djangoapps/student_account/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..7d8e587a84d4d3e1412ba3162826ceb6ae63852e --- /dev/null +++ b/lms/djangoapps/student_account/test/test_views.py @@ -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) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..90e612926798e5795e4dc5c6542d5470a12e144e --- /dev/null +++ b/lms/djangoapps/student_account/urls.py @@ -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'), +) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py new file mode 100644 index 0000000000000000000000000000000000000000..4fba5f7c8a19ef02b4eb6105e1bbad45559d771f --- /dev/null +++ b/lms/djangoapps/student_account/views.py @@ -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, + } + ) diff --git a/lms/djangoapps/student_profile/__init__.py b/lms/djangoapps/student_profile/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/student_profile/test/__init__.py b/lms/djangoapps/student_profile/test/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py new file mode 100644 index 0000000000000000000000000000000000000000..ffb4ab6404cce47616604cf210d3fd938b771cb8 --- /dev/null +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -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' + ) diff --git a/lms/djangoapps/student_profile/urls.py b/lms/djangoapps/student_profile/urls.py new file mode 100644 index 0000000000000000000000000000000000000000..113e2e89ed2653d50566ee62056d48d054fd78e8 --- /dev/null +++ b/lms/djangoapps/student_profile/urls.py @@ -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'), +) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py new file mode 100644 index 0000000000000000000000000000000000000000..dc58795a48e35774bf4d78780ecbf73581a51bd4 --- /dev/null +++ b/lms/djangoapps/student_profile/views.py @@ -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) diff --git a/lms/envs/common.py b/lms/envs/common.py index 9ab1c5b0b9d1de66cb1478175b53fff5e5441e99..ca3e936e1beff5188aceb6d8ceb64c55a7de2507 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -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', diff --git a/lms/static/js/student_account/account.js b/lms/static/js/student_account/account.js new file mode 100644 index 0000000000000000000000000000000000000000..172f09db7141f3ef92f535b2ecbe5d7c9a585829 --- /dev/null +++ b/lms/static/js/student_account/account.js @@ -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); diff --git a/lms/static/js/student_profile/profile.js b/lms/static/js/student_profile/profile.js new file mode 100644 index 0000000000000000000000000000000000000000..9a7665878a77ac1e4d55257fc83d98ec7b1ff91d --- /dev/null +++ b/lms/static/js/student_profile/profile.js @@ -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); diff --git a/lms/templates/main.html b/lms/templates/main.html index 3c5df9f2d131d7a091e0ae8d8eba227ee9d1df30..4c5d50ef1cbb735592f997853c5fff4f82c6409f 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -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"/> diff --git a/lms/templates/student_account/email_change_failed.html b/lms/templates/student_account/email_change_failed.html new file mode 100644 index 0000000000000000000000000000000000000000..5b24f9c26234466a27200d84e912a2e0a3ab787f --- /dev/null +++ b/lms/templates/student_account/email_change_failed.html @@ -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> diff --git a/lms/templates/student_account/email_change_successful.html b/lms/templates/student_account/email_change_successful.html new file mode 100644 index 0000000000000000000000000000000000000000..5cc4d17444f171580e8a3092aaa4930ddf2131f3 --- /dev/null +++ b/lms/templates/student_account/email_change_successful.html @@ -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> diff --git a/lms/templates/student_account/emails/email_change_confirmation/message_body.txt b/lms/templates/student_account/emails/email_change_confirmation/message_body.txt new file mode 100644 index 0000000000000000000000000000000000000000..c54df4a3690cad3c9de1e3f0d20ef1117b6e0c7e --- /dev/null +++ b/lms/templates/student_account/emails/email_change_confirmation/message_body.txt @@ -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")} diff --git a/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt b/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt new file mode 100644 index 0000000000000000000000000000000000000000..57eeadc97c9b2569bb83914ab8274a0998c1cb9b --- /dev/null +++ b/lms/templates/student_account/emails/email_change_confirmation/subject_line.txt @@ -0,0 +1,3 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("{platform_name} Email Change Successful").format(platform_name=settings.PLATFORM_NAME)} diff --git a/lms/templates/student_account/emails/email_change_request/message_body.txt b/lms/templates/student_account/emails/email_change_request/message_body.txt new file mode 100644 index 0000000000000000000000000000000000000000..9c38042a5b4af61693c013035a4a292aa6285e23 --- /dev/null +++ b/lms/templates/student_account/emails/email_change_request/message_body.txt @@ -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")} diff --git a/lms/templates/student_account/emails/email_change_request/subject_line.txt b/lms/templates/student_account/emails/email_change_request/subject_line.txt new file mode 100644 index 0000000000000000000000000000000000000000..a409534d948aa229858a6131856e0b29ab746358 --- /dev/null +++ b/lms/templates/student_account/emails/email_change_request/subject_line.txt @@ -0,0 +1,3 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("{platform_name} Email Change Request").format(platform_name=settings.PLATFORM_NAME)} diff --git a/lms/templates/student_account/index.html b/lms/templates/student_account/index.html new file mode 100644 index 0000000000000000000000000000000000000000..a3403fb473449e009454906c8cb6e76879a9d14d --- /dev/null +++ b/lms/templates/student_account/index.html @@ -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> diff --git a/lms/templates/student_profile/index.html b/lms/templates/student_profile/index.html new file mode 100644 index 0000000000000000000000000000000000000000..c2d829b0562b15876b2b20d9d9ed6f9a8a868f93 --- /dev/null +++ b/lms/templates/student_profile/index.html @@ -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> diff --git a/lms/urls.py b/lms/urls.py index bedf8e0ba97c318d26f8410af8cb5d41fcc75e18..7c0518da3e7046178f2da96463c71c9b3b8d70c1 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -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: