Commit 4a814c24 by Will Daly

Merge pull request #5948 from edx/will/combine-reg-login-form

Combined Login/Registration Form
parents a89b9bac ffa08453
...@@ -31,6 +31,7 @@ class CourseModeViewTest(ModuleStoreTestCase): ...@@ -31,6 +31,7 @@ class CourseModeViewTest(ModuleStoreTestCase):
self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx") self.user = UserFactory.create(username="Bob", email="bob@example.com", password="edx")
self.client.login(username=self.user.username, password="edx") self.client.login(username=self.user.username, password="edx")
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
@ddt.data( @ddt.data(
# is_active?, enrollment_mode, redirect? # is_active?, enrollment_mode, redirect?
(True, 'verified', True), (True, 'verified', True),
......
"""
Data Aggregation Layer of the Enrollment API. Collects all enrollment specific data into a single
source to be used throughout the API.
"""
import logging
from django.contrib.auth.models import User
from opaque_keys.edx.keys import CourseKey
from xmodule.modulestore.django import modulestore
from enrollment.serializers import CourseEnrollmentSerializer, CourseField
from student.models import CourseEnrollment, NonExistentCourseError
log = logging.getLogger(__name__)
def get_course_enrollments(student_id):
"""Retrieve a list representing all aggregated data for a student's course enrollments.
Construct a representation of all course enrollment data for a specific student.
Args:
student_id (str): The name of the student to retrieve course enrollment information for.
Returns:
A serializable list of dictionaries of all aggregated enrollment data for a student.
"""
qset = CourseEnrollment.objects.filter(
user__username=student_id, is_active=True
).order_by('created')
return CourseEnrollmentSerializer(qset).data # pylint: disable=no-member
def get_course_enrollment(student_id, course_id):
"""Retrieve an object representing all aggregated data for a student's course enrollment.
Get the course enrollment information for a specific student and course.
Args:
student_id (str): The name of the student to retrieve course enrollment information for.
course_id (str): The course to retrieve course enrollment information for.
Returns:
A serializable dictionary representing the course enrollment.
"""
course_key = CourseKey.from_string(course_id)
try:
enrollment = CourseEnrollment.objects.get(
user__username=student_id, course_id=course_key
)
return CourseEnrollmentSerializer(enrollment).data # pylint: disable=no-member
except CourseEnrollment.DoesNotExist:
return None
def update_course_enrollment(student_id, course_id, mode=None, is_active=None):
"""Modify a course enrollment for a student.
Allows updates to a specific course enrollment.
Args:
student_id (str): The name of the student to retrieve course enrollment information for.
course_id (str): The course to retrieve course enrollment information for.
mode (str): (Optional) The mode for the new enrollment.
is_active (boolean): (Optional) Determines if the enrollment is active.
Returns:
A serializable dictionary representing the modified course enrollment.
"""
course_key = CourseKey.from_string(course_id)
student = User.objects.get(username=student_id)
if not CourseEnrollment.is_enrolled(student, course_key):
enrollment = CourseEnrollment.enroll(student, course_key, check_access=True)
else:
enrollment = CourseEnrollment.objects.get(user=student, course_id=course_key)
enrollment.update_enrollment(is_active=is_active, mode=mode)
enrollment.save()
return CourseEnrollmentSerializer(enrollment).data # pylint: disable=no-member
def get_course_enrollment_info(course_id):
"""Returns all course enrollment information for the given course.
Based on the course id, return all related course information..
Args:
course_id (str): The course to retrieve enrollment information for.
Returns:
A serializable dictionary representing the course's enrollment information.
"""
course_key = CourseKey.from_string(course_id)
course = modulestore().get_course(course_key)
if course is None:
log.warning(
u"Requested enrollment information for unknown course {course}"
.format(course=course_id)
)
raise NonExistentCourseError
return CourseField().to_native(course)
"""
A models.py is required to make this an app (until we move to Django 1.7)
"""
"""
Serializers for all Course Enrollment related return objects.
"""
from rest_framework import serializers
from student.models import CourseEnrollment
from course_modes.models import CourseMode
class StringListField(serializers.CharField):
"""Custom Serializer for turning a comma delimited string into a list.
This field is designed to take a string such as "1,2,3" and turn it into an actual list
[1,2,3]
"""
def field_to_native(self, obj, field_name):
"""
Serialize the object's class name.
"""
if not obj.suggested_prices:
return []
items = obj.suggested_prices.split(',')
return [int(item) for item in items]
class CourseField(serializers.RelatedField):
"""Read-Only representation of course enrollment information.
Aggregates course information from the CourseDescriptor as well as the Course Modes configured
for enrolling in the course.
"""
def to_native(self, course):
course_id = unicode(course.id)
course_modes = ModeSerializer(CourseMode.modes_for_course(course.id)).data # pylint: disable=no-member
return {
"course_id": course_id,
"enrollment_start": course.enrollment_start,
"enrollment_end": course.enrollment_end,
"invite_only": course.invitation_only,
"course_modes": course_modes,
}
class CourseEnrollmentSerializer(serializers.ModelSerializer):
"""Serializes CourseEnrollment models
Aggregates all data from the Course Enrollment table, and pulls in the serialization for
the Course Descriptor and course modes, to give a complete representation of course enrollment.
"""
course = CourseField()
student = serializers.SerializerMethodField('get_username')
def get_username(self, model):
"""Retrieves the username from the associated model."""
return model.username
class Meta: # pylint: disable=C0111
model = CourseEnrollment
fields = ('created', 'mode', 'is_active', 'course', 'student')
lookup_field = 'username'
class ModeSerializer(serializers.Serializer):
"""Serializes a course's 'Mode' tuples
Returns a serialized representation of the modes available for course enrollment. The course
modes models are designed to return a tuple instead of the model object itself. This serializer
does not handle the model object itself, but the tuple.
"""
slug = serializers.CharField(max_length=100)
name = serializers.CharField(max_length=255)
min_price = serializers.IntegerField()
suggested_prices = StringListField(max_length=255)
currency = serializers.CharField(max_length=8)
expiration_datetime = serializers.DateTimeField()
description = serializers.CharField()
"""
A Fake Data API for testing purposes.
"""
import copy
import datetime
_DEFAULT_FAKE_MODE = {
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": None,
"description": None
}
_ENROLLMENTS = []
_COURSES = []
# pylint: disable=unused-argument
def get_course_enrollments(student_id):
"""Stubbed out Enrollment data request."""
return _ENROLLMENTS
def get_course_enrollment(student_id, course_id):
"""Stubbed out Enrollment data request."""
return _get_fake_enrollment(student_id, course_id)
def update_course_enrollment(student_id, course_id, mode=None, is_active=None):
"""Stubbed out Enrollment data request."""
enrollment = _get_fake_enrollment(student_id, course_id)
if not enrollment:
enrollment = add_enrollment(student_id, course_id)
if mode is not None:
enrollment['mode'] = mode
if is_active is not None:
enrollment['is_active'] = is_active
return enrollment
def get_course_enrollment_info(course_id):
"""Stubbed out Enrollment data request."""
return _get_fake_course_info(course_id)
def _get_fake_enrollment(student_id, course_id):
"""Get an enrollment from the enrollments array."""
for enrollment in _ENROLLMENTS:
if student_id == enrollment['student'] and course_id == enrollment['course']['course_id']:
return enrollment
def _get_fake_course_info(course_id):
"""Get a course from the courses array."""
for course in _COURSES:
if course_id == course['course_id']:
return course
def add_enrollment(student_id, course_id, is_active=True, mode='honor'):
"""Append an enrollment to the enrollments array."""
enrollment = {
"created": datetime.datetime.now(),
"mode": mode,
"is_active": is_active,
"course": _get_fake_course_info(course_id),
"student": student_id
}
_ENROLLMENTS.append(enrollment)
return enrollment
def add_course(course_id, enrollment_start=None, enrollment_end=None, invite_only=False, course_modes=None):
"""Append course to the courses array."""
course_info = {
"course_id": course_id,
"enrollment_end": enrollment_end,
"course_modes": [],
"enrollment_start": enrollment_start,
"invite_only": invite_only,
}
if not course_modes:
course_info['course_modes'].append(_DEFAULT_FAKE_MODE)
else:
for mode in course_modes:
new_mode = copy.deepcopy(_DEFAULT_FAKE_MODE)
new_mode['slug'] = mode
course_info['course_modes'].append(new_mode)
_COURSES.append(course_info)
def reset():
"""Set the enrollments and courses arrays to be empty."""
global _COURSES # pylint: disable=global-statement
_COURSES = []
global _ENROLLMENTS # pylint: disable=global-statement
_ENROLLMENTS = []
"""
Tests for student enrollment.
"""
import ddt
from nose.tools import raises
import unittest
from django.test import TestCase
from django.test.utils import override_settings
from django.conf import settings
from enrollment import api
from enrollment.tests import fake_data_api
@ddt.ddt
@override_settings(ENROLLMENT_DATA_API="enrollment.tests.fake_data_api")
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class EnrollmentTest(TestCase):
"""
Test student enrollment, especially with different course modes.
"""
USERNAME = "Bob"
COURSE_ID = "some/great/course"
def setUp(self):
fake_data_api.reset()
@ddt.data(
# Default (no course modes in the database)
# Expect automatically being enrolled as "honor".
([], 'honor'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit'], 'honor'),
# Check for professional ed happy path.
(['professional'], 'professional')
)
@ddt.unpack
def test_enroll(self, course_modes, mode):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=course_modes)
# Enroll in the course and verify the URL we get sent to
result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode=mode)
self.assertIsNotNone(result)
self.assertEquals(result['student'], self.USERNAME)
self.assertEquals(result['course']['course_id'], self.COURSE_ID)
self.assertEquals(result['mode'], mode)
get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID)
self.assertEquals(result, get_result)
@raises(api.CourseModeNotFoundError)
def test_prof_ed_enroll(self):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['professional'])
# Enroll in the course and verify the URL we get sent to
api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='verified')
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'honor'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit'], 'honor'),
# Check for professional ed happy path.
(['professional'], 'professional')
)
@ddt.unpack
def test_unenroll(self, course_modes, mode):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=course_modes)
# Enroll in the course and verify the URL we get sent to
result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode=mode)
self.assertIsNotNone(result)
self.assertEquals(result['student'], self.USERNAME)
self.assertEquals(result['course']['course_id'], self.COURSE_ID)
self.assertEquals(result['mode'], mode)
self.assertTrue(result['is_active'])
result = api.deactivate_enrollment(self.USERNAME, self.COURSE_ID)
self.assertIsNotNone(result)
self.assertEquals(result['student'], self.USERNAME)
self.assertEquals(result['course']['course_id'], self.COURSE_ID)
self.assertEquals(result['mode'], mode)
self.assertFalse(result['is_active'])
@raises(api.EnrollmentNotFoundError)
def test_unenroll_not_enrolled_in_course(self):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['honor'])
api.deactivate_enrollment(self.USERNAME, self.COURSE_ID)
@ddt.data(
# Simple test of honor and verified.
([
{'course_id': 'the/first/course', 'course_modes': [], 'mode': 'honor'},
{'course_id': 'the/second/course', 'course_modes': ['honor', 'verified'], 'mode': 'verified'}
]),
# No enrollments
([]),
# One Enrollment
([
{'course_id': 'the/third/course', 'course_modes': ['honor', 'verified', 'audit'], 'mode': 'audit'}
]),
)
def test_get_all_enrollments(self, enrollments):
for enrollment in enrollments:
fake_data_api.add_course(enrollment['course_id'], course_modes=enrollment['course_modes'])
api.add_enrollment(self.USERNAME, enrollment['course_id'], enrollment['mode'])
result = api.get_enrollments(self.USERNAME)
self.assertEqual(len(enrollments), len(result))
for result_enrollment in result:
self.assertIn(
result_enrollment['course']['course_id'],
[enrollment['course_id'] for enrollment in enrollments]
)
def test_update_enrollment(self):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit'])
# Enroll in the course and verify the URL we get sent to
result = api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='audit')
get_result = api.get_enrollment(self.USERNAME, self.COURSE_ID)
self.assertEquals(result, get_result)
result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='honor')
self.assertEquals('honor', result['mode'])
result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='verified')
self.assertEquals('verified', result['mode'])
def test_get_course_details(self):
# Add a fake course enrollment information to the fake data API
fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit'])
result = api.get_course_enrollment_details(self.COURSE_ID)
self.assertEquals(result['course_id'], self.COURSE_ID)
self.assertEquals(3, len(result['course_modes']))
@override_settings(ENROLLMENT_DATA_API='foo.bar.biz.baz')
@raises(api.EnrollmentApiLoadError)
def test_data_api_config_error(self):
# Enroll in the course and verify the URL we get sent to
api.add_enrollment(self.USERNAME, self.COURSE_ID, mode='audit')
"""
Test the Data Aggregation Layer for Course Enrollments.
"""
import ddt
from nose.tools import raises
import unittest
from django.test.utils import override_settings
from django.conf import settings
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment, NonExistentCourseError
from enrollment import data
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class EnrollmentDataTest(ModuleStoreTestCase):
"""
Test course enrollment data aggregation.
"""
USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
def setUp(self):
"""Create a course and user, then log in. """
super(EnrollmentDataTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'honor'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit'], 'honor'),
)
@ddt.unpack
def test_enroll(self, course_modes, enrollment_mode):
# Create the course modes (if any) required for this test case
self._create_course_modes(course_modes)
enrollment = data.update_course_enrollment(
self.user.username,
unicode(self.course.id),
mode=enrollment_mode,
is_active=True
)
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, enrollment_mode)
# Confirm the returned enrollment and the data match up.
self.assertEqual(course_mode, enrollment['mode'])
self.assertEqual(is_active, enrollment['is_active'])
def test_unenroll(self):
# Enroll the student in the course
CourseEnrollment.enroll(self.user, self.course.id, mode="honor")
enrollment = data.update_course_enrollment(
self.user.username,
unicode(self.course.id),
is_active=False
)
# Determine that the returned enrollment is inactive.
self.assertFalse(enrollment['is_active'])
# Expect that we're no longer enrolled
self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course.id))
@ddt.data(
# No course modes, no course enrollments.
([]),
# Audit / Verified / Honor course modes, with three course enrollments.
(['honor', 'verified', 'audit']),
)
def test_get_course_info(self, course_modes):
self._create_course_modes(course_modes, course=self.course)
result_course = data.get_course_enrollment_info(unicode(self.course.id))
result_slugs = [mode['slug'] for mode in result_course['course_modes']]
for course_mode in course_modes:
self.assertIn(course_mode, result_slugs)
@ddt.data(
# No course modes, no course enrollments.
([], []),
# Audit / Verified / Honor course modes, with three course enrollments.
(['honor', 'verified', 'audit'], ['1', '2', '3']),
)
@ddt.unpack
def test_get_course_enrollments(self, course_modes, course_numbers):
# Create all the courses
created_courses = []
for course_number in course_numbers:
created_courses.append(CourseFactory.create(number=course_number))
created_enrollments = []
for course in created_courses:
self._create_course_modes(course_modes, course=course)
# Create the original enrollment.
created_enrollments.append(data.update_course_enrollment(
self.user.username,
unicode(course.id),
))
# Compare the created enrollments with the results
# from the get enrollments request.
results = data.get_course_enrollments(self.user.username)
self.assertEqual(results, created_enrollments)
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'honor'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit'], 'verified'),
)
@ddt.unpack
def test_get_course_enrollment(self, course_modes, enrollment_mode):
self._create_course_modes(course_modes)
# Try to get an enrollment before it exists.
result = data.get_course_enrollment(self.user.username, unicode(self.course.id))
self.assertIsNone(result)
# Create the original enrollment.
enrollment = data.update_course_enrollment(
self.user.username,
unicode(self.course.id),
mode=enrollment_mode,
is_active=True
)
# Get the enrollment and compare it to the original.
result = data.get_course_enrollment(self.user.username, unicode(self.course.id))
self.assertEqual(self.user.username, result['student'])
self.assertEqual(enrollment, result)
@raises(NonExistentCourseError)
def test_non_existent_course(self):
data.get_course_enrollment_info("this/is/bananas")
def _create_course_modes(self, course_modes, course=None):
"""Create the course modes required for a test. """
course_id = course.id if course else self.course.id
for mode_slug in course_modes:
CourseModeFactory.create(
course_id=course_id,
mode_slug=mode_slug,
mode_display_name=mode_slug,
)
"""
Tests for student enrollment.
"""
import ddt
import json
import unittest
from django.test.utils import override_settings
from django.core.urlresolvers import reverse
from rest_framework.test import APITestCase
from rest_framework import status
from django.conf import settings
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import UserFactory, CourseModeFactory
from student.models import CourseEnrollment
# Since we don't need any XML course fixtures, use a modulestore configuration
# that disables the XML modulestore.
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class EnrollmentTest(ModuleStoreTestCase, APITestCase):
"""
Test student enrollment, especially with different course modes.
"""
USERNAME = "Bob"
EMAIL = "bob@example.com"
PASSWORD = "edx"
def setUp(self):
""" Create a course and user, then log in. """
super(EnrollmentTest, self).setUp()
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
self.client.login(username=self.USERNAME, password=self.PASSWORD)
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'honor'),
# Audit / Verified / Honor
# We should always go to the "choose your course" page.
# We should also be enrolled as "honor" by default.
(['honor', 'verified', 'audit'], 'honor'),
)
@ddt.unpack
def test_enroll(self, course_modes, enrollment_mode):
# Create the course modes (if any) required for this test case
for mode_slug in course_modes:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode_slug,
mode_display_name=mode_slug,
)
# Create an enrollment
self._create_enrollment()
self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course.id))
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, enrollment_mode)
def test_enroll_prof_ed(self):
# Create the prod ed mode.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='professional',
mode_display_name='Professional Education',
)
# Enroll in the course, this will fail if the mode is not explicitly professional.
resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))}))
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
# While the enrollment wrong is invalid, the response content should have
# all the valid enrollment modes.
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course_id'])
self.assertEqual(1, len(data['course_modes']))
self.assertEqual('professional', data['course_modes'][0]['slug'])
def test_unenroll(self):
# Create a course mode.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
)
# Create an enrollment
resp = self._create_enrollment()
# Deactivate the enrollment in the course and verify the URL we get sent to
resp = self.client.post(reverse(
'courseenrollment',
kwargs={'course_id': (unicode(self.course.id))}
), {'deactivate': True})
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course']['course_id'])
self.assertEqual('honor', data['mode'])
self.assertFalse(data['is_active'])
def test_user_not_authenticated(self):
# Log out, so we're no longer authenticated
self.client.logout()
# Try to enroll, this should fail.
resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))}))
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
def test_user_not_activated(self):
# Create a user account, but don't activate it
self.user = UserFactory.create(
username="inactive",
email="inactive@example.com",
password=self.PASSWORD,
is_active=False
)
# Log in with the unactivated account
self.client.login(username="inactive", password=self.PASSWORD)
# Enrollment should succeed, even though we haven't authenticated.
resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))}))
self.assertEqual(resp.status_code, 200)
def test_unenroll_not_enrolled_in_course(self):
# Deactivate the enrollment in the course and verify the URL we get sent to
resp = self.client.post(reverse(
'courseenrollment',
kwargs={'course_id': (unicode(self.course.id))}
), {'deactivate': True})
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
def test_invalid_enrollment_mode(self):
# Request an enrollment with verified mode, which does not exist for this course.
resp = self.client.post(reverse(
'courseenrollment',
kwargs={'course_id': (unicode(self.course.id))}),
{'mode': 'verified'}
)
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course_id'])
self.assertEqual('honor', data['course_modes'][0]['slug'])
def test_with_invalid_course_id(self):
# Create an enrollment
resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': 'entirely/fake/course'}))
self.assertEqual(resp.status_code, status.HTTP_400_BAD_REQUEST)
def _create_enrollment(self):
"""Enroll in the course and verify the URL we are sent to. """
resp = self.client.post(reverse('courseenrollment', kwargs={'course_id': (unicode(self.course.id))}))
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course']['course_id'])
self.assertEqual('honor', data['mode'])
self.assertTrue(data['is_active'])
return resp
"""
URLs for the Enrollment API
"""
from django.conf import settings
from django.conf.urls import patterns, url
from .views import get_course_enrollment, list_student_enrollments
urlpatterns = []
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns(
'enrollment.views',
url(r'^student$', list_student_enrollments, name='courseenrollments'),
url(
r'^course/{course_key}$'.format(course_key=settings.COURSE_ID_PATTERN),
get_course_enrollment,
name='courseenrollment'
),
)
"""
The Enrollment API Views should be simple, lean HTTP endpoints for API access. This should
consist primarily of authentication, request validation, and serialization.
"""
from rest_framework import status
from rest_framework.authentication import OAuth2Authentication, SessionAuthentication
from rest_framework.decorators import api_view, authentication_classes, permission_classes, throttle_classes
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.throttling import UserRateThrottle
from enrollment import api
from student.models import NonExistentCourseError, CourseEnrollmentException
class EnrollmentUserThrottle(UserRateThrottle):
"""Limit the number of requests users can make to the enrollment API."""
# TODO Limit significantly after performance testing. # pylint: disable=fixme
rate = '50/second'
class SessionAuthenticationAllowInactiveUser(SessionAuthentication):
"""Ensure that the user is logged in, but do not require the account to be active.
We use this in the special case that a user has created an account,
but has not yet activated it. We still want to allow the user to
enroll in courses, so we remove the usual restriction
on session authentication that requires an active account.
You should use this authentication class ONLY for end-points that
it's safe for an unactived user to access. For example,
we can allow a user to update his/her own enrollments without
activating an account.
"""
def authenticate(self, request):
"""Authenticate the user, requiring a logged-in account and CSRF.
This is exactly the same as the `SessionAuthentication` implementation,
with the `user.is_active` check removed.
Args:
request (HttpRequest)
Returns:
Tuple of `(user, token)`
Raises:
PermissionDenied: The CSRF token check failed.
"""
# Get the underlying HttpRequest object
request = request._request # pylint: disable=protected-access
user = getattr(request, 'user', None)
# Unauthenticated, CSRF validation not required
# This is where regular `SessionAuthentication` checks that the user is active.
# We have removed that check in this implementation.
if not user:
return None
self.enforce_csrf(request)
# CSRF passed with authenticated user
return (user, None)
@api_view(['GET'])
@authentication_classes((OAuth2Authentication, SessionAuthentication))
@permission_classes((IsAuthenticated,))
@throttle_classes([EnrollmentUserThrottle])
def list_student_enrollments(request):
"""List out all the enrollments for the current student
Returns a JSON response with all the course enrollments for the current student.
Args:
request (Request): The GET request for course enrollment listings.
Returns:
A JSON serialized representation of the student's course enrollments.
"""
return Response(api.get_enrollments(request.user.username))
@api_view(['GET', 'POST'])
@authentication_classes((OAuth2Authentication, SessionAuthenticationAllowInactiveUser))
@permission_classes((IsAuthenticated,))
@throttle_classes([EnrollmentUserThrottle])
def get_course_enrollment(request, course_id=None):
"""Create, read, or update enrollment information for a student.
HTTP Endpoint for all CRUD operations for a student course enrollment. Allows creation, reading, and
updates of the current enrollment for a particular course.
Args:
request (Request): To get current course enrollment information, a GET request will return
information for the current user and the specified course. A POST request will create a
new course enrollment for the current user. If 'mode' or 'deactivate' are found in the
POST parameters, the mode can be modified, or the enrollment can be deactivated.
course_id (str): URI element specifying the course location. Enrollment information will be
returned, created, or updated for this particular course.
Return:
A JSON serialized representation of the course enrollment. If this is a new or modified enrollment,
the returned enrollment will reflect all changes.
"""
try:
if 'mode' in request.DATA:
return Response(api.update_enrollment(request.user.username, course_id, request.DATA['mode']))
elif 'deactivate' in request.DATA:
return Response(api.deactivate_enrollment(request.user.username, course_id))
elif course_id and request.method == 'POST':
return Response(api.add_enrollment(request.user.username, course_id))
else:
return Response(api.get_enrollment(request.user.username, course_id))
except api.CourseModeNotFoundError as error:
return Response(status=status.HTTP_400_BAD_REQUEST, data=error.data)
except NonExistentCourseError:
return Response(status=status.HTTP_400_BAD_REQUEST)
except api.EnrollmentNotFoundError:
return Response(status=status.HTTP_400_BAD_REQUEST)
except CourseEnrollmentException:
return Response(status=status.HTTP_400_BAD_REQUEST)
...@@ -4,7 +4,6 @@ import unittest ...@@ -4,7 +4,6 @@ import unittest
from mock import patch from mock import patch
from django.conf import settings from django.conf import settings
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase
import ddt import ddt
from django.test.utils import override_settings from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
...@@ -130,7 +129,7 @@ class LoginFormTest(ModuleStoreTestCase): ...@@ -130,7 +129,7 @@ class LoginFormTest(ModuleStoreTestCase):
@ddt.ddt @ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG) @override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegisterFormTest(TestCase): class RegisterFormTest(ModuleStoreTestCase):
"""Test rendering of the registration form. """ """Test rendering of the registration form. """
def setUp(self): def setUp(self):
......
...@@ -97,6 +97,7 @@ from util.password_policy_validators import ( ...@@ -97,6 +97,7 @@ from util.password_policy_validators import (
validate_password_dictionary validate_password_dictionary
) )
import third_party_auth
from third_party_auth import pipeline, provider from third_party_auth import pipeline, provider
from student.helpers import auth_pipeline_urls, set_logged_in_cookie from student.helpers import auth_pipeline_urls, set_logged_in_cookie
from xmodule.error_module import ErrorDescriptor from xmodule.error_module import ErrorDescriptor
...@@ -413,7 +414,7 @@ def register_user(request, extra_context=None): ...@@ -413,7 +414,7 @@ def register_user(request, extra_context=None):
# If third-party auth is enabled, prepopulate the form with data from the # If third-party auth is enabled, prepopulate the form with data from the
# selected provider. # selected provider.
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request) running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs')) overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
...@@ -630,7 +631,7 @@ def dashboard(request): ...@@ -630,7 +631,7 @@ def dashboard(request):
'provider_states': [], 'provider_states': [],
} }
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')): if third_party_auth.is_enabled():
context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request))
context['provider_user_states'] = pipeline.get_provider_user_states(user) context['provider_user_states'] = pipeline.get_provider_user_states(user)
...@@ -921,7 +922,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un ...@@ -921,7 +922,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
redirect_url = None redirect_url = None
response = None response = None
running_pipeline = None running_pipeline = None
third_party_auth_requested = microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request) third_party_auth_requested = third_party_auth.is_enabled() and pipeline.running(request)
third_party_auth_successful = False third_party_auth_successful = False
trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password')) trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
user = None user = None
...@@ -943,7 +944,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un ...@@ -943,7 +944,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
AUDIT_LOG.warning( AUDIT_LOG.warning(
u'Login failed - user with username {username} has no social auth with backend_name {backend_name}'.format( u'Login failed - user with username {username} has no social auth with backend_name {backend_name}'.format(
username=username, backend_name=backend_name)) username=username, backend_name=backend_name))
return HttpResponseBadRequest( return HttpResponse(
_("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format( _("You've successfully logged into your {provider_name} account, but this account isn't linked with an {platform_name} account yet.").format(
platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME platform_name=settings.PLATFORM_NAME, provider_name=requested_provider.NAME
) )
...@@ -957,7 +958,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un ...@@ -957,7 +958,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
platform_name=settings.PLATFORM_NAME platform_name=settings.PLATFORM_NAME
), ),
content_type="text/plain", content_type="text/plain",
status=401 status=403
) )
else: else:
...@@ -1367,7 +1368,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many ...@@ -1367,7 +1368,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {}) getattr(settings, 'REGISTRATION_EXTRA_FIELDS', {})
) )
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
post_vars = dict(post_vars.items()) post_vars = dict(post_vars.items())
post_vars.update({'password': pipeline.make_random_password()}) post_vars.update({'password': pipeline.make_random_password()})
...@@ -1547,7 +1548,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many ...@@ -1547,7 +1548,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
# If the user is registering via 3rd party auth, track which provider they use # If the user is registering via 3rd party auth, track which provider they use
provider_name = None provider_name = None
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request) running_pipeline = pipeline.get(request)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend')) current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME provider_name = current_provider.NAME
...@@ -1636,7 +1637,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many ...@@ -1636,7 +1637,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
redirect_url = try_change_enrollment(request) redirect_url = try_change_enrollment(request)
# Resume the third-party-auth pipeline if necessary. # Resume the third-party-auth pipeline if necessary.
if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')) and pipeline.running(request): if third_party_auth.is_enabled() and pipeline.running(request):
running_pipeline = pipeline.get(request) running_pipeline = pipeline.get(request)
redirect_url = pipeline.get_complete_url(running_pipeline['backend']) redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
......
...@@ -9,10 +9,17 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware): ...@@ -9,10 +9,17 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware):
"""Custom middleware that handles conditional redirection.""" """Custom middleware that handles conditional redirection."""
def get_redirect_uri(self, request, exception): def get_redirect_uri(self, request, exception):
# Fall back to django settings's SOCIAL_AUTH_LOGIN_ERROR_URL.
redirect_uri = super(ExceptionMiddleware, self).get_redirect_uri(request, exception)
# Safe because it's already been validated by # Safe because it's already been validated by
# pipeline.parse_query_params. If that pipeline step ever moves later # pipeline.parse_query_params. If that pipeline step ever moves later
# in the pipeline stack, we'd need to validate this value because it # in the pipeline stack, we'd need to validate this value because it
# would be an injection point for attacker data. # would be an injection point for attacker data.
auth_entry = request.session.get(pipeline.AUTH_ENTRY_KEY) auth_entry = request.session.get(pipeline.AUTH_ENTRY_KEY)
# Fall back to django settings's SOCIAL_AUTH_LOGIN_ERROR_URL.
return '/' + auth_entry if auth_entry else super(ExceptionMiddleware, self).get_redirect_uri(request, exception) # Check if we have an auth entry key we can use instead
if auth_entry and auth_entry in pipeline.AUTH_DISPATCH_URLS:
redirect_uri = pipeline.AUTH_DISPATCH_URLS[auth_entry]
return redirect_uri
...@@ -110,14 +110,57 @@ AUTH_ENTRY_DASHBOARD = 'dashboard' ...@@ -110,14 +110,57 @@ AUTH_ENTRY_DASHBOARD = 'dashboard'
AUTH_ENTRY_LOGIN = 'login' AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_PROFILE = 'profile' AUTH_ENTRY_PROFILE = 'profile'
AUTH_ENTRY_REGISTER = 'register' AUTH_ENTRY_REGISTER = 'register'
# pylint: disable=fixme
# TODO (ECOM-369): Replace `AUTH_ENTRY_LOGIN` and `AUTH_ENTRY_REGISTER`
# with these values once the A/B test completes, then delete
# these constants.
AUTH_ENTRY_LOGIN_2 = 'account_login'
AUTH_ENTRY_REGISTER_2 = 'account_register'
AUTH_ENTRY_API = 'api' AUTH_ENTRY_API = 'api'
# URLs associated with auth entry points
# These are used to request additional user information
# (for example, account credentials when logging in),
# and when the user cancels the auth process
# (e.g., refusing to grant permission on the provider's login page).
# We don't use "reverse" here because doing so may cause modules
# to load that depend on this module.
AUTH_DISPATCH_URLS = {
AUTH_ENTRY_DASHBOARD: '/dashboard',
AUTH_ENTRY_LOGIN: '/login',
AUTH_ENTRY_REGISTER: '/register',
# TODO (ECOM-369): Replace the dispatch URLs
# for `AUTH_ENTRY_LOGIN` and `AUTH_ENTRY_REGISTER`
# with these values, but DO NOT DELETE THESE KEYS.
AUTH_ENTRY_LOGIN_2: '/account/login/',
AUTH_ENTRY_REGISTER_2: '/account/register/',
# If linking/unlinking an account from the new student profile
# page, redirect to the profile page. Only used if
# `FEATURES['ENABLE_NEW_DASHBOARD']` is true.
AUTH_ENTRY_PROFILE: '/profile/',
}
_AUTH_ENTRY_CHOICES = frozenset([ _AUTH_ENTRY_CHOICES = frozenset([
AUTH_ENTRY_DASHBOARD, AUTH_ENTRY_DASHBOARD,
AUTH_ENTRY_LOGIN, AUTH_ENTRY_LOGIN,
AUTH_ENTRY_PROFILE, AUTH_ENTRY_PROFILE,
AUTH_ENTRY_REGISTER, AUTH_ENTRY_REGISTER,
# TODO (ECOM-369): For the A/B test of the combined
# login/registration, we needed to introduce two
# additional end-points. Once the test completes,
# delete these constants from the choices list.
# pylint: disable=fixme
AUTH_ENTRY_LOGIN_2,
AUTH_ENTRY_REGISTER_2,
AUTH_ENTRY_API, AUTH_ENTRY_API,
]) ])
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12 _DEFAULT_RANDOM_PASSWORD_LENGTH = 12
_PASSWORD_CHARSET = string.letters + string.digits _PASSWORD_CHARSET = string.letters + string.digits
...@@ -401,9 +444,23 @@ def parse_query_params(strategy, response, *args, **kwargs): ...@@ -401,9 +444,23 @@ def parse_query_params(strategy, response, *args, **kwargs):
'is_profile': auth_entry == AUTH_ENTRY_PROFILE, 'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
# Whether the auth pipeline entered from an API # Whether the auth pipeline entered from an API
'is_api': auth_entry == AUTH_ENTRY_API, 'is_api': auth_entry == AUTH_ENTRY_API,
}
# TODO (ECOM-369): Delete these once the A/B test
# for the combined login/registration form completes.
# pylint: disable=fixme
'is_login_2': auth_entry == AUTH_ENTRY_LOGIN_2,
'is_register_2': auth_entry == AUTH_ENTRY_REGISTER_2,
}
# TODO (ECOM-369): Once the A/B test of the combined login/registration
# form completes, we will be able to remove the extra login/registration
# end-points. HOWEVER, users who used the new forms during the A/B
# test may still have values for "is_login_2" and "is_register_2"
# in their sessions. For this reason, we need to continue accepting
# these kwargs in `redirect_to_supplementary_form`, but
# these should redirect to the same location as "is_login" and "is_register"
# (whichever login/registration end-points win in the test).
# pylint: disable=fixme
@partial.partial @partial.partial
def ensure_user_information( def ensure_user_information(
strategy, strategy,
...@@ -414,6 +471,8 @@ def ensure_user_information( ...@@ -414,6 +471,8 @@ def ensure_user_information(
is_login=None, is_login=None,
is_profile=None, is_profile=None,
is_register=None, is_register=None,
is_login_2=None,
is_register_2=None,
is_api=None, is_api=None,
user=None, user=None,
*args, *args,
...@@ -435,7 +494,6 @@ def ensure_user_information( ...@@ -435,7 +494,6 @@ def ensure_user_information(
# It is important that we always execute the entire pipeline. Even if # It is important that we always execute the entire pipeline. Even if
# behavior appears correct without executing a step, it means important # behavior appears correct without executing a step, it means important
# invariants have been violated and future misbehavior is likely. # invariants have been violated and future misbehavior is likely.
user_inactive = user and not user.is_active user_inactive = user and not user.is_active
user_unset = user is None user_unset = user is None
dispatch_to_login = is_login and (user_unset or user_inactive) dispatch_to_login = is_login and (user_unset or user_inactive)
...@@ -445,14 +503,28 @@ def ensure_user_information( ...@@ -445,14 +503,28 @@ def ensure_user_information(
# Content doesn't matter; we just want to exit the pipeline # Content doesn't matter; we just want to exit the pipeline
return HttpResponseBadRequest() return HttpResponseBadRequest()
# TODO (ECOM-369): Consolidate this with `dispatch_to_login`
# once the A/B test completes. # pylint: disable=fixme
dispatch_to_login_2 = is_login_2 and (user_unset or user_inactive)
if is_dashboard or is_profile: if is_dashboard or is_profile:
return return
if dispatch_to_login: if dispatch_to_login:
return redirect('/login', name='signin_user') return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN], name='signin_user')
# TODO (ECOM-369): Consolidate this with `dispatch_to_login`
# once the A/B test completes. # pylint: disable=fixme
if dispatch_to_login_2:
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_LOGIN_2])
if is_register and user_unset: if is_register and user_unset:
return redirect('/register', name='register_user') return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER], name='register_user')
# TODO (ECOM-369): Consolidate this with `is_register`
# once the A/B test completes. # pylint: disable=fixme
if is_register_2 and user_unset:
return redirect(AUTH_DISPATCH_URLS[AUTH_ENTRY_REGISTER_2])
@partial.partial @partial.partial
...@@ -509,6 +581,12 @@ def login_analytics(strategy, *args, **kwargs): ...@@ -509,6 +581,12 @@ def login_analytics(strategy, *args, **kwargs):
'is_login': 'edx.bi.user.account.authenticated', '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', 'is_profile': 'edx.bi.user.account.linked',
# Backwards compatibility: during an A/B test for the combined
# login/registration form, we introduced a new login end-point.
# Since users may continue to have this in their sessions after
# the test concludes, we need to continue accepting this action.
'is_login_2': 'edx.bi.user.account.authenticated',
} }
# Note: we assume only one of the `action` kwargs (is_dashboard, is_login) to be # Note: we assume only one of the `action` kwargs (is_dashboard, is_login) to be
......
...@@ -142,7 +142,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -142,7 +142,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual('link' if linked else 'unlink', icon_state) self.assertEqual('link' if linked else 'unlink', icon_state)
self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name) self.assertEqual(self.PROVIDER_CLASS.NAME, provider_name)
def assert_exception_redirect_looks_correct(self, auth_entry=None): def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None):
"""Tests middleware conditional redirection. """Tests middleware conditional redirection.
middleware.ExceptionMiddleware makes sure the user ends up in the right middleware.ExceptionMiddleware makes sure the user ends up in the right
...@@ -157,13 +157,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -157,13 +157,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual(302, response.status_code) self.assertEqual(302, response.status_code)
self.assertIn('canceled', location) self.assertIn('canceled', location)
self.assertIn(self.backend_name, location) self.assertIn(self.backend_name, location)
self.assertTrue(location.startswith(expected_uri + '?'))
if auth_entry:
# Custom redirection to form.
self.assertTrue(location.startswith('/' + auth_entry))
else:
# Stock framework redirection to root.
self.assertTrue(location.startswith('/?'))
def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None): def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None):
"""Asserts first party auth was used in place of third party auth. """Asserts first party auth was used in place of third party auth.
...@@ -220,7 +214,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -220,7 +214,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_json_failure_response_is_missing_social_auth(self, response): def assert_json_failure_response_is_missing_social_auth(self, response):
"""Asserts failure on /login for missing social auth looks right.""" """Asserts failure on /login for missing social auth looks right."""
self.assertEqual(401, response.status_code) self.assertEqual(403, response.status_code)
self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content) self.assertIn("successfully logged into your %s account, but this account isn't linked" % self.PROVIDER_CLASS.NAME, response.content)
def assert_json_failure_response_is_username_collision(self, response): def assert_json_failure_response_is_username_collision(self, response):
...@@ -410,13 +404,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase): ...@@ -410,13 +404,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Actual tests, executed once per child. # Actual tests, executed once per child.
def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self): def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self):
self.assert_exception_redirect_looks_correct(auth_entry=pipeline.AUTH_ENTRY_LOGIN) self.assert_exception_redirect_looks_correct('/login', auth_entry=pipeline.AUTH_ENTRY_LOGIN)
def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self): def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self):
self.assert_exception_redirect_looks_correct(auth_entry=pipeline.AUTH_ENTRY_REGISTER) self.assert_exception_redirect_looks_correct('/register', auth_entry=pipeline.AUTH_ENTRY_REGISTER)
def test_canceling_authentication_redirects_to_login_when_auth_login_2(self):
self.assert_exception_redirect_looks_correct('/account/login/', auth_entry=pipeline.AUTH_ENTRY_LOGIN_2)
def test_canceling_authentication_redirects_to_login_when_auth_register_2(self):
self.assert_exception_redirect_looks_correct('/account/register/', auth_entry=pipeline.AUTH_ENTRY_REGISTER_2)
def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self):
self.assert_exception_redirect_looks_correct() self.assert_exception_redirect_looks_correct('/')
def test_full_pipeline_succeeds_for_linking_account(self): def test_full_pipeline_succeeds_for_linking_account(self):
# First, create, the request and strategy that store pipeline state, # First, create, the request and strategy that store pipeline state,
......
...@@ -4,7 +4,9 @@ Utilities for writing third_party_auth tests. ...@@ -4,7 +4,9 @@ Utilities for writing third_party_auth tests.
Used by Django and non-Django tests; must not have Django deps. Used by Django and non-Django tests; must not have Django deps.
""" """
from contextlib import contextmanager
import unittest import unittest
import mock
from third_party_auth import provider from third_party_auth import provider
...@@ -37,3 +39,81 @@ class TestCase(unittest.TestCase): ...@@ -37,3 +39,81 @@ class TestCase(unittest.TestCase):
provider.Registry._reset() provider.Registry._reset()
provider.Registry.configure_once(self._original_providers) provider.Registry.configure_once(self._original_providers)
super(TestCase, self).tearDown() super(TestCase, self).tearDown()
@contextmanager
def simulate_running_pipeline(pipeline_target, backend, email=None, fullname=None, username=None):
"""Simulate that a pipeline is currently running.
You can use this context manager to test packages that rely on third party auth.
This uses `mock.patch` to override some calls in `third_party_auth.pipeline`,
so you will need to provide the "target" module *as it is imported*
in the software under test. For example, if `foo/bar.py` does this:
>>> from third_party_auth import pipeline
then you will need to do something like this:
>>> with simulate_running_pipeline("foo.bar.pipeline", "google-oauth2"):
>>> bar.do_something_with_the_pipeline()
If, on the other hand, `foo/bar.py` had done this:
>>> import third_party_auth
then you would use the target "foo.bar.third_party_auth.pipeline" instead.
Arguments:
pipeline_target (string): The path to `third_party_auth.pipeline` as it is imported
in the software under test.
backend (string): The name of the backend currently running, for example "google-oauth2".
Note that this is NOT the same as the name of the *provider*. See the Python
social auth documentation for the names of the backends.
Keyword Arguments:
email (string): If provided, simulate that the current provider has
included the user's email address (useful for filling in the registration form).
fullname (string): If provided, simulate that the current provider has
included the user's full name (useful for filling in the registration form).
username (string): If provided, simulate that the pipeline has provided
this suggested username. This is something that the `third_party_auth`
app generates itself and should be available by the time the user
is authenticating with a third-party provider.
Returns:
None
"""
pipeline_data = {
"backend": backend,
"kwargs": {
"details": {}
}
}
if email is not None:
pipeline_data["kwargs"]["details"]["email"] = email
if fullname is not None:
pipeline_data["kwargs"]["details"]["fullname"] = fullname
if username is not None:
pipeline_data["kwargs"]["username"] = username
pipeline_get = mock.patch("{pipeline}.get".format(pipeline=pipeline_target), spec=True)
pipeline_running = mock.patch("{pipeline}.running".format(pipeline=pipeline_target), spec=True)
mock_get = pipeline_get.start()
mock_running = pipeline_running.start()
mock_get.return_value = pipeline_data
mock_running.return_value = True
try:
yield
finally:
pipeline_get.stop()
pipeline_running.stop()
"""Python API for user accounts. """Python API for user accounts.
Account information includes a student's username, password, and email Account information includes a student's username, password, and email
address, but does NOT include user profile information (i.e., demographic address, but does NOT include user profile information (i.e., demographic
information and preferences). information and preferences).
...@@ -141,6 +142,34 @@ def create_account(username, password, email): ...@@ -141,6 +142,34 @@ def create_account(username, password, email):
return registration.activation_key return registration.activation_key
def check_account_exists(username=None, email=None):
"""Check whether an account with a particular username or email already exists.
Keyword Arguments:
username (unicode)
email (unicode)
Returns:
list of conflicting fields
Example Usage:
>>> account_api.check_account_exists(username="bob")
[]
>>> account_api.check_account_exists(username="ted", email="ted@example.com")
["email", "username"]
"""
conflicts = []
if email is not None and User.objects.filter(email=email).exists():
conflicts.append("email")
if username is not None and User.objects.filter(username=username).exists():
conflicts.append("username")
return conflicts
@intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError]) @intercept_errors(AccountInternalError, ignore_errors=[AccountRequestError])
def account_info(username): def account_info(username):
"""Retrieve information about a user's account. """Retrieve information about a user's account.
......
...@@ -53,6 +53,10 @@ def set_course_tag(user, course_id, key, value): ...@@ -53,6 +53,10 @@ def set_course_tag(user, course_id, key, value):
key: arbitrary (<=255 char string) key: arbitrary (<=255 char string)
value: arbitrary string value: arbitrary string
""" """
# pylint: disable=W0511
# TODO: There is a risk of IntegrityErrors being thrown here given
# simultaneous calls from many processes. Handle by retrying after
# a short delay?
record, _ = UserCourseTag.objects.get_or_create( record, _ = UserCourseTag.objects.get_or_create(
user=user, user=user,
...@@ -61,6 +65,3 @@ def set_course_tag(user, course_id, key, value): ...@@ -61,6 +65,3 @@ def set_course_tag(user, course_id, key, value):
record.value = value record.value = value
record.save() record.save()
# TODO: There is a risk of IntegrityErrors being thrown here given
# simultaneous calls from many processes. Handle by retrying after a short delay?
...@@ -65,9 +65,15 @@ def profile_info(username): ...@@ -65,9 +65,15 @@ def profile_info(username):
return None return None
profile_dict = { profile_dict = {
u'username': profile.user.username, "username": profile.user.username,
u'email': profile.user.email, "email": profile.user.email,
u'full_name': profile.name, "full_name": profile.name,
"level_of_education": profile.level_of_education,
"mailing_address": profile.mailing_address,
"year_of_birth": profile.year_of_birth,
"goals": profile.goals,
"city": profile.city,
"country": unicode(profile.country),
} }
return profile_dict return profile_dict
......
"""Constants used in the test suite. """
SORTED_COUNTRIES = [
(u'AF', u'Afghanistan'),
(u'AL', u'Albania'),
(u'DZ', u'Algeria'),
(u'AS', u'American Samoa'),
(u'AD', u'Andorra'),
(u'AO', u'Angola'),
(u'AI', u'Anguilla'),
(u'AQ', u'Antarctica'),
(u'AG', u'Antigua and Barbuda'),
(u'AR', u'Argentina'),
(u'AM', u'Armenia'),
(u'AW', u'Aruba'),
(u'AU', u'Australia'),
(u'AT', u'Austria'),
(u'AZ', u'Azerbaijan'),
(u'BS', u'Bahamas'),
(u'BH', u'Bahrain'),
(u'BD', u'Bangladesh'),
(u'BB', u'Barbados'),
(u'BY', u'Belarus'),
(u'BE', u'Belgium'),
(u'BZ', u'Belize'),
(u'BJ', u'Benin'),
(u'BM', u'Bermuda'),
(u'BT', u'Bhutan'),
(u'BO', u'Bolivia, Plurinational State of'),
(u'BQ', u'Bonaire, Sint Eustatius and Saba'),
(u'BA', u'Bosnia and Herzegovina'),
(u'BW', u'Botswana'),
(u'BV', u'Bouvet Island'),
(u'BR', u'Brazil'),
(u'IO', u'British Indian Ocean Territory'),
(u'BN', u'Brunei Darussalam'),
(u'BG', u'Bulgaria'),
(u'BF', u'Burkina Faso'),
(u'BI', u'Burundi'),
(u'KH', u'Cambodia'),
(u'CM', u'Cameroon'),
(u'CA', u'Canada'),
(u'CV', u'Cape Verde'),
(u'KY', u'Cayman Islands'),
(u'CF', u'Central African Republic'),
(u'TD', u'Chad'),
(u'CL', u'Chile'),
(u'CN', u'China'),
(u'CX', u'Christmas Island'),
(u'CC', u'Cocos (Keeling) Islands'),
(u'CO', u'Colombia'),
(u'KM', u'Comoros'),
(u'CG', u'Congo'),
(u'CD', u'Congo (the Democratic Republic of the)'),
(u'CK', u'Cook Islands'),
(u'CR', u'Costa Rica'),
(u'HR', u'Croatia'),
(u'CU', u'Cuba'),
(u'CW', u'Cura\xe7ao'),
(u'CY', u'Cyprus'),
(u'CZ', u'Czech Republic'),
(u'CI', u"C\xf4te d'Ivoire"),
(u'DK', u'Denmark'),
(u'DJ', u'Djibouti'),
(u'DM', u'Dominica'),
(u'DO', u'Dominican Republic'),
(u'EC', u'Ecuador'),
(u'EG', u'Egypt'),
(u'SV', u'El Salvador'),
(u'GQ', u'Equatorial Guinea'),
(u'ER', u'Eritrea'),
(u'EE', u'Estonia'),
(u'ET', u'Ethiopia'),
(u'FK', u'Falkland Islands [Malvinas]'),
(u'FO', u'Faroe Islands'),
(u'FJ', u'Fiji'),
(u'FI', u'Finland'),
(u'FR', u'France'),
(u'GF', u'French Guiana'),
(u'PF', u'French Polynesia'),
(u'TF', u'French Southern Territories'),
(u'GA', u'Gabon'),
(u'GM', u'Gambia (The)'),
(u'GE', u'Georgia'),
(u'DE', u'Germany'),
(u'GH', u'Ghana'),
(u'GI', u'Gibraltar'),
(u'GR', u'Greece'),
(u'GL', u'Greenland'),
(u'GD', u'Grenada'),
(u'GP', u'Guadeloupe'),
(u'GU', u'Guam'),
(u'GT', u'Guatemala'),
(u'GG', u'Guernsey'),
(u'GN', u'Guinea'),
(u'GW', u'Guinea-Bissau'),
(u'GY', u'Guyana'),
(u'HT', u'Haiti'),
(u'HM', u'Heard Island and McDonald Islands'),
(u'VA', u'Holy See [Vatican City State]'),
(u'HN', u'Honduras'),
(u'HK', u'Hong Kong'),
(u'HU', u'Hungary'),
(u'IS', u'Iceland'),
(u'IN', u'India'),
(u'ID', u'Indonesia'),
(u'IR', u'Iran (the Islamic Republic of)'),
(u'IQ', u'Iraq'),
(u'IE', u'Ireland'),
(u'IM', u'Isle of Man'),
(u'IL', u'Israel'),
(u'IT', u'Italy'),
(u'JM', u'Jamaica'),
(u'JP', u'Japan'),
(u'JE', u'Jersey'),
(u'JO', u'Jordan'),
(u'KZ', u'Kazakhstan'),
(u'KE', u'Kenya'),
(u'KI', u'Kiribati'),
(u'KP', u"Korea (the Democratic People's Republic of)"),
(u'KR', u'Korea (the Republic of)'),
(u'KW', u'Kuwait'),
(u'KG', u'Kyrgyzstan'),
(u'LA', u"Lao People's Democratic Republic"),
(u'LV', u'Latvia'),
(u'LB', u'Lebanon'),
(u'LS', u'Lesotho'),
(u'LR', u'Liberia'),
(u'LY', u'Libya'),
(u'LI', u'Liechtenstein'),
(u'LT', u'Lithuania'),
(u'LU', u'Luxembourg'),
(u'MO', u'Macao'),
(u'MK', u'Macedonia (the former Yugoslav Republic of)'),
(u'MG', u'Madagascar'),
(u'MW', u'Malawi'),
(u'MY', u'Malaysia'),
(u'MV', u'Maldives'),
(u'ML', u'Mali'),
(u'MT', u'Malta'),
(u'MH', u'Marshall Islands'),
(u'MQ', u'Martinique'),
(u'MR', u'Mauritania'),
(u'MU', u'Mauritius'),
(u'YT', u'Mayotte'),
(u'MX', u'Mexico'),
(u'FM', u'Micronesia (the Federated States of)'),
(u'MD', u'Moldova (the Republic of)'),
(u'MC', u'Monaco'),
(u'MN', u'Mongolia'),
(u'ME', u'Montenegro'),
(u'MS', u'Montserrat'),
(u'MA', u'Morocco'),
(u'MZ', u'Mozambique'),
(u'MM', u'Myanmar'),
(u'NA', u'Namibia'),
(u'NR', u'Nauru'),
(u'NP', u'Nepal'),
(u'NL', u'Netherlands'),
(u'NC', u'New Caledonia'),
(u'NZ', u'New Zealand'),
(u'NI', u'Nicaragua'),
(u'NE', u'Niger'),
(u'NG', u'Nigeria'),
(u'NU', u'Niue'),
(u'NF', u'Norfolk Island'),
(u'MP', u'Northern Mariana Islands'),
(u'NO', u'Norway'),
(u'OM', u'Oman'),
(u'PK', u'Pakistan'),
(u'PW', u'Palau'),
(u'PS', u'Palestine, State of'),
(u'PA', u'Panama'),
(u'PG', u'Papua New Guinea'),
(u'PY', u'Paraguay'),
(u'PE', u'Peru'),
(u'PH', u'Philippines'),
(u'PN', u'Pitcairn'),
(u'PL', u'Poland'),
(u'PT', u'Portugal'),
(u'PR', u'Puerto Rico'),
(u'QA', u'Qatar'),
(u'RO', u'Romania'),
(u'RU', u'Russian Federation'),
(u'RW', u'Rwanda'),
(u'RE', u'R\xe9union'),
(u'BL', u'Saint Barth\xe9lemy'),
(u'SH', u'Saint Helena, Ascension and Tristan da Cunha'),
(u'KN', u'Saint Kitts and Nevis'),
(u'LC', u'Saint Lucia'),
(u'MF', u'Saint Martin (French part)'),
(u'PM', u'Saint Pierre and Miquelon'),
(u'VC', u'Saint Vincent and the Grenadines'),
(u'WS', u'Samoa'),
(u'SM', u'San Marino'),
(u'ST', u'Sao Tome and Principe'),
(u'SA', u'Saudi Arabia'),
(u'SN', u'Senegal'),
(u'RS', u'Serbia'),
(u'SC', u'Seychelles'),
(u'SL', u'Sierra Leone'),
(u'SG', u'Singapore'),
(u'SX', u'Sint Maarten (Dutch part)'),
(u'SK', u'Slovakia'),
(u'SI', u'Slovenia'),
(u'SB', u'Solomon Islands'),
(u'SO', u'Somalia'),
(u'ZA', u'South Africa'),
(u'GS', u'South Georgia and the South Sandwich Islands'),
(u'SS', u'South Sudan'),
(u'ES', u'Spain'),
(u'LK', u'Sri Lanka'),
(u'SD', u'Sudan'),
(u'SR', u'Suriname'),
(u'SJ', u'Svalbard and Jan Mayen'),
(u'SZ', u'Swaziland'),
(u'SE', u'Sweden'),
(u'CH', u'Switzerland'),
(u'SY', u'Syrian Arab Republic'),
(u'TW', u'Taiwan'),
(u'TJ', u'Tajikistan'),
(u'TZ', u'Tanzania, United Republic of'),
(u'TH', u'Thailand'),
(u'TL', u'Timor-Leste'),
(u'TG', u'Togo'),
(u'TK', u'Tokelau'),
(u'TO', u'Tonga'),
(u'TT', u'Trinidad and Tobago'),
(u'TN', u'Tunisia'),
(u'TR', u'Turkey'),
(u'TM', u'Turkmenistan'),
(u'TC', u'Turks and Caicos Islands'),
(u'TV', u'Tuvalu'),
(u'UG', u'Uganda'),
(u'UA', u'Ukraine'),
(u'AE', u'United Arab Emirates'),
(u'GB', u'United Kingdom'),
(u'US', u'United States'),
(u'UM', u'United States Minor Outlying Islands'),
(u'UY', u'Uruguay'),
(u'UZ', u'Uzbekistan'),
(u'VU', u'Vanuatu'),
(u'VE', u'Venezuela, Bolivarian Republic of'),
(u'VN', u'Viet Nam'),
(u'VG', u'Virgin Islands (British)'),
(u'VI', u'Virgin Islands (U.S.)'),
(u'WF', u'Wallis and Futuna'),
(u'EH', u'Western Sahara'),
(u'YE', u'Yemen'),
(u'ZM', u'Zambia'),
(u'ZW', u'Zimbabwe'),
(u'AX', u'\xc5land Islands')
]
""" """
Test the user service Test the user course tag API.
""" """
from django.test import TestCase from django.test import TestCase
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from user_api import user_service from user_api.api import course_tag as course_tag_api
from opaque_keys.edx.locations import SlashSeparatedCourseKey from opaque_keys.edx.locations import SlashSeparatedCourseKey
...@@ -19,17 +19,17 @@ class TestUserService(TestCase): ...@@ -19,17 +19,17 @@ class TestUserService(TestCase):
def test_get_set_course_tag(self): def test_get_set_course_tag(self):
# get a tag that doesn't exist # get a tag that doesn't exist
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key) tag = course_tag_api.get_course_tag(self.user, self.course_id, self.test_key)
self.assertIsNone(tag) self.assertIsNone(tag)
# test setting a new key # test setting a new key
test_value = 'value' test_value = 'value'
user_service.set_course_tag(self.user, self.course_id, self.test_key, test_value) course_tag_api.set_course_tag(self.user, self.course_id, self.test_key, test_value)
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key) tag = course_tag_api.get_course_tag(self.user, self.course_id, self.test_key)
self.assertEqual(tag, test_value) self.assertEqual(tag, test_value)
#test overwriting an existing key #test overwriting an existing key
test_value = 'value2' test_value = 'value2'
user_service.set_course_tag(self.user, self.course_id, self.test_key, test_value) course_tag_api.set_course_tag(self.user, self.course_id, self.test_key, test_value)
tag = user_service.get_course_tag(self.user, self.course_id, self.test_key) tag = course_tag_api.get_course_tag(self.user, self.course_id, self.test_key)
self.assertEqual(tag, test_value) self.assertEqual(tag, test_value)
""" """
Tests for helper functions. Tests for helper functions.
""" """
import json
import mock import mock
import ddt
from django.test import TestCase from django.test import TestCase
from nose.tools import raises from nose.tools import raises
from user_api.helpers import intercept_errors from django.http import HttpRequest, HttpResponse
from user_api.helpers import (
intercept_errors, shim_student_view,
FormDescription, InvalidFieldError
)
class FakeInputException(Exception): class FakeInputException(Exception):
"""Fake exception that should be intercepted. """ """Fake exception that should be intercepted."""
pass pass
class FakeOutputException(Exception): class FakeOutputException(Exception):
"""Fake exception that should be raised. """ """Fake exception that should be raised."""
pass pass
...@@ -30,9 +36,7 @@ def intercepted_function(raise_error=None): ...@@ -30,9 +36,7 @@ def intercepted_function(raise_error=None):
class InterceptErrorsTest(TestCase): class InterceptErrorsTest(TestCase):
""" """Tests for the decorator that intercepts errors."""
Tests for the decorator that intercepts errors.
"""
@raises(FakeOutputException) @raises(FakeOutputException)
def test_intercepts_errors(self): def test_intercepts_errors(self):
...@@ -64,3 +68,149 @@ class InterceptErrorsTest(TestCase): ...@@ -64,3 +68,149 @@ class InterceptErrorsTest(TestCase):
# This will include the stack trace for the original exception # This will include the stack trace for the original exception
# because it's called with log level "ERROR" # because it's called with log level "ERROR"
mock_logger.exception.assert_called_once_with(expected_log_msg) mock_logger.exception.assert_called_once_with(expected_log_msg)
class FormDescriptionTest(TestCase):
"""Tests of helper functions which generate form descriptions."""
def test_to_json(self):
desc = FormDescription("post", "/submit")
desc.add_field(
"name",
label="label",
field_type="text",
default="default",
placeholder="placeholder",
instructions="instructions",
required=True,
restrictions={
"min_length": 2,
"max_length": 10
},
error_messages={
"required": "You must provide a value!"
}
)
self.assertEqual(desc.to_json(), json.dumps({
"method": "post",
"submit_url": "/submit",
"fields": [
{
"name": "name",
"label": "label",
"type": "text",
"defaultValue": "default",
"placeholder": "placeholder",
"instructions": "instructions",
"required": True,
"restrictions": {
"min_length": 2,
"max_length": 10,
},
"errorMessages": {
"required": "You must provide a value!"
}
}
]
}))
def test_invalid_field_type(self):
desc = FormDescription("post", "/submit")
with self.assertRaises(InvalidFieldError):
desc.add_field("invalid", field_type="invalid")
def test_missing_options(self):
desc = FormDescription("post", "/submit")
with self.assertRaises(InvalidFieldError):
desc.add_field("name", field_type="select")
def test_invalid_restriction(self):
desc = FormDescription("post", "/submit")
with self.assertRaises(InvalidFieldError):
desc.add_field("name", field_type="text", restrictions={"invalid": 0})
@ddt.ddt
class StudentViewShimTest(TestCase):
"Tests of the student view shim."
def setUp(self):
self.captured_request = None
def test_strip_enrollment_action(self):
view = self._shimmed_view(HttpResponse())
request = HttpRequest()
request.POST["enrollment_action"] = "enroll"
request.POST["course_id"] = "edx/101/demo"
view(request)
# Expect that the enrollment action and course ID
# were stripped out before reaching the wrapped view.
self.assertNotIn("enrollment_action", self.captured_request.POST)
self.assertNotIn("course_id", self.captured_request.POST)
def test_include_analytics_info(self):
view = self._shimmed_view(HttpResponse())
request = HttpRequest()
request.POST["analytics"] = json.dumps({
"enroll_course_id": "edX/DemoX/Fall"
})
view(request)
# Expect that the analytics course ID was passed to the view
self.assertEqual(self.captured_request.POST.get("course_id"), "edX/DemoX/Fall")
def test_third_party_auth_login_failure(self):
view = self._shimmed_view(
HttpResponse(status=403),
check_logged_in=True
)
response = view(HttpRequest())
self.assertEqual(response.status_code, 403)
self.assertEqual(response.content, "third-party-auth")
def test_non_json_response(self):
view = self._shimmed_view(HttpResponse(content="Not a JSON dict"))
response = view(HttpRequest())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, "Not a JSON dict")
@ddt.data("redirect", "redirect_url")
def test_ignore_redirect_from_json(self, redirect_key):
view = self._shimmed_view(
HttpResponse(content=json.dumps({
"success": True,
redirect_key: "/redirect"
}))
)
response = view(HttpRequest())
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, "")
def test_error_from_json(self):
view = self._shimmed_view(
HttpResponse(content=json.dumps({
"success": False,
"value": "Error!"
}))
)
response = view(HttpRequest())
self.assertEqual(response.status_code, 400)
self.assertEqual(response.content, "Error!")
def test_preserve_headers(self):
view_response = HttpResponse()
view_response["test-header"] = "test"
view = self._shimmed_view(view_response)
response = view(HttpRequest())
self.assertEqual(response["test-header"], "test")
def test_check_logged_in(self):
view = self._shimmed_view(HttpResponse(), check_logged_in=True)
response = view(HttpRequest())
self.assertEqual(response.status_code, 403)
def _shimmed_view(self, response, check_logged_in=False): # pylint: disable=missing-docstring
def stub_view(request): # pylint: disable=missing-docstring
self.captured_request = request
return response
return shim_student_view(stub_view, check_logged_in=check_logged_in)
...@@ -28,6 +28,12 @@ class ProfileApiTest(TestCase): ...@@ -28,6 +28,12 @@ class ProfileApiTest(TestCase):
'username': self.USERNAME, 'username': self.USERNAME,
'email': self.EMAIL, 'email': self.EMAIL,
'full_name': u'', 'full_name': u'',
'goals': None,
'level_of_education': None,
'mailing_address': None,
'year_of_birth': None,
'country': '',
'city': None,
}) })
def test_update_full_name(self): def test_update_full_name(self):
......
# pylint: disable=missing-docstring
from django.conf import settings
from django.conf.urls import include, patterns, url from django.conf.urls import include, patterns, url
from rest_framework import routers from rest_framework import routers
from user_api import views as user_api_views from user_api import views as user_api_views
...@@ -19,3 +21,11 @@ urlpatterns = patterns( ...@@ -19,3 +21,11 @@ urlpatterns = patterns(
user_api_views.ForumRoleUsersListView.as_view() user_api_views.ForumRoleUsersListView.as_view()
), ),
) )
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns(
'',
url(r'^v1/account/login_session/$', user_api_views.LoginSessionView.as_view(), name="user_api_login_session"),
url(r'^v1/account/registration/$', user_api_views.RegistrationView.as_view(), name="user_api_registration"),
url(r'^v1/account/password_reset/$', user_api_views.PasswordResetView.as_view(), name="user_api_password_reset"),
)
...@@ -19,10 +19,9 @@ class UrlResetMixin(object): ...@@ -19,10 +19,9 @@ class UrlResetMixin(object):
that affect the contents of urls.py that affect the contents of urls.py
""" """
def _reset_urls(self, urlconf=None): def _reset_urls(self, urlconf_modules):
if urlconf is None: """Reset `urls.py` for a set of Django apps."""
urlconf = settings.ROOT_URLCONF for urlconf in urlconf_modules:
if urlconf in sys.modules: if urlconf in sys.modules:
reload(sys.modules[urlconf]) reload(sys.modules[urlconf])
clear_url_caches() clear_url_caches()
...@@ -30,8 +29,29 @@ class UrlResetMixin(object): ...@@ -30,8 +29,29 @@ class UrlResetMixin(object):
# Resolve a URL so that the new urlconf gets loaded # Resolve a URL so that the new urlconf gets loaded
resolve('/') resolve('/')
def setUp(self, **kwargs): def setUp(self, *args, **kwargs):
"""Reset django default urlconf before tests and after tests""" """Reset Django urls before tests and after tests
If you need to reset `urls.py` from a particular Django app (or apps),
specify these modules in *args.
Examples:
# Reload only the root urls.py
super(MyTestCase, self).setUp()
# Reload urls from my_app
super(MyTestCase, self).setUp("my_app.urls")
# Reload urls from my_app and another_app
super(MyTestCase, self).setUp("my_app.urls", "another_app.urls")
"""
super(UrlResetMixin, self).setUp(**kwargs) super(UrlResetMixin, self).setUp(**kwargs)
self._reset_urls()
self.addCleanup(self._reset_urls) urlconf_modules = [settings.ROOT_URLCONF]
if args:
urlconf_modules.extend(args)
self._reset_urls(urlconf_modules)
self.addCleanup(lambda: self._reset_urls(urlconf_modules))
describe('edx.utils.validate', function () {
'use strict';
var fixture = null,
field = null,
result = null,
MIN_LENGTH = 2,
MAX_LENGTH = 20,
VALID_STRING = 'xsy_is_awesome',
SHORT_STRING = 'x',
LONG_STRING = 'xsy_is_way_too_awesome',
EMAIL_ERROR_FRAGMENT = 'formatted',
MIN_ERROR_FRAGMENT = 'least',
MAX_ERROR_FRAGMENT = 'up to',
REQUIRED_ERROR_FRAGMENT = 'empty',
CUSTOM_MESSAGE = 'custom message';
var createFixture = function( type, name, required, minlength, maxlength, value ) {
setFixtures('<input id="field" type=' + type + '>');
field = $('#field');
field.prop('required', required);
field.attr({
name: name,
minlength: minlength,
maxlength: maxlength,
value: value
});
};
var expectValid = function() {
result = edx.utils.validate(field);
expect(result.isValid).toBe(true);
};
var expectInvalid = function( errorFragment ) {
result = edx.utils.validate(field);
expect(result.isValid).toBe(false);
expect(result.message).toMatch(errorFragment);
};
it('succeeds if an optional field is left blank', function () {
createFixture('text', 'username', false, MIN_LENGTH, MAX_LENGTH, '');
expectValid();
});
it('succeeds if a required field is provided a valid value', function () {
createFixture('text', 'username', true, MIN_LENGTH, MAX_LENGTH, VALID_STRING);
expectValid();
});
it('fails if a required field is left blank', function () {
createFixture('text', 'username', true, MIN_LENGTH, MAX_LENGTH, '');
expectInvalid(REQUIRED_ERROR_FRAGMENT);
});
it('fails if a field is provided a value below its minimum character limit', function () {
createFixture('text', 'username', false, MIN_LENGTH, MAX_LENGTH, SHORT_STRING);
// Verify optional field behavior
expectInvalid(MIN_ERROR_FRAGMENT);
// Verify required field behavior
field.prop('required', true);
expectInvalid(MIN_ERROR_FRAGMENT);
});
it('succeeds if a field with no minimum character limit is provided a value below its maximum character limit', function () {
createFixture('text', 'username', false, null, MAX_LENGTH, SHORT_STRING);
// Verify optional field behavior
expectValid();
// Verify required field behavior
field.prop('required', true);
expectValid();
});
it('fails if a required field with no minimum character limit is left blank', function () {
createFixture('text', 'username', true, null, MAX_LENGTH, '');
expectInvalid(REQUIRED_ERROR_FRAGMENT);
});
it('fails if a field is provided a value above its maximum character limit', function () {
createFixture('text', 'username', false, MIN_LENGTH, MAX_LENGTH, LONG_STRING);
// Verify optional field behavior
expectInvalid(MAX_ERROR_FRAGMENT);
// Verify required field behavior
field.prop('required', true);
expectInvalid(MAX_ERROR_FRAGMENT);
});
it('succeeds if a field with no maximum character limit is provided a value above its minimum character limit', function () {
createFixture('text', 'username', false, MIN_LENGTH, null, LONG_STRING);
// Verify optional field behavior
expectValid();
// Verify required field behavior
field.prop('required', true);
expectValid();
});
it('succeeds if a field with no character limits is provided a value', function () {
createFixture('text', 'username', false, null, null, VALID_STRING);
// Verify optional field behavior
expectValid();
// Verify required field behavior
field.prop('required', true);
expectValid();
});
it('fails if an email field is provided an invalid address', function () {
createFixture('email', 'email', false, MIN_LENGTH, MAX_LENGTH, 'localpart');
// Verify optional field behavior
expectInvalid(EMAIL_ERROR_FRAGMENT);
// Verify required field behavior
field.prop('required', false);
expectInvalid(EMAIL_ERROR_FRAGMENT);
});
it('succeeds if an email field is provided a valid address', function () {
createFixture('email', 'email', false, MIN_LENGTH, MAX_LENGTH, 'localpart@label.tld');
// Verify optional field behavior
expectValid();
// Verify required field behavior
field.prop('required', true);
expectValid();
});
it('succeeds if a checkbox is optional, or required and checked, but fails if a required checkbox is unchecked', function () {
createFixture('checkbox', 'checkbox', false, null, null, 'value');
// Optional, unchecked
expectValid();
// Optional, checked
field.prop('checked', true);
expectValid();
// Required, checked
field.prop('required', true);
expectValid();
// Required, unchecked
field.prop('checked', false);
expectInvalid(REQUIRED_ERROR_FRAGMENT);
});
it('succeeds if a select is optional, or required and default is selected, but fails if a required select has the default option selected', function () {
var select = [
'<select id="dropdown" name="country">',
'<option value="" data-isdefault="true">Please select a country</option>',
'<option value="BE">Belgium</option>',
'<option value="DE">Germany</option>',
'</select>'
].join('');
setFixtures(select);
field = $('#dropdown');
// Optional
expectValid();
// Required, default text selected
field.attr('required', true);
expectInvalid(REQUIRED_ERROR_FRAGMENT);
// Required, country selected
field.val('BE');
expectValid();
});
it('returns a custom error message if an invalid field has one attached', function () {
// Create a blank required field
createFixture('text', 'username', true, MIN_LENGTH, MAX_LENGTH, '');
// Attach a custom error message to the field
field.data('errormsg-required', CUSTOM_MESSAGE);
expectInvalid(CUSTOM_MESSAGE);
});
});
var edx = edx || {};
(function( $, _, _s, gettext ) {
'use strict';
/* Mix non-conflicting functions from underscore.string
* (all but include, contains, and reverse) into the
* Underscore namespace. In practice, this mixin is done
* by the access view, but doing it here helps keep the
* utility self-contained.
*/
_.mixin( _.str.exports() );
edx.utils = edx.utils || {};
var utils = (function(){
var _fn = {
validate: {
msg: {
email: '<li><%- gettext("The email address you\'ve provided isn\'t formatted correctly.") %></li>',
min: '<li><%- _.sprintf(gettext("%(field)s must have at least %(count)d characters."), context) %></li>',
max: '<li><%- _.sprintf(gettext("%(field)s can only contain up to %(count)d characters."), context) %></li>',
required: '<li><%- _.sprintf(gettext("The %(field)s field cannot be empty."), context) %></li>',
custom: '<li><%= content %></li>'
},
field: function( el ) {
var $el = $(el),
required = true,
min = true,
max = true,
email = true,
response = {},
isBlank = _fn.validate.isBlank( $el );
if ( _fn.validate.isRequired( $el ) ) {
if ( isBlank ) {
required = false;
} else {
min = _fn.validate.str.minlength( $el );
max = _fn.validate.str.maxlength( $el );
email = _fn.validate.email.valid( $el );
}
} else if ( !isBlank ) {
min = _fn.validate.str.minlength( $el );
max = _fn.validate.str.maxlength( $el );
email = _fn.validate.email.valid( $el );
}
response.isValid = required && min && max && email;
if ( !response.isValid ) {
_fn.validate.removeDefault( $el );
response.message = _fn.validate.getMessage( $el, {
required: required,
min: min,
max: max,
email: email
});
}
return response;
},
str: {
minlength: function( $el ) {
var min = $el.attr('minlength') || 0;
return min <= $el.val().length;
},
maxlength: function( $el ) {
var max = $el.attr('maxlength') || false;
return ( !!max ) ? max >= $el.val().length : true;
}
},
isRequired: function( $el ) {
return $el.attr('required');
},
isBlank: function( $el ) {
var type = $el.attr('type'),
isBlank;
if ( type === 'checkbox' ) {
isBlank = !$el.prop('checked');
} else if ( type === 'select' ) {
isBlank = ( $el.data('isdefault') === true );
} else {
isBlank = !$el.val();
}
return isBlank;
},
email: {
// This is the same regex used to validate email addresses in Django 1.4
regex: new RegExp(
[
'(^[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+(\\.[-!#$%&\'*+/=?^_`{}|~0-9A-Z]+)*',
'|^"([\\001-\\010\\013\\014\\016-\\037!#-\\[\\]-\\177]|\\\\[\\001-\\011\\013\\014\\016-\\177])*"',
')@((?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\\.)+[A-Z]{2,6}\\.?$)',
'|\\[(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)(\\.(25[0-5]|2[0-4]\\d|[0-1]?\\d?\\d)){3}\\]$'
].join(''), 'i'
),
valid: function( $el ) {
return $el.attr('type') === 'email' ? _fn.validate.email.format( $el.val() ) : true;
},
format: function( str ) {
return _fn.validate.email.regex.test( str );
}
},
getLabel: function( id ) {
// Extract the field label, remove the asterisk (if it appears) and any extra whitespace
return $("label[for=" + id + "]").text().split("*")[0].trim();
},
getMessage: function( $el, tests ) {
var txt = [],
tpl,
label,
obj,
customMsg;
_.each( tests, function( value, key ) {
if ( !value ) {
label = _fn.validate.getLabel( $el.attr('id') );
customMsg = $el.data('errormsg-' + key) || false;
// If the field has a custom error msg attached, use it
if ( customMsg ) {
tpl = _fn.validate.msg.custom;
obj = {
content: customMsg
};
} else {
tpl = _fn.validate.msg[key];
obj = {
// We pass the context object to the template so that
// we can perform variable interpolation using sprintf
context: {
field: label
}
};
if ( key === 'min' ) {
obj.context.count = parseInt( $el.attr('minlength'), 10 );
} else if ( key === 'max' ) {
obj.context.count = parseInt( $el.attr('maxlength'), 10 );
}
}
txt.push( _.template( tpl, obj ) );
}
});
return txt.join(' ');
},
// Removes the default HTML5 validation pop-up
removeDefault: function( $el ) {
if ( $el.setCustomValidity ) {
$el.setCustomValidity(' ');
}
}
}
};
return {
validate: _fn.validate.field
};
})();
edx.utils.validate = utils.validate;
})( jQuery, _, _.str, gettext );
/**
* Adds rwd classes and click handlers.
*/
(function($) {
'use strict';
var rwd = (function() {
var _fn = {
header: 'header.global-new',
footer: '.edx-footer-new',
resultsUrl: 'course-search',
init: function() {
_fn.$header = $( _fn.header );
_fn.$footer = $( _fn.footer );
_fn.$nav = _fn.$header.find('nav');
_fn.$globalNav = _fn.$nav.find('.nav-global');
_fn.add.elements();
_fn.add.classes();
_fn.eventHandlers.init();
},
add: {
classes: function() {
// Add any RWD-specific classes
_fn.$header.addClass('rwd');
_fn.$footer.addClass('rwd');
},
elements: function() {
_fn.add.burger();
_fn.add.registerLink();
},
burger: function() {
_fn.$nav.prepend([
'<a href="#" class="mobile-menu-button" aria-label="menu">',
'<i class="icon-reorder" aria-hidden="true"></i>',
'</a>'
].join(''));
},
registerLink: function() {
var $register = _fn.$nav.find('.cta-register'),
$li = {},
$a = {},
count = 0;
// Add if register link is shown
if ( $register.length > 0 ) {
count = _fn.$globalNav.find('li').length + 1;
// Create new li
$li = $('<li/>');
$li.addClass('desktop-hide nav-global-0' + count);
// Clone register link and remove classes
$a = $register.clone();
$a.removeClass();
// append to DOM
$a.appendTo( $li );
_fn.$globalNav.append( $li );
}
}
},
eventHandlers: {
init: function() {
_fn.eventHandlers.click();
},
click: function() {
// Toggle menu
_fn.$nav.on( 'click', '.mobile-menu-button', _fn.toggleMenu );
}
},
toggleMenu: function( event ) {
event.preventDefault();
_fn.$globalNav.toggleClass('show');
}
};
return {
init: _fn.init
};
})();
rwd.init();
})(jQuery);
/*! url - v1.8.4 - 2013-08-14 */window.url=function(){function a(a){return!isNaN(parseFloat(a))&&isFinite(a)}return function(b,c){var d=c||window.location.toString();if(!b)return d;b=b.toString(),"//"===d.substring(0,2)?d="http:"+d:1===d.split("://").length&&(d="http://"+d),c=d.split("/");var e={auth:""},f=c[2].split("@");1===f.length?f=f[0].split(":"):(e.auth=f[0],f=f[1].split(":")),e.protocol=c[0],e.hostname=f[0],e.port=f[1]||"80",e.pathname=(c.length>3?"/":"")+c.slice(3,c.length).join("/").split("?")[0].split("#")[0];var g=e.pathname;"/"===g.charAt(g.length-1)&&(g=g.substring(0,g.length-1));var h=e.hostname,i=h.split("."),j=g.split("/");if("hostname"===b)return h;if("domain"===b)return i.slice(-2).join(".");if("sub"===b)return i.slice(0,i.length-2).join(".");if("port"===b)return e.port||"80";if("protocol"===b)return e.protocol.split(":")[0];if("auth"===b)return e.auth;if("user"===b)return e.auth.split(":")[0];if("pass"===b)return e.auth.split(":")[1]||"";if("path"===b)return e.pathname;if("."===b.charAt(0)){if(b=b.substring(1),a(b))return b=parseInt(b,10),i[0>b?i.length+b:b-1]||""}else{if(a(b))return b=parseInt(b,10),j[0>b?j.length+b:b]||"";if("file"===b)return j.slice(-1)[0];if("filename"===b)return j.slice(-1)[0].split(".")[0];if("fileext"===b)return j.slice(-1)[0].split(".")[1]||"";if("?"===b.charAt(0)||"#"===b.charAt(0)){var k=d,l=null;if("?"===b.charAt(0)?k=(k.split("?")[1]||"").split("#")[0]:"#"===b.charAt(0)&&(k=k.split("#")[1]||""),!b.charAt(1))return k;b=b.substring(1),k=k.split("&");for(var m=0,n=k.length;n>m;m++)if(l=k[m].split("="),l[0]===b)return l[1]||"";return null}}return""}}(),"undefined"!=typeof jQuery&&jQuery.extend({url:function(a,b){return window.url(a,b)}});
\ No newline at end of file
...@@ -34,6 +34,7 @@ lib_paths: ...@@ -34,6 +34,7 @@ lib_paths:
- js/vendor/jquery.truncate.js - js/vendor/jquery.truncate.js
- js/vendor/mustache.js - js/vendor/mustache.js
- js/vendor/underscore-min.js - js/vendor/underscore-min.js
- js/vendor/underscore.string.min.js
- js/vendor/backbone-min.js - js/vendor/backbone-min.js
- js/vendor/jquery.timeago.js - js/vendor/jquery.timeago.js
- js/vendor/URI.min.js - js/vendor/URI.min.js
...@@ -46,6 +47,7 @@ lib_paths: ...@@ -46,6 +47,7 @@ lib_paths:
src_paths: src_paths:
- coffee/src - coffee/src
- js/src - js/src
- js/utils
- js/capa/src - js/capa/src
# Paths to spec (test) JavaScript files # Paths to spec (test) JavaScript files
......
...@@ -3,7 +3,7 @@ Course about page (with registration button) ...@@ -3,7 +3,7 @@ Course about page (with registration button)
""" """
from .course_page import CoursePage from .course_page import CoursePage
from .register import RegisterPage from .login_and_register import RegisterPage
class CourseAboutPage(CoursePage): class CourseAboutPage(CoursePage):
......
"""Login and Registration pages """
from urllib import urlencode
from bok_choy.page_object import PageObject, unguarded
from bok_choy.promise import Promise, EmptyPromise
from . import BASE_URL
from .dashboard import DashboardPage
class RegisterPage(PageObject):
"""
Registration page (create a new account)
"""
def __init__(self, browser, course_id):
"""
Course ID is currently of the form "edx/999/2013_Spring"
but this format could change.
"""
super(RegisterPage, self).__init__(browser)
self._course_id = course_id
@property
def url(self):
"""
URL for the registration page of a course.
"""
return "{base}/register?course_id={course_id}&enrollment_action={action}".format(
base=BASE_URL,
course_id=self._course_id,
action="enroll",
)
def is_browser_on_page(self):
return any([
'register' in title.lower()
for title in self.q(css='span.title-sub').text
])
def provide_info(self, email, password, username, full_name):
"""
Fill in registration info.
`email`, `password`, `username`, and `full_name` are the user's credentials.
"""
self.q(css='input#email').fill(email)
self.q(css='input#password').fill(password)
self.q(css='input#username').fill(username)
self.q(css='input#name').fill(full_name)
self.q(css='input#tos-yes').first.click()
self.q(css='input#honorcode-yes').first.click()
self.q(css="#country option[value='US']").first.click()
def submit(self):
"""
Submit registration info to create an account.
"""
self.q(css='button#submit').first.click()
# The next page is the dashboard; make sure it loads
dashboard = DashboardPage(self.browser)
dashboard.wait_for_page()
return dashboard
class CombinedLoginAndRegisterPage(PageObject):
"""Interact with combined login and registration page.
This page is currently hidden behind the feature flag
`ENABLE_COMBINED_LOGIN_REGISTRATION`, which is enabled
in the bok choy settings.
When enabled, the new page is available from either
`/account/login` or `/account/register`.
Users can reach this page while attempting to enroll
in a course, in which case users will be auto-enrolled
when they successfully authenticate (unless the course
has been paywalled).
"""
def __init__(self, browser, start_page="register", course_id=None):
"""Initialize the page.
Arguments:
browser (Browser): The browser instance.
Keyword Args:
start_page (str): Whether to start on the login or register page.
course_id (unicode): If provided, load the page as if the user
is trying to enroll in a course.
"""
super(CombinedLoginAndRegisterPage, self).__init__(browser)
self._course_id = course_id
if start_page not in ["register", "login"]:
raise ValueError("Start page must be either 'register' or 'login'")
self._start_page = start_page
@property
def url(self):
"""Return the URL for the combined login/registration page. """
url = "{base}/account/{login_or_register}".format(
base=BASE_URL,
login_or_register=self._start_page
)
# These are the parameters that would be included if the user
# were trying to enroll in a course.
if self._course_id is not None:
url += "?{params}".format(
params=urlencode({
"course_id": self._course_id,
"enrollment_action": "enroll"
})
)
return url
def is_browser_on_page(self):
"""Check whether the combined login/registration page has loaded. """
return (
self.q(css="#register-option").is_present() and
self.q(css="#login-option").is_present() and
self.current_form is not None
)
def toggle_form(self):
"""Toggle between the login and registration forms. """
old_form = self.current_form
# Toggle the form
self.q(css=".form-toggle:not(:checked)").click()
# Wait for the form to change before returning
EmptyPromise(
lambda: self.current_form != old_form,
"Finish toggling to the other form"
).fulfill()
def register(self, email="", password="", username="", full_name="", country="", terms_of_service=False):
"""Fills in and submits the registration form.
Requires that the "register" form is visible.
This does NOT wait for the next page to load,
so the caller should wait for the next page
(or errors if that's the expected behavior.)
Keyword Arguments:
email (unicode): The user's email address.
password (unicode): The user's password.
username (unicode): The user's username.
full_name (unicode): The user's full name.
country (unicode): Two-character country code.
terms_of_service (boolean): If True, agree to the terms of service and honor code.
"""
# Fill in the form
self.q(css="#register-email").fill(email)
self.q(css="#register-password").fill(password)
self.q(css="#register-username").fill(username)
self.q(css="#register-name").fill(full_name)
if country:
self.q(css="#register-country option[value='{country}']".format(country=country)).click()
if (terms_of_service):
self.q(css="#register-honor_code").click()
# Submit it
self.q(css=".register-button").click()
def login(self, email="", password="", remember_me=True):
"""Fills in and submits the login form.
Requires that the "login" form is visible.
This does NOT wait for the next page to load,
so the caller should wait for the next page
(or errors if that's the expected behavior).
Keyword Arguments:
email (unicode): The user's email address.
password (unicode): The user's password.
remember_me (boolean): If True, check the "remember me" box.
"""
# Fill in the form
self.q(css="#login-email").fill(email)
self.q(css="#login-password").fill(password)
if remember_me:
self.q(css="#login-remember").click()
# Submit it
self.q(css=".login-button").click()
def password_reset(self, email):
"""Navigates to, fills in, and submits the password reset form.
Requires that the "login" form is visible.
Keyword Arguments:
email (unicode): The user's email address.
"""
login_form = self.current_form
# Click the password reset link on the login page
self.q(css="a.forgot-password").click()
# Wait for the password reset form to load
EmptyPromise(
lambda: self.current_form != login_form,
"Finish toggling to the password reset form"
).fulfill()
# Fill in the form
self.q(css="#password-reset-email").fill(email)
# Submit it
self.q(css="button.js-reset").click()
@property
@unguarded
def current_form(self):
"""Return the form that is currently visible to the user.
Returns:
Either "register", "login", or "password-reset" if a valid
form is loaded.
If we can't find any of these forms on the page, return None.
"""
if self.q(css=".register-button").visible:
return "register"
elif self.q(css=".login-button").visible:
return "login"
elif self.q(css=".js-reset").visible or self.q(css=".js-reset-success").visible:
return "password-reset"
@property
def errors(self):
"""Return a list of errors displayed to the user. """
return self.q(css=".submission-error li").text
def wait_for_errors(self):
"""Wait for errors to be visible, then return them. """
def _check_func():
errors = self.errors
return (bool(errors), errors)
return Promise(_check_func, "Errors are visible").fulfill()
@property
def success(self):
"""Return a success message displayed to the user."""
if self.q(css=".submission-success").visible:
return self.q(css=".submission-success h4").text
def wait_for_success(self):
"""Wait for a success message to be visible, then return it."""
def _check_func():
success = self.success
return (bool(success), success)
return Promise(_check_func, "Success message is visible").fulfill()
"""
Registration page (create a new account)
"""
from bok_choy.page_object import PageObject
from . import BASE_URL
from .dashboard import DashboardPage
class RegisterPage(PageObject):
"""
Registration page (create a new account)
"""
def __init__(self, browser, course_id):
"""
Course ID is currently of the form "edx/999/2013_Spring"
but this format could change.
"""
super(RegisterPage, self).__init__(browser)
self._course_id = course_id
@property
def url(self):
"""
URL for the registration page of a course.
"""
return "{base}/register?course_id={course_id}&enrollment_action={action}".format(
base=BASE_URL,
course_id=self._course_id,
action="enroll",
)
def is_browser_on_page(self):
return any([
'register' in title.lower()
for title in self.q(css='span.title-sub').text
])
def provide_info(self, email, password, username, full_name):
"""
Fill in registration info.
`email`, `password`, `username`, and `full_name` are the user's credentials.
"""
self.q(css='input#email').fill(email)
self.q(css='input#password').fill(password)
self.q(css='input#username').fill(username)
self.q(css='input#name').fill(full_name)
self.q(css='input#tos-yes').first.click()
self.q(css='input#honorcode-yes').first.click()
def submit(self):
"""
Submit registration info to create an account.
"""
self.q(css='button#submit').first.click()
# The next page is the dashboard; make sure it loads
dashboard = DashboardPage(self.browser)
dashboard.wait_for_page()
return dashboard
...@@ -5,10 +5,12 @@ End-to-end tests for the LMS. ...@@ -5,10 +5,12 @@ End-to-end tests for the LMS.
from textwrap import dedent from textwrap import dedent
from unittest import skip from unittest import skip
from nose.plugins.attrib import attr
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from ..helpers import UniqueCourseTest, load_data_str from ..helpers import UniqueCourseTest, load_data_str
from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage
from ...pages.lms.find_courses import FindCoursesPage from ...pages.lms.find_courses import FindCoursesPage
from ...pages.lms.course_about import CourseAboutPage from ...pages.lms.course_about import CourseAboutPage
from ...pages.lms.course_info import CourseInfoPage from ...pages.lms.course_info import CourseInfoPage
...@@ -19,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage ...@@ -19,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.problem import ProblemPage from ...pages.lms.problem import ProblemPage
from ...pages.lms.video.video import VideoPage from ...pages.lms.video.video import VideoPage
from ...pages.lms.courseware import CoursewarePage from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
...@@ -64,6 +67,166 @@ class RegistrationTest(UniqueCourseTest): ...@@ -64,6 +67,166 @@ class RegistrationTest(UniqueCourseTest):
self.assertIn(self.course_info['display_name'], course_names) self.assertIn(self.course_info['display_name'], course_names)
@attr('shard_1')
class LoginFromCombinedPageTest(UniqueCourseTest):
"""Test that we can log in using the combined login/registration page.
Also test that we can request a password reset from the combined
login/registration page.
"""
def setUp(self):
"""Initialize the page objects and create a test course. """
super(LoginFromCombinedPageTest, self).setUp()
self.login_page = CombinedLoginAndRegisterPage(
self.browser,
start_page="login",
course_id=self.course_id
)
self.dashboard_page = DashboardPage(self.browser)
# Create a course to enroll in
CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
).install()
def test_login_success(self):
# Create a user account
email, password = self._create_unique_user()
# Navigate to the login page and try to log in
self.login_page.visit().login(email=email, password=password)
# Expect that we reach the dashboard and we're auto-enrolled in the course
course_names = self.dashboard_page.wait_for_page().available_courses
self.assertIn(self.course_info["display_name"], course_names)
def test_login_failure(self):
# Navigate to the login page
self.login_page.visit()
# User account does not exist
self.login_page.login(email="nobody@nowhere.com", password="password")
# Verify that an error is displayed
self.assertIn("Email or password is incorrect.", self.login_page.wait_for_errors())
def test_toggle_to_register_form(self):
self.login_page.visit().toggle_form()
self.assertEqual(self.login_page.current_form, "register")
def test_password_reset_success(self):
# Create a user account
email, password = self._create_unique_user()
# Navigate to the password reset form and try to submit it
self.login_page.visit().password_reset(email=email)
# Expect that we're shown a success message
self.assertIn("PASSWORD RESET EMAIL SENT", self.login_page.wait_for_success())
def test_password_reset_failure(self):
# Navigate to the password reset form
self.login_page.visit()
# User account does not exist
self.login_page.password_reset(email="nobody@nowhere.com")
# Expect that we're shown a failure message
self.assertIn(
"No active user with the provided email address exists.",
self.login_page.wait_for_errors()
)
def _create_unique_user(self):
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
password = "password"
# Create the user (automatically logs us in)
AutoAuthPage(
self.browser,
username=username,
email=email,
password=password
).visit()
# Log out
LogoutPage(self.browser).visit()
return (email, password)
@attr('shard_1')
class RegisterFromCombinedPageTest(UniqueCourseTest):
"""Test that we can register a new user from the combined login/registration page. """
def setUp(self):
"""Initialize the page objects and create a test course. """
super(RegisterFromCombinedPageTest, self).setUp()
self.register_page = CombinedLoginAndRegisterPage(
self.browser,
start_page="register",
course_id=self.course_id
)
self.dashboard_page = DashboardPage(self.browser)
# Create a course to enroll in
CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
).install()
def test_register_success(self):
# Navigate to the registration page
self.register_page.visit()
# Fill in the form and submit it
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
self.register_page.register(
email=email,
password="password",
username=username,
full_name="Test User",
country="US",
terms_of_service=True
)
# Expect that we reach the dashboard and we're auto-enrolled in the course
course_names = self.dashboard_page.wait_for_page().available_courses
self.assertIn(self.course_info["display_name"], course_names)
def test_register_failure(self):
# Navigate to the registration page
self.register_page.visit()
# Enter a blank for the username field, which is required
# Don't agree to the terms of service / honor code.
# Don't specify a country code, which is required.
username = "test_{uuid}".format(uuid=self.unique_id[0:6])
email = "{user}@example.com".format(user=username)
self.register_page.register(
email=email,
password="password",
username="",
full_name="Test User",
terms_of_service=False
)
# Verify that the expected errors are displayed.
errors = self.register_page.wait_for_errors()
self.assertIn(u'The Username field cannot be empty.', errors)
self.assertIn(u'You must agree to the edX Terms of Service and Honor Code.', errors)
self.assertIn(u'The Country field cannot be empty.', errors)
def test_toggle_to_login_form(self):
self.register_page.visit().toggle_form()
self.assertEqual(self.register_page.current_form, "login")
class LanguageTest(WebAppTest): class LanguageTest(WebAppTest):
""" """
Tests that the change language functionality on the dashboard works Tests that the change language functionality on the dashboard works
......
"""Helper functions for the student account app. """
# TODO: move this function here instead of importing it from student # pylint: disable=fixme
from student.helpers import auth_pipeline_urls # pylint: disable=unused-import
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
urlpatterns = []
if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'):
urlpatterns += patterns(
'student_account.views',
url(r'^login/$', 'login_and_registration_form', {'initial_mode': 'login'}, name='account_login'),
url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'),
url(r'^password$', 'password_change_request_handler', name='password_change_request'),
)
if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
urlpatterns += patterns(
'student_account.views', 'student_account.views',
url(r'^$', 'index', name='account_index'), url(r'^$', 'index', name='account_index'),
url(r'^email$', 'email_change_request_handler', name='email_change_request'), url(r'^email$', 'email_change_request_handler', name='email_change_request'),
url(r'^email/confirmation/(?P<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'), url(r'^email/confirmation/(?P<key>[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'),
url(r'^password$', 'password_change_request_handler', name='password_change_request'), )
)
""" Views for a student's account information. """ """ Views for a student's account information. """
import logging import logging
import json
from django.conf import settings from django.conf import settings
from django.http import ( from django.http import (
HttpResponse, HttpResponseBadRequest, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
HttpResponseForbidden
) )
from django.shortcuts import redirect
from django.core.urlresolvers import reverse
from django.core.mail import send_mail from django.core.mail import send_mail
from django_future.csrf import ensure_csrf_cookie from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from edxmako.shortcuts import render_to_response, render_to_string from edxmako.shortcuts import render_to_response, render_to_string
from microsite_configuration import microsite from microsite_configuration import microsite
import third_party_auth
from user_api.api import account as account_api from user_api.api import account as account_api
from user_api.api import profile as profile_api from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter from util.bad_request_rate_limiter import BadRequestRateLimiter
from student_account.helpers import auth_pipeline_urls
AUDIT_LOG = logging.getLogger("audit") AUDIT_LOG = logging.getLogger("audit")
...@@ -47,6 +51,34 @@ def index(request): ...@@ -47,6 +51,34 @@ def index(request):
) )
@require_http_methods(['GET'])
@ensure_csrf_cookie
def login_and_registration_form(request, initial_mode="login"):
"""Render the combined login/registration form, defaulting to login
This relies on the JS to asynchronously load the actual form from
the user_api.
Keyword Args:
initial_mode (string): Either "login" or "registration".
"""
# If we're already logged in, redirect to the dashboard
if request.user.is_authenticated():
return redirect(reverse('dashboard'))
# Otherwise, render the combined login/registration page
context = {
'disable_courseware_js': True,
'initial_mode': initial_mode,
'third_party_auth': json.dumps(_third_party_auth_context(request)),
'platform_name': settings.PLATFORM_NAME,
'responsive': True
}
return render_to_response('student_account/login_and_register.html', context)
@login_required @login_required
@require_http_methods(['POST']) @require_http_methods(['POST'])
@ensure_csrf_cookie @ensure_csrf_cookie
...@@ -234,3 +266,50 @@ def password_change_request_handler(request): ...@@ -234,3 +266,50 @@ def password_change_request_handler(request):
return HttpResponse(status=200) return HttpResponse(status=200)
else: else:
return HttpResponseBadRequest("No email address provided.") return HttpResponseBadRequest("No email address provided.")
def _third_party_auth_context(request):
"""Context for third party auth providers and the currently running pipeline.
Arguments:
request (HttpRequest): The request, used to determine if a pipeline
is currently running.
Returns:
dict
"""
context = {
"currentProvider": None,
"providers": []
}
course_id = request.GET.get("course_id")
login_urls = auth_pipeline_urls(
third_party_auth.pipeline.AUTH_ENTRY_LOGIN_2,
course_id=course_id
)
register_urls = auth_pipeline_urls(
third_party_auth.pipeline.AUTH_ENTRY_REGISTER_2,
course_id=course_id
)
if third_party_auth.is_enabled():
context["providers"] = [
{
"name": enabled.NAME,
"iconClass": enabled.ICON_CLASS,
"loginUrl": login_urls[enabled.NAME],
"registerUrl": register_urls[enabled.NAME]
}
for enabled in third_party_auth.provider.Registry.enabled()
]
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline is not None:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(
running_pipeline.get('backend')
)
context["currentProvider"] = current_provider.NAME
return context
...@@ -35,7 +35,7 @@ class StudentProfileViewTest(UrlResetMixin, TestCase): ...@@ -35,7 +35,7 @@ class StudentProfileViewTest(UrlResetMixin, TestCase):
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True}) @patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
def setUp(self): def setUp(self):
super(StudentProfileViewTest, self).setUp() super(StudentProfileViewTest, self).setUp("student_profile.urls")
# Create/activate a new account # Create/activate a new account
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
......
from django.conf.urls import patterns, url from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
urlpatterns = []
if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
urlpatterns = patterns(
'student_profile.views', 'student_profile.views',
url(r'^$', 'index', name='profile_index'), url(r'^$', 'index', name='profile_index'),
url(r'^preferences$', 'preference_handler', name='preference_handler'), url(r'^preferences$', 'preference_handler', name='preference_handler'),
url(r'^preferences/languages$', 'language_info', name='language_info'), url(r'^preferences/languages$', 'language_info', name='language_info'),
) )
...@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required ...@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from user_api.api import profile as profile_api from user_api.api import profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api from lang_pref import LANGUAGE_KEY, api as language_api
from third_party_auth import pipeline import third_party_auth
@login_required @login_required
...@@ -60,8 +60,8 @@ def _get_profile(request): ...@@ -60,8 +60,8 @@ def _get_profile(request):
'disable_courseware_js': True 'disable_courseware_js': True
} }
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): if third_party_auth.is_enabled():
context['provider_user_states'] = pipeline.get_provider_user_states(user) context['provider_user_states'] = third_party_auth.pipeline.get_provider_user_states(user)
return render_to_response('student_profile/index.html', context) return render_to_response('student_profile/index.html', context)
......
...@@ -204,7 +204,6 @@ class TestVerifyView(ModuleStoreTestCase): ...@@ -204,7 +204,6 @@ class TestVerifyView(ModuleStoreTestCase):
url = reverse('verify_student_verify', url = reverse('verify_student_verify',
kwargs={"course_id": unicode(self.course_key)}) kwargs={"course_id": unicode(self.course_key)})
response = self.client.get(url) response = self.client.get(url)
self.assertIn("You are now enrolled in", response.content) self.assertIn("You are now enrolled in", response.content)
def test_valid_course_upgrade_text(self): def test_valid_course_upgrade_text(self):
......
...@@ -69,6 +69,7 @@ ...@@ -69,6 +69,7 @@
"ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_S3_GRADE_DOWNLOADS": true,
"ENABLE_THIRD_PARTY_AUTH": true, "ENABLE_THIRD_PARTY_AUTH": true,
"ENABLE_COMBINED_LOGIN_REGISTRATION": true,
"PREVIEW_LMS_BASE": "localhost:8003", "PREVIEW_LMS_BASE": "localhost:8003",
"SUBDOMAIN_BRANDING": false, "SUBDOMAIN_BRANDING": false,
"SUBDOMAIN_COURSE_LISTINGS": false "SUBDOMAIN_COURSE_LISTINGS": false
...@@ -82,6 +83,17 @@ ...@@ -82,6 +83,17 @@
"MEDIA_URL": "", "MEDIA_URL": "",
"MKTG_URL_LINK_MAP": {}, "MKTG_URL_LINK_MAP": {},
"PLATFORM_NAME": "edX", "PLATFORM_NAME": "edX",
"REGISTRATION_EXTRA_FIELDS": {
"level_of_education": "optional",
"gender": "optional",
"year_of_birth": "optional",
"mailing_address": "optional",
"goals": "optional",
"honor_code": "required",
"terms_of_service": "hidden",
"city": "hidden",
"country": "required"
},
"SEGMENT_IO_LMS": true, "SEGMENT_IO_LMS": true,
"SERVER_EMAIL": "devops@example.com", "SERVER_EMAIL": "devops@example.com",
"SESSION_COOKIE_DOMAIN": null, "SESSION_COOKIE_DOMAIN": null,
......
...@@ -280,6 +280,9 @@ FEATURES = { ...@@ -280,6 +280,9 @@ FEATURES = {
# Enable the new dashboard, account, and profile pages # Enable the new dashboard, account, and profile pages
'ENABLE_NEW_DASHBOARD': False, 'ENABLE_NEW_DASHBOARD': False,
# Enable the combined login/registration form
'ENABLE_COMBINED_LOGIN_REGISTRATION': False,
# Show a section in the membership tab of the instructor dashboard # Show a section in the membership tab of the instructor dashboard
# to allow an upload of a CSV file that contains a list of new accounts to create # to allow an upload of a CSV file that contains a list of new accounts to create
# and register for course. # and register for course.
...@@ -1037,7 +1040,23 @@ instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/ins ...@@ -1037,7 +1040,23 @@ instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/ins
# JavaScript used by the student account and profile pages # JavaScript used by the student account and profile pages
# These are not courseware, so they do not need many of the courseware-specific # These are not courseware, so they do not need many of the courseware-specific
# JavaScript modules. # JavaScript modules.
student_account_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_account/**/*.js')) student_account_js = [
'js/utils/rwd_header_footer.js',
'js/utils/edx.utils.validate.js',
'js/src/utility.js',
'js/student_account/enrollment.js',
'js/student_account/shoppingcart.js',
'js/student_account/models/LoginModel.js',
'js/student_account/models/RegisterModel.js',
'js/student_account/models/PasswordResetModel.js',
'js/student_account/views/FormView.js',
'js/student_account/views/LoginView.js',
'js/student_account/views/RegisterView.js',
'js/student_account/views/PasswordResetView.js',
'js/student_account/views/AccessView.js',
'js/student_account/accessApp.js',
]
student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js')) student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js'))
PIPELINE_CSS = { PIPELINE_CSS = {
...@@ -1555,6 +1574,7 @@ REGISTRATION_EXTRA_FIELDS = { ...@@ -1555,6 +1574,7 @@ REGISTRATION_EXTRA_FIELDS = {
'mailing_address': 'optional', 'mailing_address': 'optional',
'goals': 'optional', 'goals': 'optional',
'honor_code': 'required', 'honor_code': 'required',
'terms_of_service': 'hidden',
'city': 'hidden', 'city': 'hidden',
'country': 'hidden', 'country': 'hidden',
} }
......
...@@ -53,6 +53,8 @@ FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True ...@@ -53,6 +53,8 @@ FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True
# Toggles embargo on for testing # Toggles embargo on for testing
FEATURES['EMBARGO'] = True FEATURES['EMBARGO'] = True
FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True WIKI_ENABLED = True
......
...@@ -7,7 +7,7 @@ import xblock.reference.plugins ...@@ -7,7 +7,7 @@ import xblock.reference.plugins
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.conf import settings from django.conf import settings
from user_api import user_service from user_api.api import course_tag as user_course_tag_api
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from xmodule.x_module import ModuleSystem from xmodule.x_module import ModuleSystem
from xmodule.partitions.partitions_service import PartitionService from xmodule.partitions.partitions_service import PartitionService
...@@ -144,7 +144,7 @@ class UserTagsService(object): ...@@ -144,7 +144,7 @@ class UserTagsService(object):
the current course id and current user. the current course id and current user.
""" """
COURSE_SCOPE = user_service.COURSE_SCOPE COURSE_SCOPE = user_course_tag_api.COURSE_SCOPE
def __init__(self, runtime): def __init__(self, runtime):
self.runtime = runtime self.runtime = runtime
...@@ -161,11 +161,13 @@ class UserTagsService(object): ...@@ -161,11 +161,13 @@ class UserTagsService(object):
scope: the current scope of the runtime scope: the current scope of the runtime
key: the key for the value we want key: the key for the value we want
""" """
if scope != user_service.COURSE_SCOPE: if scope != user_course_tag_api.COURSE_SCOPE:
raise ValueError("unexpected scope {0}".format(scope)) raise ValueError("unexpected scope {0}".format(scope))
return user_service.get_course_tag(self._get_current_user(), return user_course_tag_api.get_course_tag(
self.runtime.course_id, key) self._get_current_user(),
self.runtime.course_id, key
)
def set_tag(self, scope, key, value): def set_tag(self, scope, key, value):
""" """
...@@ -175,11 +177,13 @@ class UserTagsService(object): ...@@ -175,11 +177,13 @@ class UserTagsService(object):
key: the key that to the value to be set key: the key that to the value to be set
value: the value to set value: the value to set
""" """
if scope != user_service.COURSE_SCOPE: if scope != user_course_tag_api.COURSE_SCOPE:
raise ValueError("unexpected scope {0}".format(scope)) raise ValueError("unexpected scope {0}".format(scope))
return user_service.set_course_tag(self._get_current_user(), return user_course_tag_api.set_course_tag(
self.runtime.course_id, key, value) self._get_current_user(),
self.runtime.course_id, key, value
)
class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method
......
...@@ -23,6 +23,7 @@ ...@@ -23,6 +23,7 @@
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill', 'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents', 'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate', 'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate',
'jquery.url': 'xmodule_js/common_static/js/vendor/url.min',
'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair', 'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair',
'date': 'xmodule_js/common_static/js/vendor/date', 'date': 'xmodule_js/common_static/js/vendor/date',
'underscore': 'xmodule_js/common_static/js/vendor/underscore-min', 'underscore': 'xmodule_js/common_static/js/vendor/underscore-min',
...@@ -43,7 +44,6 @@ ...@@ -43,7 +44,6 @@
'jasmine.async': 'xmodule_js/common_static/js/vendor/jasmine.async', 'jasmine.async': 'xmodule_js/common_static/js/vendor/jasmine.async',
'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly.pkgd', 'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly.pkgd',
'domReady': 'xmodule_js/common_static/js/vendor/domReady', 'domReady': 'xmodule_js/common_static/js/vendor/domReady',
'URI': 'xmodule_js/common_static/js/vendor/URI.min',
'mathjax': '//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured', 'mathjax': '//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured',
'youtube': '//www.youtube.com/player_api?noext', 'youtube': '//www.youtube.com/player_api?noext',
'tender': '//edxedge.tenderapp.com/tender_widget', 'tender': '//edxedge.tenderapp.com/tender_widget',
...@@ -65,7 +65,17 @@ ...@@ -65,7 +65,17 @@
'js/views/cohort_editor': 'js/views/cohort_editor', 'js/views/cohort_editor': 'js/views/cohort_editor',
'js/views/cohorts': 'js/views/cohorts', 'js/views/cohorts': 'js/views/cohorts',
'js/views/notification': 'js/views/notification', 'js/views/notification': 'js/views/notification',
'js/models/notification': 'js/models/notification' 'js/models/notification': 'js/models/notification',
'js/student_account/account': 'js/student_account/account',
'js/student_account/views/FormView': 'js/student_account/views/FormView',
'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel',
'js/student_account/views/LoginView': 'js/student_account/views/LoginView',
'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel',
'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView',
'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel',
'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView',
'js/student_account/views/AccessView': 'js/student_account/views/AccessView',
'js/student_profile/profile': 'js/student_profile/profile'
}, },
shim: { shim: {
'gettext': { 'gettext': {
...@@ -133,11 +143,31 @@ ...@@ -133,11 +143,31 @@
deps: ['jquery', 'tinymce'], deps: ['jquery', 'tinymce'],
exports: 'jQuery.fn.tinymce' exports: 'jQuery.fn.tinymce'
}, },
'jquery.url': {
deps: ['jquery'],
exports: 'jQuery.fn.url'
},
'datepair': { 'datepair': {
deps: ['jquery.ui', 'jquery.timepicker'] deps: ['jquery.ui', 'jquery.timepicker']
}, },
'underscore': { 'underscore': {
exports: '_' deps: ['underscore.string'],
exports: '_',
init: function(UnderscoreString) {
/* Mix non-conflicting functions from underscore.string
* (all but include, contains, and reverse) into the
* Underscore namespace. This allows the login, register,
* and password reset templates to render independent of the
* access view.
*/
_.mixin(UnderscoreString.exports());
/* Since the access view is not using RequireJS, we also
* expose underscore.string at _.str, so that the access
* view can perform the mixin on its own.
*/
_.str = UnderscoreString;
}
}, },
'backbone': { 'backbone': {
deps: ['underscore', 'jquery'], deps: ['underscore', 'jquery'],
...@@ -231,6 +261,7 @@ ...@@ -231,6 +261,7 @@
exports: 'js/dashboard/donation', exports: 'js/dashboard/donation',
deps: ['jquery', 'underscore', 'gettext'] deps: ['jquery', 'underscore', 'gettext']
}, },
// Backbone classes loaded explicitly until they are converted to use RequireJS // Backbone classes loaded explicitly until they are converted to use RequireJS
'js/models/cohort': { 'js/models/cohort': {
exports: 'CohortModel', exports: 'CohortModel',
...@@ -257,8 +288,85 @@ ...@@ -257,8 +288,85 @@
'js/views/notification': { 'js/views/notification': {
exports: 'NotificationView', exports: 'NotificationView',
deps: ['backbone', 'jquery', 'underscore'] deps: ['backbone', 'jquery', 'underscore']
}
}, },
'js/student_account/enrollment': {
exports: 'edx.student.account.EnrollmentInterface',
deps: ['jquery', 'jquery.cookie']
},
'js/student_account/shoppingcart': {
exports: 'edx.student.account.ShoppingCartInterface',
deps: ['jquery', 'jquery.cookie', 'underscore']
},
// Student account registration/login
// Loaded explicitly until these are converted to RequireJS
'js/student_account/views/FormView': {
exports: 'edx.student.account.FormView',
deps: ['jquery', 'underscore', 'backbone', 'gettext']
},
'js/student_account/models/LoginModel': {
exports: 'edx.student.account.LoginModel',
deps: ['jquery', 'jquery.cookie', 'backbone']
},
'js/student_account/views/LoginView': {
exports: 'edx.student.account.LoginView',
deps: [
'jquery',
'jquery.url',
'underscore',
'gettext',
'js/student_account/models/LoginModel',
'js/student_account/views/FormView'
]
},
'js/student_account/models/PasswordResetModel': {
exports: 'edx.student.account.PasswordResetModel',
deps: ['jquery', 'jquery.cookie', 'backbone']
},
'js/student_account/views/PasswordResetView': {
exports: 'edx.student.account.PasswordResetView',
deps: [
'jquery',
'underscore',
'gettext',
'js/student_account/models/PasswordResetModel',
'js/student_account/views/FormView'
]
},
'js/student_account/models/RegisterModel': {
exports: 'edx.student.account.RegisterModel',
deps: ['jquery', 'jquery.cookie', 'backbone']
},
'js/student_account/views/RegisterView': {
exports: 'edx.student.account.RegisterView',
deps: [
'jquery',
'jquery.url',
'underscore',
'gettext',
'js/student_account/models/RegisterModel',
'js/student_account/views/FormView'
]
},
'js/student_account/views/AccessView': {
exports: 'edx.student.account.AccessView',
deps: [
'jquery',
'underscore',
'backbone',
'gettext',
'utility',
'js/student_account/views/LoginView',
'js/student_account/views/PasswordResetView',
'js/student_account/views/RegisterView',
'js/student_account/models/LoginModel',
'js/student_account/models/PasswordResetModel',
'js/student_account/models/RegisterModel',
'js/student_account/views/FormView',
'js/student_account/enrollment',
'js/student_account/shoppingcart',
]
}
}
}); });
// TODO: why do these need 'lms/include' at the front but the CMS equivalent logic doesn't? // TODO: why do these need 'lms/include' at the front but the CMS equivalent logic doesn't?
...@@ -269,8 +377,14 @@ ...@@ -269,8 +377,14 @@
'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js', 'lms/include/js/spec/views/notification_spec.js',
'lms/include/js/spec/dashboard/donation.js', 'lms/include/js/spec/dashboard/donation.js',
'lms/include/js/spec/student_account/account.js', 'lms/include/js/spec/student_account/account_spec.js',
'lms/include/js/spec/student_profile/profile.js' 'lms/include/js/spec/student_account/access_spec.js',
'lms/include/js/spec/student_account/login_spec.js',
'lms/include/js/spec/student_account/register_spec.js',
'lms/include/js/spec/student_account/password_reset_spec.js',
'lms/include/js/spec/student_account/enrollment_spec.js',
'lms/include/js/spec/student_account/shoppingcart_spec.js',
'lms/include/js/spec/student_profile/profile_spec.js'
]); ]);
}).call(this, requirejs, define); }).call(this, requirejs, define);
define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'],
function( AjaxHelpers, EnrollmentInterface ) {
'use strict';
describe( 'edx.student.account.EnrollmentInterface', function() {
var COURSE_KEY = 'edX/DemoX/Fall',
ENROLL_URL = '/enrollment/v0/course/edX/DemoX/Fall',
FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/';
beforeEach(function() {
// Mock the redirect call
spyOn(EnrollmentInterface, 'redirect').andCallFake(function() {});
});
it('enrolls a user in a course', function() {
// Spy on Ajax requests
var requests = AjaxHelpers.requests( this );
// Attempt to enroll the user
EnrollmentInterface.enroll( COURSE_KEY );
// Expect that the correct request was made to the server
AjaxHelpers.expectRequest( requests, 'POST', ENROLL_URL );
// Simulate a successful response from the server
AjaxHelpers.respondWithJson(requests, {});
// Verify that the user was redirected correctly
expect( EnrollmentInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL );
});
it('redirects the user if enrollment fails', function() {
// Spy on Ajax requests
var requests = AjaxHelpers.requests( this );
// Attempt to enroll the user
EnrollmentInterface.enroll( COURSE_KEY );
// Simulate an error response from the server
AjaxHelpers.respondWithError(requests);
// Verify that the user was still redirected
expect(EnrollmentInterface.redirect).toHaveBeenCalledWith( FORWARD_URL );
});
});
}
);
define([
'jquery',
'underscore',
'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers',
'js/student_account/models/LoginModel',
'js/student_account/views/LoginView'
], function($, _, TemplateHelpers, AjaxHelpers, LoginModel, LoginView) {
'use strict';
describe('edx.student.account.LoginView', function() {
var model = null,
view = null,
requests = null,
authComplete = false,
PLATFORM_NAME = 'edX',
USER_DATA = {
email: 'xsy@edx.org',
password: 'xsyisawesome',
remember: true
},
THIRD_PARTY_AUTH = {
currentProvider: null,
providers: [
{
name: 'Google',
iconClass: 'icon-google-plus',
loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login',
registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register'
},
{
name: 'Facebook',
iconClass: 'icon-facebook',
loginUrl: '/auth/login/facebook/?auth_entry=account_login',
registerUrl: '/auth/login/facebook/?auth_entry=account_register'
}
]
},
FORM_DESCRIPTION = {
method: 'post',
submit_url: '/user_api/v1/account/login_session/',
fields: [
{
name: 'email',
label: 'Email',
defaultValue: '',
type: 'email',
required: true,
placeholder: 'place@holder.org',
instructions: 'Enter your email.',
restrictions: {}
},
{
name: 'password',
label: 'Password',
defaultValue: '',
type: 'password',
required: true,
instructions: 'Enter your password.',
restrictions: {}
},
{
name: 'remember',
label: 'Remember me',
defaultValue: '',
type: 'checkbox',
required: true,
instructions: "Agree to the terms of service.",
restrictions: {}
}
]
},
COURSE_ID = "edX/demoX/Fall";
var createLoginView = function(test) {
// Initialize the login model
model = new LoginModel({}, {
url: FORM_DESCRIPTION.submit_url,
method: FORM_DESCRIPTION.method
});
// Initialize the login view
view = new LoginView({
fields: FORM_DESCRIPTION.fields,
model: model,
thirdPartyAuth: THIRD_PARTY_AUTH,
platformName: PLATFORM_NAME
});
// Spy on AJAX requests
requests = AjaxHelpers.requests(test);
// Intercept events from the view
authComplete = false;
view.on("auth-complete", function() {
authComplete = true;
});
};
var submitForm = function(validationSuccess) {
// Simulate manual entry of login form data
$('#login-email').val(USER_DATA.email);
$('#login-password').val(USER_DATA.password);
// Check the "Remember me" checkbox
$('#login-remember').prop('checked', USER_DATA.remember);
// Create a fake click event
var clickEvent = $.Event('click');
// If validationSuccess isn't passed, we avoid
// spying on `view.validate` twice
if ( !_.isUndefined(validationSuccess) ) {
// Force validation to return as expected
spyOn(view, 'validate').andReturn({
isValid: validationSuccess,
message: 'Submission was validated.'
});
}
// Submit the email address
view.submitForm(clickEvent);
};
beforeEach(function() {
setFixtures('<div id="login-form"></div>');
TemplateHelpers.installTemplate('templates/student_account/login');
TemplateHelpers.installTemplate('templates/student_account/form_field');
});
it('logs the user in', function() {
createLoginView(this);
// Submit the form, with successful validation
submitForm(true);
// Form button should be disabled on success.
expect(view.$submitButton).toHaveAttr('disabled');
// Verify that the client contacts the server with the expected data
AjaxHelpers.expectRequest(
requests, 'POST',
FORM_DESCRIPTION.submit_url,
$.param( USER_DATA )
);
// Respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Verify that auth-complete is triggered
expect(authComplete).toBe(true);
});
it('sends analytics info containing the enrolled course ID', function() {
createLoginView( this );
// Simulate that the user is attempting to enroll in a course
// by setting the course_id query string param.
spyOn($, 'url').andCallFake(function( param ) {
if (param === "?course_id") {
return encodeURIComponent( COURSE_ID );
}
});
// Attempt to login
submitForm( true );
// Verify that the client sent the course ID for analytics
var expectedData = {};
$.extend(expectedData, USER_DATA, {
analytics: JSON.stringify({
enroll_course_id: COURSE_ID
})
});
AjaxHelpers.expectRequest(
requests, 'POST',
FORM_DESCRIPTION.submit_url,
$.param( expectedData )
);
});
it('displays third-party auth login buttons', function() {
createLoginView(this);
// Verify that Google and Facebook registration buttons are displayed
expect($('.button-Google')).toBeVisible();
expect($('.button-Facebook')).toBeVisible();
});
it('displays a link to the password reset form', function() {
createLoginView(this);
// Verify that the password reset link is displayed
expect($('.forgot-password')).toBeVisible();
});
it('validates login form fields', function() {
createLoginView(this);
submitForm(true);
// Verify that validation of form fields occurred
expect(view.validate).toHaveBeenCalledWith($('#login-email')[0]);
expect(view.validate).toHaveBeenCalledWith($('#login-password')[0]);
});
it('displays login form validation errors', function() {
createLoginView(this);
// Submit the form, with failed validation
submitForm(false);
// Verify that submission errors are visible
expect(view.$errors).not.toHaveClass('hidden');
// Expect auth complete NOT to have been triggered
expect(authComplete).toBe(false);
// Form button should be re-enabled when errors occur
expect(view.$submitButton).not.toHaveAttr('disabled');
});
it('displays an error if the server returns an error while logging in', function() {
createLoginView(this);
// Submit the form, with successful validation
submitForm(true);
// Simulate an error from the LMS servers
AjaxHelpers.respondWithError(requests);
// Expect that an error is displayed and that auth complete is not triggered
expect(view.$errors).not.toHaveClass('hidden');
expect(authComplete).toBe(false);
// Form button should be re-enabled on server failure.
expect(view.$submitButton).not.toHaveAttr('disabled');
// If we try again and succeed, the error should go away
submitForm();
// Form button should be disabled on success.
expect(view.$submitButton).toHaveAttr('disabled');
// This time, respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Expect that the error is hidden and auth complete is triggered
expect(view.$errors).toHaveClass('hidden');
expect(authComplete).toBe(true);
});
});
});
define([
'jquery',
'underscore',
'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers',
'js/student_account/models/PasswordResetModel',
'js/student_account/views/PasswordResetView',
], function($, _, TemplateHelpers, AjaxHelpers, PasswordResetModel, PasswordResetView) {
describe('edx.student.account.PasswordResetView', function() {
'use strict';
var model = null,
view = null,
requests = null,
EMAIL = 'xsy@edx.org',
FORM_DESCRIPTION = {
method: 'post',
submit_url: '/account/password',
fields: [{
name: 'email',
label: 'Email',
defaultValue: '',
type: 'text',
required: true,
placeholder: 'place@holder.org',
instructions: 'Enter your email.',
restrictions: {}
}]
};
var createPasswordResetView = function(that) {
// Initialize the password reset model
model = new PasswordResetModel({}, {
url: FORM_DESCRIPTION.submit_url,
method: FORM_DESCRIPTION.method
});
// Initialize the password reset view
view = new PasswordResetView({
fields: FORM_DESCRIPTION.fields,
model: model
});
// Spy on AJAX requests
requests = AjaxHelpers.requests(that);
};
var submitEmail = function(validationSuccess) {
// Simulate manual entry of an email address
$('#password-reset-email').val(EMAIL);
// Create a fake click event
var clickEvent = $.Event('click');
// If validationSuccess isn't passed, we avoid
// spying on `view.validate` twice
if ( !_.isUndefined(validationSuccess) ) {
// Force validation to return as expected
spyOn(view, 'validate').andReturn({
isValid: validationSuccess,
message: 'Submission was validated.'
});
}
// Submit the email address
view.submitForm(clickEvent);
};
beforeEach(function() {
setFixtures('<div id="password-reset-wrapper"></div>');
TemplateHelpers.installTemplate('templates/student_account/password_reset');
TemplateHelpers.installTemplate('templates/student_account/form_field');
});
it('allows the user to request a new password', function() {
createPasswordResetView(this);
// Submit the form, with successful validation
submitEmail(true);
// Verify that the client contacts the server with the expected data
AjaxHelpers.expectRequest(
requests, 'POST',
FORM_DESCRIPTION.submit_url,
$.param({ email: EMAIL })
);
// Respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Verify that the success message is visible
expect($('.js-reset-success')).not.toHaveClass('hidden');
});
it('validates the email field', function() {
createPasswordResetView(this);
// Submit the form, with successful validation
submitEmail(true);
// Verify that validation of the email field occurred
expect(view.validate).toHaveBeenCalledWith($('#password-reset-email')[0]);
// Verify that no submission errors are visible
expect(view.$errors).toHaveClass('hidden');
});
it('displays password reset validation errors', function() {
createPasswordResetView(this);
// Submit the form, with failed validation
submitEmail(false);
// Verify that submission errors are visible
expect(view.$errors).not.toHaveClass('hidden');
});
it('displays an error if the server returns an error while sending a password reset email', function() {
createPasswordResetView(this);
submitEmail(true);
// Simulate an error from the LMS servers
AjaxHelpers.respondWithError(requests);
// Expect that an error is displayed
expect(view.$errors).not.toHaveClass('hidden');
// If we try again and succeed, the error should go away
submitEmail();
// This time, respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Expect that the error is hidden
expect(view.$errors).toHaveClass('hidden');
});
});
}
);
define(['js/common_helpers/ajax_helpers', 'js/student_account/shoppingcart'],
function(AjaxHelpers, ShoppingCartInterface) {
'use strict';
describe( 'edx.student.account.ShoppingCartInterface', function() {
var COURSE_KEY = "edX/DemoX/Fall",
ADD_COURSE_URL = "/shoppingcart/add/course/edX/DemoX/Fall/",
FORWARD_URL = "/shoppingcart/";
beforeEach(function() {
// Mock the redirect call
spyOn(ShoppingCartInterface, 'redirect').andCallFake(function() {});
});
it('adds a course to the cart', function() {
// Spy on Ajax requests
var requests = AjaxHelpers.requests( this );
// Attempt to add a course to the cart
ShoppingCartInterface.addCourseToCart( COURSE_KEY );
// Expect that the correct request was made to the server
AjaxHelpers.expectRequest( requests, 'POST', ADD_COURSE_URL );
// Simulate a successful response from the server
AjaxHelpers.respondWithJson( requests, {} );
// Expect that the user was redirected to the shopping cart
expect( ShoppingCartInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL );
});
it('redirects the user on a server error', function() {
// Spy on Ajax requests
var requests = AjaxHelpers.requests( this );
// Attempt to add a course to the cart
ShoppingCartInterface.addCourseToCart( COURSE_KEY );
// Simulate an error response from the server
AjaxHelpers.respondWithError( requests );
// Expect that the user was redirected to the shopping cart
expect( ShoppingCartInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL );
});
});
}
);
var edx = edx || {};
(function($) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
return new edx.student.account.AccessView({
mode: $('#login-and-registration-container').data('initial-mode'),
thirdPartyAuth: $('#login-and-registration-container').data('third-party-auth'),
platformName: $('#login-and-registration-container').data('platform-name')
});
})(jQuery);
...@@ -4,7 +4,7 @@ var edx = edx || {}; ...@@ -4,7 +4,7 @@ var edx = edx || {};
'use strict'; 'use strict';
edx.student = edx.student || {}; edx.student = edx.student || {};
edx.student.account = {}; edx.student.account = edx.student.account || {};
edx.student.account.AccountModel = Backbone.Model.extend({ edx.student.account.AccountModel = Backbone.Model.extend({
// These should be the same length limits enforced by the server // These should be the same length limits enforced by the server
......
var edx = edx || {};
(function($) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.EnrollmentInterface = {
urls: {
course: '/enrollment/v0/course/',
trackSelection: '/course_modes/choose/'
},
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
/**
* Enroll a user in a course, then redirect the user
* to the track selection page.
* @param {string} courseKey Slash-separated course key.
*/
enroll: function( courseKey ) {
$.ajax({
url: this.courseEnrollmentUrl( courseKey ),
type: 'POST',
data: {},
headers: this.headers,
context: this
}).always(function() {
this.redirect( this.trackSelectionUrl( courseKey ) );
});
},
/**
* Construct the URL to the track selection page for a course.
* @param {string} courseKey Slash-separated course key.
* @return {string} The URL to the track selection page.
*/
trackSelectionUrl: function( courseKey ) {
return this.urls.trackSelection + courseKey + '/';
},
/**
* Construct a URL to enroll in a course.
* @param {string} courseKey Slash-separated course key.
* @return {string} The URL to enroll in a course.
*/
courseEnrollmentUrl: function( courseKey ) {
return this.urls.course + courseKey;
},
/**
* Redirect to a URL. Mainly useful for mocking out in tests.
* @param {string} url The URL to redirect to.
*/
redirect: function(url) {
window.location.href = url;
}
};
})(jQuery);
var edx = edx || {};
(function($, Backbone) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.LoginModel = Backbone.Model.extend({
defaults: {
email: '',
password: '',
remember: false
},
ajaxType: '',
urlRoot: '',
initialize: function( attributes, options ) {
this.ajaxType = options.method;
this.urlRoot = options.url;
},
sync: function(method, model) {
var headers = { 'X-CSRFToken': $.cookie('csrftoken') },
data = {},
analytics,
courseId = $.url( '?course_id' );
// If there is a course ID in the query string param,
// send that to the server as well so it can be included
// in analytics events.
if ( courseId ) {
analytics = JSON.stringify({
enroll_course_id: decodeURIComponent( courseId )
});
}
// Include all form fields and analytics info in the data sent to the server
$.extend( data, model.attributes, { analytics: analytics });
$.ajax({
url: model.urlRoot,
type: model.ajaxType,
data: data,
headers: headers,
success: function() {
model.trigger('sync');
},
error: function( error ) {
model.trigger('error', error);
}
});
}
});
})(jQuery, Backbone);
var edx = edx || {};
(function($, Backbone) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.PasswordResetModel = Backbone.Model.extend({
defaults: {
email: ''
},
ajaxType: '',
urlRoot: '',
initialize: function( attributes, options ) {
this.ajaxType = options.method;
this.urlRoot = options.url;
},
sync: function(method, model) {
var headers = {
'X-CSRFToken': $.cookie('csrftoken')
};
// Only expects an email address.
$.ajax({
url: model.urlRoot,
type: model.ajaxType,
data: model.attributes,
headers: headers,
success: function() {
model.trigger('sync');
},
error: function( error ) {
model.trigger('error', error);
}
});
}
});
})(jQuery, Backbone);
var edx = edx || {};
(function($, Backbone) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.RegisterModel = Backbone.Model.extend({
defaults: {
email: '',
name: '',
username: '',
password: '',
level_of_education: '',
gender: '',
year_of_birth: '',
mailing_address: '',
goals: '',
},
ajaxType: '',
urlRoot: '',
initialize: function( attributes, options ) {
this.ajaxType = options.method;
this.urlRoot = options.url;
},
sync: function(method, model) {
var headers = { 'X-CSRFToken': $.cookie('csrftoken') },
data = {},
analytics,
courseId = $.url( '?course_id' );
// If there is a course ID in the query string param,
// send that to the server as well so it can be included
// in analytics events.
if ( courseId ) {
analytics = JSON.stringify({
enroll_course_id: decodeURIComponent( courseId )
});
}
// Include all form fields and analytics info in the data sent to the server
$.extend( data, model.attributes, { analytics: analytics });
$.ajax({
url: model.urlRoot,
type: model.ajaxType,
data: data,
headers: headers,
success: function() {
model.trigger('sync');
},
error: function( error ) {
model.trigger('error', error);
}
});
}
});
})(jQuery, Backbone);
/**
* Use the shopping cart to purchase courses.
*/
var edx = edx || {};
(function($) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.ShoppingCartInterface = {
urls: {
viewCart: "/shoppingcart/",
addCourse: "/shoppingcart/add/course/"
},
headers: {
'X-CSRFToken': $.cookie('csrftoken')
},
/**
* Add a course to a cart, then redirect to the view cart page.
* @param {string} courseId The slash-separated course ID to add to the cart.
*/
addCourseToCart: function( courseId ) {
$.ajax({
url: this.urls.addCourse + courseId + "/",
type: 'POST',
data: {},
headers: this.headers,
context: this
}).always(function() {
this.redirect( this.urls.viewCart );
});
},
/**
* Redirect to a URL. Mainly useful for mocking out in tests.
* @param {string} url The URL to redirect to.
*/
redirect: function( url ) {
window.location.href = url;
}
};
})(jQuery);
var edx = edx || {};
(function($, _, _s, Backbone, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.AccessView = Backbone.View.extend({
el: '#login-and-registration-container',
tpl: '#access-tpl',
events: {
'change .form-toggle': 'toggleForm'
},
subview: {
login: {},
register: {},
passwordHelp: {}
},
// The form currently loaded
activeForm: '',
initialize: function( obj ) {
/* Mix non-conflicting functions from underscore.string
* (all but include, contains, and reverse) into the
* Underscore namespace
*/
_.mixin( _s.exports() );
this.tpl = $(this.tpl).html();
this.activeForm = obj.mode || 'login';
this.thirdPartyAuth = obj.thirdPartyAuth || {
currentProvider: null,
providers: []
};
this.platformName = obj.platformName;
this.render();
},
render: function() {
$(this.el).html( _.template( this.tpl, {
mode: this.activeForm
}));
this.postRender();
return this;
},
postRender: function() {
// Load the default form
this.loadForm( this.activeForm );
this.$header = $(this.el).find('.js-login-register-header');
},
loadForm: function( type ) {
this.getFormData( type, this );
},
load: {
login: function( data, context ) {
var model = new edx.student.account.LoginModel({}, {
method: data.method,
url: data.submit_url
});
context.subview.login = new edx.student.account.LoginView({
fields: data.fields,
model: model,
thirdPartyAuth: context.thirdPartyAuth,
platformName: context.platformName
});
// Listen for 'password-help' event to toggle sub-views
context.listenTo( context.subview.login, 'password-help', context.resetPassword );
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
context.listenTo( context.subview.login, 'auth-complete', context.authComplete );
},
reset: function( data, context ) {
var model = new edx.student.account.PasswordResetModel({}, {
method: data.method,
url: data.submit_url
});
context.subview.passwordHelp = new edx.student.account.PasswordResetView({
fields: data.fields,
model: model
});
},
register: function( data, context ) {
var model = new edx.student.account.RegisterModel({}, {
method: data.method,
url: data.submit_url
});
context.subview.register = new edx.student.account.RegisterView({
fields: data.fields,
model: model,
thirdPartyAuth: context.thirdPartyAuth,
platformName: context.platformName
});
// Listen for 'auth-complete' event so we can enroll/redirect the user appropriately.
context.listenTo( context.subview.register, 'auth-complete', context.authComplete );
}
},
getFormData: function( type, context ) {
var urls = {
login: 'login_session',
register: 'registration',
reset: 'password_reset'
};
$.ajax({
url: '/user_api/v1/account/' + urls[type] + '/',
type: 'GET',
dataType: 'json',
context: this,
success: function( data ) {
this.load[type]( data, context );
},
error: this.showFormError
});
},
resetPassword: function() {
window.analytics.track('edx.bi.password_reset_form.viewed', {
category: 'user-engagement'
});
this.element.hide( this.$header );
this.element.hide( $(this.el).find('.form-type') );
this.loadForm('reset');
this.element.scrollTop( $('#password-reset-wrapper') );
},
showFormError: function() {
this.element.show( $('#form-load-fail') );
},
toggleForm: function( e ) {
var type = $(e.currentTarget).val(),
$form = $('#' + type + '-form'),
$anchor = $('#' + type + '-anchor');
window.analytics.track('edx.bi.' + type + '_form.toggled', {
category: 'user-engagement'
});
if ( !this.form.isLoaded( $form ) ) {
this.loadForm( type );
}
this.element.hide( $(this.el).find('.form-wrapper') );
this.element.show( $form );
this.element.scrollTop( $anchor );
},
/**
* Once authentication has completed successfully, a user may need to:
*
* - Enroll in a course.
* - Add a course to the shopping cart.
* - Be redirected to the dashboard / track selection page / shopping cart.
*
* This handler is triggered upon successful authentication,
* either from the login or registration form. It checks
* query string params, performs enrollment/shopping cart actions,
* then redirects the user to the next page.
*
* The optional query string params are:
*
* ?next: If provided, redirect to this page upon successful auth.
* Django uses this when an unauthenticated user accesses a view
* decorated with @login_required.
*
* ?enrollment_action: Can be either "enroll" or "add_to_cart".
* If you provide this param, you must also provide a `course_id` param;
* otherwise, no action will be taken.
*
* ?course_id: The slash-separated course ID to enroll in or add to the cart.
*
*/
authComplete: function() {
var enrollment = edx.student.account.EnrollmentInterface,
shoppingcart = edx.student.account.ShoppingCartInterface,
redirectUrl = '/dashboard',
queryParams = this.queryParams();
if ( queryParams.enrollmentAction === 'enroll' && queryParams.courseId) {
/*
If we need to enroll in a course, mark as enrolled.
The enrollment interface will redirect the student once enrollment completes.
*/
enrollment.enroll( decodeURIComponent( queryParams.courseId ) );
} else if ( queryParams.enrollmentAction === 'add_to_cart' && queryParams.courseId) {
/*
If this is a paid course, add it to the shopping cart and redirect
the user to the "view cart" page.
*/
shoppingcart.addCourseToCart( decodeURIComponent( queryParams.courseId ) );
} else {
/*
Otherwise, redirect the user to the next page
Check for forwarding url and ensure that it isn't external.
If not, use the default forwarding URL.
*/
if ( !_.isNull( queryParams.next ) ) {
var next = decodeURIComponent( queryParams.next );
// Ensure that the URL is internal for security reasons
if ( !window.isExternal( next ) ) {
redirectUrl = next;
}
}
this.redirect( redirectUrl );
}
},
/**
* Redirect to a URL. Mainly useful for mocking out in tests.
* @param {string} url The URL to redirect to.
*/
redirect: function( url ) {
window.location.href = url;
},
/**
* Retrieve query params that we use post-authentication
* to decide whether to enroll a student in a course, add
* an item to the cart, or redirect.
*
* @return {object} The query params. If any param is not
* provided, it will default to null.
*/
queryParams: function() {
return {
next: $.url( '?next' ),
enrollmentAction: $.url( '?enrollment_action' ),
courseId: $.url( '?course_id' )
};
},
form: {
isLoaded: function( $form ) {
return $form.html().length > 0;
}
},
/* Helper method to toggle display
* including accessibility considerations
*/
element: {
hide: function( $el ) {
$el.addClass('hidden')
.attr('aria-hidden', true);
},
scrollTop: function( $el ) {
// Scroll to top of selected element
$('html,body').animate({
scrollTop: $el.offset().top
},'slow');
},
show: function( $el ) {
$el.removeClass('hidden')
.attr('aria-hidden', false);
}
}
});
})(jQuery, _, _.str, Backbone, gettext);
var edx = edx || {};
(function($, _, Backbone, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.FormView = Backbone.View.extend({
tagName: 'form',
el: '',
tpl: '',
fieldTpl: '#form_field-tpl',
events: {},
errors: [],
formType: '',
$form: {},
fields: [],
// String to append to required label fields
requiredStr: '*',
submitButton: '',
initialize: function( data ) {
this.model = data.model;
this.preRender( data );
this.tpl = $(this.tpl).html();
this.fieldTpl = $(this.fieldTpl).html();
this.buildForm( data.fields );
this.listenTo( this.model, 'error', this.saveError );
},
/* Allows extended views to add custom
* init steps without needing to repeat
* default init steps
*/
preRender: function( data ) {
/* Custom code goes here */
return data;
},
render: function( html ) {
var fields = html || '';
$(this.el).html( _.template( this.tpl, {
fields: fields
}));
this.postRender();
return this;
},
postRender: function() {
var $container = $(this.el);
this.$form = $container.find('form');
this.$errors = $container.find('.submission-error');
this.$submitButton = $container.find(this.submitButton);
},
buildForm: function( data ) {
var html = [],
i,
len = data.length,
fieldTpl = this.fieldTpl;
this.fields = data;
for ( i=0; i<len; i++ ) {
if ( data[i].errorMessages ) {
data[i].errorMessages = this.escapeStrings( data[i].errorMessages );
}
html.push( _.template( fieldTpl, $.extend( data[i], {
form: this.formType,
requiredStr: this.requiredStr
}) ) );
}
this.render( html.join('') );
},
/* Helper method to toggle display
* including accessibility considerations
*/
element: {
hide: function( $el ) {
if ( $el ) {
$el.addClass('hidden')
.attr('aria-hidden', true);
}
},
scrollTop: function( $el ) {
// Scroll to top of selected element
$('html,body').animate({
scrollTop: $el.offset().top
},'slow');
},
show: function( $el ) {
if ( $el ) {
$el.removeClass('hidden')
.attr('aria-hidden', false);
}
}
},
escapeStrings: function( obj ) {
_.each( obj, function( val, key ) {
obj[key] = _.escape( val );
});
return obj;
},
focusFirstError: function() {
var $error = this.$form.find('.error').first(),
$field = {},
$parent = {};
if ( $error.is('label') ) {
$parent = $error.parent('.form-field');
$error = $parent.find('input') || $parent.find('select');
} else {
$field = $error;
}
$error.focus();
},
forgotPassword: function( event ) {
event.preventDefault();
this.trigger('password-help');
},
getFormData: function() {
var obj = {},
$form = this.$form,
elements = $form[0].elements,
i,
len = elements.length,
$el,
$label,
key = '',
errors = [],
test = {};
for ( i=0; i<len; i++ ) {
$el = $( elements[i] );
$label = $form.find('label[for=' + $el.attr('id') + ']');
key = $el.attr('name') || false;
if ( key ) {
test = this.validate( elements[i] );
if ( test.isValid ) {
obj[key] = $el.attr('type') === 'checkbox' ? $el.is(':checked') : $el.val();
$el.removeClass('error');
$label.removeClass('error');
} else {
errors.push( test.message );
$el.addClass('error');
$label.addClass('error');
}
}
}
this.errors = _.uniq( errors );
return obj;
},
saveError: function( error ) {
this.errors = ['<li>' + error.responseText + '</li>'];
this.setErrors();
this.toggleDisableButton(false);
},
setErrors: function() {
var $msg = this.$errors.find('.message-copy'),
html = [],
errors = this.errors,
i,
len = errors.length;
for ( i=0; i<len; i++ ) {
html.push( errors[i] );
}
$msg.html( html.join('') );
this.element.show( this.$errors );
// Scroll to error messages
$('html,body').animate({
scrollTop: this.$errors.offset().top
},'slow');
// Focus on first error field
this.focusFirstError();
},
submitForm: function( event ) {
var data = this.getFormData();
event.preventDefault();
this.toggleDisableButton(true);
if ( !_.compact(this.errors).length ) {
this.model.set( data );
this.model.save();
this.toggleErrorMsg( false );
} else {
this.toggleErrorMsg( true );
}
},
toggleErrorMsg: function( show ) {
if ( show ) {
this.setErrors();
this.toggleDisableButton(false);
} else {
this.element.hide( this.$errors );
}
},
/**
* If a form button is defined for this form, this will disable the button on
* submit, and re-enable the button if an error occurs.
*
* Args:
* disabled (boolean): If set to TRUE, disable the button.
*
*/
toggleDisableButton: function ( disabled ) {
if (this.$submitButton) {
this.$submitButton.attr('disabled', disabled);
}
},
validate: function( $el ) {
return edx.utils.validate( $el );
}
});
})(jQuery, _, Backbone, gettext);
var edx = edx || {};
(function($, _, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.LoginView = edx.student.account.FormView.extend({
el: '#login-form',
tpl: '#login-tpl',
events: {
'click .js-login': 'submitForm',
'click .forgot-password': 'forgotPassword',
'click .login-provider': 'thirdPartyAuth'
},
formType: 'login',
requiredStr: '',
submitButton: '.js-login',
preRender: function( data ) {
this.providers = data.thirdPartyAuth.providers || [];
this.currentProvider = data.thirdPartyAuth.currentProvider || '';
this.platformName = data.platformName;
this.listenTo( this.model, 'sync', this.saveSuccess );
},
render: function( html ) {
var fields = html || '';
$(this.el).html( _.template( this.tpl, {
// We pass the context object to the template so that
// we can perform variable interpolation using sprintf
context: {
fields: fields,
currentProvider: this.currentProvider,
providers: this.providers,
platformName: this.platformName
}
}));
this.postRender();
return this;
},
postRender: function() {
this.$container = $(this.el);
this.$form = this.$container.find('form');
this.$errors = this.$container.find('.submission-error');
this.$authError = this.$container.find('.already-authenticated-msg');
this.$submitButton = this.$container.find(this.submitButton);
/* If we're already authenticated with a third-party
* provider, try logging in. The easiest way to do this
* is to simply submit the form.
*/
if (this.currentProvider) {
this.model.save();
}
},
forgotPassword: function( event ) {
event.preventDefault();
this.trigger('password-help');
},
thirdPartyAuth: function( event ) {
var providerUrl = $(event.target).data('provider-url') || '';
if (providerUrl) {
window.location.href = providerUrl;
}
},
saveSuccess: function () {
this.trigger('auth-complete');
},
saveError: function( error ) {
this.errors = ['<li>' + error.responseText + '</li>'];
this.setErrors();
/* If we've gotten a 403 error, it means that we've successfully
* authenticated with a third-party provider, but we haven't
* linked the account to an EdX account. In this case,
* we need to prompt the user to enter a little more information
* to complete the registration process.
*/
if ( error.status === 403 &&
error.responseText === 'third-party-auth' &&
this.currentProvider ) {
this.element.show( this.$authError );
this.element.hide( this.$errors );
} else {
this.element.hide( this.$authError );
this.element.show( this.$errors );
}
this.toggleDisableButton(false);
}
});
})(jQuery, _, gettext);
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