Commit 7f8c6bb0 by Will Daly

Add Django apps for student account and profile.

Add Python APIs for account/profile information to user_api
Updating profile page to have social linking

Authors: Renzo Lucioni, Alasdair Swan, Stephen Sanchez, Will Daly
parent 3cdfdae8
......@@ -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': {},
}
......
"""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
"""
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"""
......
"""
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)
# -*- 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
# -*- 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)
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'),
)
""" 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,
}
)
# -*- 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'
)
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'),
)
""" 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',
......
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);
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"/>
......
<%! 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>
<%! 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>
<%! 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")}
<%! from django.utils.translation import ugettext as _ %>
${_("{platform_name} Email Change Successful").format(platform_name=settings.PLATFORM_NAME)}
<%! 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")}
<%! from django.utils.translation import ugettext as _ %>
${_("{platform_name} Email Change Request").format(platform_name=settings.PLATFORM_NAME)}
<%! 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>
<%! 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:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment