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):
self.user = UserFactory.create(username="Bob", email="bob@example.com", 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(
# is_active?, enrollment_mode, redirect?
(True, 'verified', True),
......
"""
Enrollment API for creating, updating, and deleting enrollments. Also provides access to enrollment information at a
course level, such as available course modes.
"""
from django.utils import importlib
import logging
from django.conf import settings
log = logging.getLogger(__name__)
class CourseEnrollmentError(Exception):
"""Generic Course Enrollment Error.
Describes any error that may occur when reading or updating enrollment information for a student or a course.
"""
def __init__(self, msg, data=None):
super(CourseEnrollmentError, self).__init__(msg)
# Corresponding information to help resolve the error.
self.data = data
class CourseModeNotFoundError(CourseEnrollmentError):
"""The requested course mode could not be found."""
pass
class EnrollmentNotFoundError(CourseEnrollmentError):
"""The requested enrollment could not be found."""
pass
class EnrollmentApiLoadError(CourseEnrollmentError):
"""The data API could not be loaded."""
pass
DEFAULT_DATA_API = 'enrollment.data'
def get_enrollments(student_id):
"""Retrieves all the courses a student is enrolled in.
Takes a student and retrieves all relative enrollments. Includes information regarding how the student is enrolled
in the the course.
Args:
student_id (str): The username of the student we want to retrieve course enrollment information for.
Returns:
A list of enrollment information for the given student.
Examples:
>>> get_enrollments("Bob")
[
{
"created": "2014-10-20T20:18:00Z",
"mode": "honor",
"is_active": True,
"student": "Bob",
"course": {
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
},
{
"created": "2014-10-25T20:18:00Z",
"mode": "verified",
"is_active": True,
"student": "Bob",
"course": {
"course_id": "edX/edX-Insider/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": True
}
}
]
"""
return _data_api().get_course_enrollments(student_id)
def get_enrollment(student_id, course_id):
"""Retrieves all enrollment information for the student in respect to a specific course.
Gets all the course enrollment information specific to a student in a course.
Args:
student_id (str): The student to get course enrollment information for.
course_id (str): The course to get enrollment information for.
Returns:
A serializable dictionary of the course enrollment.
Example:
>>> get_enrollment("Bob", "edX/DemoX/2014T2")
{
"created": "2014-10-20T20:18:00Z",
"mode": "honor",
"is_active": True,
"student": "Bob",
"course": {
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
}
"""
return _data_api().get_course_enrollment(student_id, course_id)
def add_enrollment(student_id, course_id, mode='honor', is_active=True):
"""Enrolls a student in a course.
Enrolls a student in a course. If the mode is not specified, this will default to 'honor'.
Args:
student_id (str): The student to enroll.
course_id (str): The course to enroll the student in.
mode (str): Optional argument for the type of enrollment to create. Ex. 'audit', 'honor', 'verified',
'professional'. If not specified, this defaults to 'honor'.
is_active (boolean): Optional argument for making the new enrollment inactive. If not specified, is_active
defaults to True.
Returns:
A serializable dictionary of the new course enrollment.
Example:
>>> add_enrollment("Bob", "edX/DemoX/2014T2", mode="audit")
{
"created": "2014-10-20T20:18:00Z",
"mode": "honor",
"is_active": True,
"student": "Bob",
"course": {
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
}
"""
_validate_course_mode(course_id, mode)
return _data_api().update_course_enrollment(student_id, course_id, mode=mode, is_active=is_active)
def deactivate_enrollment(student_id, course_id):
"""Un-enrolls a student in a course
Deactivate the enrollment of a student in a course. We will not remove the enrollment data, but simply flag it
as inactive.
Args:
student_id (str): The student associated with the deactivated enrollment.
course_id (str): The course associated with the deactivated enrollment.
Returns:
A serializable dictionary representing the deactivated course enrollment for the student.
Example:
>>> deactivate_enrollment("Bob", "edX/DemoX/2014T2")
{
"created": "2014-10-20T20:18:00Z",
"mode": "honor",
"is_active": False,
"student": "Bob",
"course": {
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
}
"""
# Check to see if there is an enrollment. We do not want to create a deactivated enrollment.
if not _data_api().get_course_enrollment(student_id, course_id):
raise EnrollmentNotFoundError(
u"No enrollment was found for student {student} in course {course}"
.format(student=student_id, course=course_id)
)
return _data_api().update_course_enrollment(student_id, course_id, is_active=False)
def update_enrollment(student_id, course_id, mode):
"""Updates the course mode for the enrolled user.
Update a course enrollment for the given student and course.
Args:
student_id (str): The student associated with the updated enrollment.
course_id (str): The course associated with the updated enrollment.
mode (str): The new course mode for this enrollment.
Returns:
A serializable dictionary representing the updated enrollment.
Example:
>>> update_enrollment("Bob", "edX/DemoX/2014T2", "honor")
{
"created": "2014-10-20T20:18:00Z",
"mode": "honor",
"is_active": True,
"student": "Bob",
"course": {
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
}
"""
_validate_course_mode(course_id, mode)
return _data_api().update_course_enrollment(student_id, course_id, mode)
def get_course_enrollment_details(course_id):
"""Get the course modes for course. Also get enrollment start and end date, invite only, etc.
Given a course_id, return a serializable dictionary of properties describing course enrollment information.
Args:
course_id (str): The Course to get enrollment information for.
Returns:
A serializable dictionary of course enrollment information.
Example:
>>> get_course_enrollment_details("edX/DemoX/2014T2")
{
"course_id": "edX/DemoX/2014T2",
"enrollment_end": 2014-12-20T20:18:00Z,
"course_modes": [
{
"slug": "honor",
"name": "Honor Code Certificate",
"min_price": 0,
"suggested_prices": "",
"currency": "usd",
"expiration_datetime": null,
"description": null
}
],
"enrollment_start": 2014-10-15T20:18:00Z,
"invite_only": False
}
"""
return _data_api().get_course_enrollment_info(course_id)
def _validate_course_mode(course_id, mode):
"""Checks to see if the specified course mode is valid for the course.
If the requested course mode is not available for the course, raise an error with corresponding
course enrollment information.
'honor' is special cased. If there are no course modes configured, and the specified mode is
'honor', return true, allowing the enrollment to be 'honor' even if the mode is not explicitly
set for the course.
Args:
course_id (str): The course to check against for available course modes.
mode (str): The slug for the course mode specified in the enrollment.
Returns:
None
Raises:
CourseModeNotFound: raised if the course mode is not found.
"""
course_enrollment_info = _data_api().get_course_enrollment_info(course_id)
course_modes = course_enrollment_info["course_modes"]
available_modes = [m['slug'] for m in course_modes]
if mode not in available_modes:
msg = (
u"Specified course mode '{mode}' unavailable for course {course_id}. "
u"Available modes were: {available}"
).format(
mode=mode,
course_id=course_id,
available=", ".join(available_modes)
)
log.warn(msg)
raise CourseModeNotFoundError(msg, course_enrollment_info)
def _data_api():
"""Returns a Data API.
This relies on Django settings to find the appropriate data API.
"""
# We retrieve the settings in-line here (rather than using the
# top-level constant), so that @override_settings will work
# in the test suite.
api_path = getattr(settings, "ENROLLMENT_DATA_API", DEFAULT_DATA_API)
try:
return importlib.import_module(api_path)
except (ImportError, ValueError):
log.exception(u"Could not load module at '{path}'".format(path=api_path))
raise EnrollmentApiLoadError(api_path)
"""
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
from mock import patch
from django.conf import settings
from django.core.urlresolvers import reverse
from django.test import TestCase
import ddt
from django.test.utils import override_settings
from xmodule.modulestore.tests.factories import CourseFactory
......@@ -130,7 +129,7 @@ class LoginFormTest(ModuleStoreTestCase):
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegisterFormTest(TestCase):
class RegisterFormTest(ModuleStoreTestCase):
"""Test rendering of the registration form. """
def setUp(self):
......
......@@ -97,6 +97,7 @@ from util.password_policy_validators import (
validate_password_dictionary
)
import third_party_auth
from third_party_auth import pipeline, provider
from student.helpers import auth_pipeline_urls, set_logged_in_cookie
from xmodule.error_module import ErrorDescriptor
......@@ -413,7 +414,7 @@ def register_user(request, extra_context=None):
# If third-party auth is enabled, prepopulate the form with data from the
# 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)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
overrides = current_provider.get_register_form_data(running_pipeline.get('kwargs'))
......@@ -630,7 +631,7 @@ def dashboard(request):
'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['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
redirect_url = None
response = 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
trumped_by_first_party_auth = bool(request.POST.get('email')) or bool(request.POST.get('password'))
user = None
......@@ -943,7 +944,7 @@ def login_user(request, error=""): # pylint: disable-msg=too-many-statements,un
AUDIT_LOG.warning(
u'Login failed - user with username {username} has no social auth with backend_name {backend_name}'.format(
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(
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
platform_name=settings.PLATFORM_NAME
),
content_type="text/plain",
status=401
status=403
)
else:
......@@ -1367,7 +1368,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
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.update({'password': pipeline.make_random_password()})
......@@ -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
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)
current_provider = provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
provider_name = current_provider.NAME
......@@ -1636,7 +1637,7 @@ def create_account(request, post_override=None): # pylint: disable-msg=too-many
redirect_url = try_change_enrollment(request)
# 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)
redirect_url = pipeline.get_complete_url(running_pipeline['backend'])
......
......@@ -9,10 +9,17 @@ class ExceptionMiddleware(SocialAuthExceptionMiddleware):
"""Custom middleware that handles conditional redirection."""
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
# pipeline.parse_query_params. If that pipeline step ever moves later
# in the pipeline stack, we'd need to validate this value because it
# would be an injection point for attacker data.
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'
AUTH_ENTRY_LOGIN = 'login'
AUTH_ENTRY_PROFILE = 'profile'
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'
# 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_DASHBOARD,
AUTH_ENTRY_LOGIN,
AUTH_ENTRY_PROFILE,
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,
])
_DEFAULT_RANDOM_PASSWORD_LENGTH = 12
_PASSWORD_CHARSET = string.letters + string.digits
......@@ -401,9 +444,23 @@ def parse_query_params(strategy, response, *args, **kwargs):
'is_profile': auth_entry == AUTH_ENTRY_PROFILE,
# Whether the auth pipeline entered from an 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
def ensure_user_information(
strategy,
......@@ -414,6 +471,8 @@ def ensure_user_information(
is_login=None,
is_profile=None,
is_register=None,
is_login_2=None,
is_register_2=None,
is_api=None,
user=None,
*args,
......@@ -435,7 +494,6 @@ def ensure_user_information(
# It is important that we always execute the entire pipeline. Even if
# behavior appears correct without executing a step, it means important
# invariants have been violated and future misbehavior is likely.
user_inactive = user and not user.is_active
user_unset = user is None
dispatch_to_login = is_login and (user_unset or user_inactive)
......@@ -445,14 +503,28 @@ def ensure_user_information(
# Content doesn't matter; we just want to exit the pipeline
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:
return
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:
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
......@@ -509,6 +581,12 @@ def login_analytics(strategy, *args, **kwargs):
'is_login': 'edx.bi.user.account.authenticated',
'is_dashboard': '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
......
......@@ -142,7 +142,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual('link' if linked else 'unlink', icon_state)
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.
middleware.ExceptionMiddleware makes sure the user ends up in the right
......@@ -157,13 +157,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
self.assertEqual(302, response.status_code)
self.assertIn('canceled', location)
self.assertIn(self.backend_name, location)
if auth_entry:
# Custom redirection to form.
self.assertTrue(location.startswith('/' + auth_entry))
else:
# Stock framework redirection to root.
self.assertTrue(location.startswith('/?'))
self.assertTrue(location.startswith(expected_uri + '?'))
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.
......@@ -220,7 +214,7 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
def assert_json_failure_response_is_missing_social_auth(self, response):
"""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)
def assert_json_failure_response_is_username_collision(self, response):
......@@ -410,13 +404,19 @@ class IntegrationTest(testutil.TestCase, test.TestCase):
# Actual tests, executed once per child.
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):
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):
self.assert_exception_redirect_looks_correct()
self.assert_exception_redirect_looks_correct('/')
def test_full_pipeline_succeeds_for_linking_account(self):
# First, create, the request and strategy that store pipeline state,
......
......@@ -4,7 +4,9 @@ Utilities for writing third_party_auth tests.
Used by Django and non-Django tests; must not have Django deps.
"""
from contextlib import contextmanager
import unittest
import mock
from third_party_auth import provider
......@@ -37,3 +39,81 @@ class TestCase(unittest.TestCase):
provider.Registry._reset()
provider.Registry.configure_once(self._original_providers)
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.
Account information includes a student's username, password, and email
address, but does NOT include user profile information (i.e., demographic
information and preferences).
......@@ -141,6 +142,34 @@ def create_account(username, password, email):
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])
def account_info(username):
"""Retrieve information about a user's account.
......
......@@ -53,6 +53,10 @@ def set_course_tag(user, course_id, key, value):
key: arbitrary (<=255 char 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(
user=user,
......@@ -61,6 +65,3 @@ def set_course_tag(user, course_id, key, value):
record.value = value
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):
return None
profile_dict = {
u'username': profile.user.username,
u'email': profile.user.email,
u'full_name': profile.name,
"username": profile.user.username,
"email": profile.user.email,
"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
......
......@@ -2,8 +2,12 @@
Helper functions for the account/profile Python APIs.
This is NOT part of the public API.
"""
from collections import defaultdict
from functools import wraps
import logging
import json
from django.http import HttpResponseBadRequest
LOGGER = logging.getLogger(__name__)
......@@ -54,3 +58,402 @@ def intercept_errors(api_error, ignore_errors=[]):
raise api_error(msg)
return _wrapped
return _decorator
def require_post_params(required_params):
"""
View decorator that ensures the required POST params are
present. If not, returns an HTTP response with status 400.
Args:
required_params (list): The required parameter keys.
Returns:
HttpResponse
"""
def _decorator(func): # pylint: disable=missing-docstring
@wraps(func)
def _wrapped(*args, **_kwargs): # pylint: disable=missing-docstring
request = args[0]
missing_params = set(required_params) - set(request.POST.keys())
if len(missing_params) > 0:
msg = u"Missing POST parameters: {missing}".format(
missing=", ".join(missing_params)
)
return HttpResponseBadRequest(msg)
else:
return func(request)
return _wrapped
return _decorator
class InvalidFieldError(Exception):
"""The provided field definition is not valid. """
class FormDescription(object):
"""Generate a JSON representation of a form. """
ALLOWED_TYPES = ["text", "email", "select", "textarea", "checkbox", "password"]
ALLOWED_RESTRICTIONS = {
"text": ["min_length", "max_length"],
"password": ["min_length", "max_length"],
"email": ["min_length", "max_length"],
}
OVERRIDE_FIELD_PROPERTIES = [
"label", "type", "defaultValue", "placeholder",
"instructions", "required", "restrictions",
"options"
]
def __init__(self, method, submit_url):
"""Configure how the form should be submitted.
Args:
method (unicode): The HTTP method used to submit the form.
submit_url (unicode): The URL where the form should be submitted.
"""
self.method = method
self.submit_url = submit_url
self.fields = []
self._field_overrides = defaultdict(dict)
def add_field(
self, name, label=u"", field_type=u"text", default=u"",
placeholder=u"", instructions=u"", required=True, restrictions=None,
options=None, include_default_option=False, error_messages=None
):
"""Add a field to the form description.
Args:
name (unicode): The name of the field, which is the key for the value
to send back to the server.
Keyword Arguments:
label (unicode): The label for the field (e.g. "E-mail" or "Username")
field_type (unicode): The type of the field. See `ALLOWED_TYPES` for
acceptable values.
default (unicode): The default value for the field.
placeholder (unicode): Placeholder text in the field
(e.g. "user@example.com" for an email field)
instructions (unicode): Short instructions for using the field
(e.g. "This is the email address you used when you registered.")
required (boolean): Whether the field is required or optional.
restrictions (dict): Validation restrictions for the field.
See `ALLOWED_RESTRICTIONS` for acceptable values.
options (list): For "select" fields, a list of tuples
(value, display_name) representing the options available to
the user. `value` is the value of the field to send to the server,
and `display_name` is the name to display to the user.
If the field type is "select", you *must* provide this kwarg.
include_default_option (boolean): If True, include a "default" empty option
at the beginning of the options list.
error_messages (dict): Custom validation error messages.
Currently, the only supported key is "required" indicating
that the messages should be displayed if the user does
not provide a value for a required field.
Raises:
InvalidFieldError
"""
if field_type not in self.ALLOWED_TYPES:
msg = u"Field type '{field_type}' is not a valid type. Allowed types are: {allowed}.".format(
field_type=field_type,
allowed=", ".join(self.ALLOWED_TYPES)
)
raise InvalidFieldError(msg)
field_dict = {
"name": name,
"label": label,
"type": field_type,
"defaultValue": default,
"placeholder": placeholder,
"instructions": instructions,
"required": required,
"restrictions": {},
"errorMessages": {},
}
if field_type == "select":
if options is not None:
field_dict["options"] = []
# Include an empty "default" option at the beginning of the list
if include_default_option:
field_dict["options"].append({
"value": "",
"name": "--",
"default": True
})
field_dict["options"].extend([
{"value": option_value, "name": option_name}
for option_value, option_name in options
])
else:
raise InvalidFieldError("You must provide options for a select field.")
if restrictions is not None:
allowed_restrictions = self.ALLOWED_RESTRICTIONS.get(field_type, [])
for key, val in restrictions.iteritems():
if key in allowed_restrictions:
field_dict["restrictions"][key] = val
else:
msg = "Restriction '{restriction}' is not allowed for field type '{field_type}'".format(
restriction=key,
field_type=field_type
)
raise InvalidFieldError(msg)
if error_messages is not None:
field_dict["errorMessages"] = error_messages
# If there are overrides for this field, apply them now.
# Any field property can be overwritten (for example, the default value or placeholder)
field_dict.update(self._field_overrides.get(name, {}))
self.fields.append(field_dict)
def to_json(self):
"""Create a JSON representation of the form description.
Here's an example of the output:
{
"method": "post",
"submit_url": "/submit",
"fields": [
{
"name": "cheese_or_wine",
"label": "Cheese or Wine?",
"defaultValue": "cheese",
"type": "select",
"required": True,
"placeholder": "",
"instructions": "",
"options": [
{"value": "cheese", "name": "Cheese"},
{"value": "wine", "name": "Wine"}
]
"restrictions": {},
"errorMessages": {},
},
{
"name": "comments",
"label": "comments",
"defaultValue": "",
"type": "text",
"required": False,
"placeholder": "Any comments?",
"instructions": "Please enter additional comments here."
"restrictions": {
"max_length": 200
}
"errorMessages": {},
},
...
]
}
If the field is NOT a "select" type, then the "options"
key will be omitted.
Returns:
unicode
"""
return json.dumps({
"method": self.method,
"submit_url": self.submit_url,
"fields": self.fields
})
def override_field_properties(self, field_name, **kwargs):
"""Override properties of a field.
The overridden values take precedence over the values provided
to `add_field()`.
Field properties not in `OVERRIDE_FIELD_PROPERTIES` will be ignored.
Arguments:
field_name (string): The name of the field to override.
Keyword Args:
Same as to `add_field()`.
"""
# Transform kwarg "field_type" to "type" (a reserved Python keyword)
if "field_type" in kwargs:
kwargs["type"] = kwargs["field_type"]
# Transform kwarg "default" to "defaultValue", since "default"
# is a reserved word in JavaScript
if "default" in kwargs:
kwargs["defaultValue"] = kwargs["default"]
self._field_overrides[field_name].update({
property_name: property_value
for property_name, property_value in kwargs.iteritems()
if property_name in self.OVERRIDE_FIELD_PROPERTIES
})
def shim_student_view(view_func, check_logged_in=False):
"""Create a "shim" view for a view function from the student Django app.
Specifically, we need to:
* Strip out enrollment params, since the client for the new registration/login
page will communicate with the enrollment API to update enrollments.
* Return responses with HTTP status codes indicating success/failure
(instead of always using status 200, but setting "success" to False in
the JSON-serialized content of the response)
* Use status code 403 to indicate a login failure.
The shim will preserve any cookies set by the view.
Arguments:
view_func (function): The view function from the student Django app.
Keyword Args:
check_logged_in (boolean): If true, check whether the user successfully
authenticated and if not set the status to 403.
Returns:
function
"""
@wraps(view_func)
def _inner(request): # pylint: disable=missing-docstring
# Ensure that the POST querydict is mutable
request.POST = request.POST.copy()
# The login and registration handlers in student view try to change
# the user's enrollment status if these parameters are present.
# Since we want the JavaScript client to communicate directly with
# the enrollment API, we want to prevent the student views from
# updating enrollments.
if "enrollment_action" in request.POST:
del request.POST["enrollment_action"]
if "course_id" in request.POST:
del request.POST["course_id"]
# Include the course ID if it's specified in the analytics info
# so it can be included in analytics events.
if "analytics" in request.POST:
try:
analytics = json.loads(request.POST["analytics"])
if "enroll_course_id" in analytics:
request.POST["course_id"] = analytics.get("enroll_course_id")
except (ValueError, TypeError):
LOGGER.error(
u"Could not parse analytics object sent to user API: {analytics}".format(
analytics=analytics
)
)
# Backwards compatibility: the student view expects both
# terms of service and honor code values. Since we're combining
# these into a single checkbox, the only value we may get
# from the new view is "honor_code".
# Longer term, we will need to make this more flexible to support
# open source installations that may have separate checkboxes
# for TOS, privacy policy, etc.
if request.POST.get("honor_code") is not None and request.POST.get("terms_of_service") is None:
request.POST["terms_of_service"] = request.POST.get("honor_code")
# Call the original view to generate a response.
# We can safely modify the status code or content
# of the response, but to be safe we won't mess
# with the headers.
response = view_func(request)
# Most responses from this view are JSON-encoded
# dictionaries with keys "success", "value", and
# (sometimes) "redirect_url".
#
# We want to communicate some of this information
# using HTTP status codes instead.
#
# We ignore the "redirect_url" parameter, because we don't need it:
# 1) It's used to redirect on change enrollment, which
# our client will handle directly
# (that's why we strip out the enrollment params from the request)
# 2) It's used by third party auth when a user has already successfully
# authenticated and we're not sending login credentials. However,
# this case is never encountered in practice: on the old login page,
# the login form would be submitted directly, so third party auth
# would always be "trumped" by first party auth. If a user has
# successfully authenticated with us, we redirect them to the dashboard
# regardless of how they authenticated; and if a user is completing
# the third party auth pipeline, we redirect them from the pipeline
# completion end-point directly.
try:
response_dict = json.loads(response.content)
msg = response_dict.get("value", u"")
success = response_dict.get("success")
except (ValueError, TypeError):
msg = response.content
success = True
# If the user is not authenticated when we expect them to be
# send the appropriate status code.
# We check whether the user attribute is set to make
# it easier to test this without necessarily running
# the request through authentication middleware.
is_authenticated = (
getattr(request, 'user', None) is not None
and request.user.is_authenticated()
)
if check_logged_in and not is_authenticated:
# If we get a 403 status code from the student view
# this means we've successfully authenticated with a
# third party provider, but we don't have a linked
# EdX account. Send a helpful error code so the client
# knows this occurred.
if response.status_code == 403:
response.content = "third-party-auth"
# Otherwise, it's a general authentication failure.
# Ensure that the status code is a 403 and pass
# along the message from the view.
else:
response.status_code = 403
response.content = msg
# If an error condition occurs, send a status 400
elif response.status_code != 200 or not success:
# The student views tend to send status 200 even when an error occurs
# If the JSON-serialized content has a value "success" set to False,
# then we know an error occurred.
if response.status_code == 200:
response.status_code = 400
response.content = msg
# If the response is successful, then return the content
# of the response directly rather than including it
# in a JSON-serialized dictionary.
else:
response.content = msg
# Return the response, preserving the original headers.
# This is really important, since the student views set cookies
# that are used elsewhere in the system (such as the marketing site).
return response
return _inner
"""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 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
......@@ -19,17 +19,17 @@ class TestUserService(TestCase):
def test_get_set_course_tag(self):
# 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)
# test setting a new key
test_value = 'value'
user_service.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)
course_tag_api.set_course_tag(self.user, self.course_id, self.test_key, test_value)
tag = course_tag_api.get_course_tag(self.user, self.course_id, self.test_key)
self.assertEqual(tag, test_value)
#test overwriting an existing key
test_value = 'value2'
user_service.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)
course_tag_api.set_course_tag(self.user, self.course_id, self.test_key, test_value)
tag = course_tag_api.get_course_tag(self.user, self.course_id, self.test_key)
self.assertEqual(tag, test_value)
"""
Tests for helper functions.
"""
import json
import mock
import ddt
from django.test import TestCase
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):
"""Fake exception that should be intercepted. """
"""Fake exception that should be intercepted."""
pass
class FakeOutputException(Exception):
"""Fake exception that should be raised. """
"""Fake exception that should be raised."""
pass
......@@ -30,9 +36,7 @@ def intercepted_function(raise_error=None):
class InterceptErrorsTest(TestCase):
"""
Tests for the decorator that intercepts errors.
"""
"""Tests for the decorator that intercepts errors."""
@raises(FakeOutputException)
def test_intercepts_errors(self):
......@@ -64,3 +68,149 @@ class InterceptErrorsTest(TestCase):
# This will include the stack trace for the original exception
# because it's called with log level "ERROR"
mock_logger.exception.assert_called_once_with(expected_log_msg)
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):
'username': self.USERNAME,
'email': self.EMAIL,
'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):
......
"""Tests for the user API at the HTTP request level. """
import datetime
import base64
import json
import re
from django.conf import settings
from django.core.urlresolvers import reverse
from django.core import mail
from django.test import TestCase
from django.test.utils import override_settings
import json
import re
from unittest import SkipTest, skipUnless
import ddt
from pytz import UTC
import mock
from user_api.api import account as account_api, profile as profile_api
from student.tests.factories import UserFactory
from unittest import SkipTest
from user_api.models import UserPreference
from user_api.tests.factories import UserPreferenceFactory
from django_comment_common import models
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.tests.test_constants import SORTED_COUNTRIES
TEST_API_KEY = "test_api_key"
......@@ -97,6 +111,19 @@ class ApiTestCase(TestCase):
"""Assert that the given response has the status code 405"""
self.assertEqual(response.status_code, 405)
def assertAuthDisabled(self, method, uri):
"""
Assert that the Django rest framework does not interpret basic auth
headers for views exposed to anonymous users as an attempt to authenticate.
"""
# Django rest framework interprets basic auth headers
# as an attempt to authenticate with the API.
# We don't want this for views available to anonymous users.
basic_auth_header = "Basic " + base64.b64encode('username:password')
response = getattr(self.client, method)(uri, HTTP_AUTHORIZATION=basic_auth_header)
self.assertNotEqual(response.status_code, 403)
class EmptyUserTestCase(ApiTestCase):
def test_get_list_empty(self):
......@@ -532,3 +559,912 @@ class PreferenceUsersListViewTest(UserApiTestCase):
self.assertUserIsValid(user)
all_user_uris = [user["url"] for user in first_page_users + second_page_users]
self.assertEqual(len(set(all_user_uris)), 2)
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class LoginSessionViewTest(ApiTestCase):
"""Tests for the login end-points of the user API. """
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = "password"
def setUp(self):
super(LoginSessionViewTest, self).setUp()
self.url = reverse("user_api_login_session")
@ddt.data("get", "post")
def test_auth_disabled(self, method):
self.assertAuthDisabled(method, self.url)
def test_allowed_methods(self):
self.assertAllowedMethods(self.url, ["GET", "POST", "HEAD", "OPTIONS"])
def test_put_not_allowed(self):
response = self.client.put(self.url)
self.assertHttpMethodNotAllowed(response)
def test_delete_not_allowed(self):
response = self.client.delete(self.url)
self.assertHttpMethodNotAllowed(response)
def test_patch_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_login_form(self):
# Retrieve the login form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
# Verify that the form description matches what we expect
form_desc = json.loads(response.content)
self.assertEqual(form_desc["method"], "post")
self.assertEqual(form_desc["submit_url"], self.url)
self.assertEqual(form_desc["fields"], [
{
"name": "email",
"defaultValue": "",
"type": "email",
"required": True,
"label": "Email",
"placeholder": "username@domain.com",
"instructions": "The email address you used to register with {platform_name}".format(
platform_name=settings.PLATFORM_NAME
),
"restrictions": {
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH
},
"errorMessages": {},
},
{
"name": "password",
"defaultValue": "",
"type": "password",
"required": True,
"label": "Password",
"placeholder": "",
"instructions": "",
"restrictions": {
"min_length": account_api.PASSWORD_MIN_LENGTH,
"max_length": account_api.PASSWORD_MAX_LENGTH
},
"errorMessages": {},
},
{
"name": "remember",
"defaultValue": False,
"type": "checkbox",
"required": False,
"label": "Remember me",
"placeholder": "",
"instructions": "",
"restrictions": {},
"errorMessages": {},
}
])
def test_login(self):
# Create a test user
UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
# Login
response = self.client.post(self.url, {
"email": self.EMAIL,
"password": self.PASSWORD,
})
self.assertHttpOK(response)
# Verify that we logged in successfully by accessing
# a page that requires authentication.
response = self.client.get(reverse("dashboard"))
self.assertHttpOK(response)
@ddt.data(
(json.dumps(True), False),
(json.dumps(False), True),
(None, True),
)
@ddt.unpack
def test_login_remember_me(self, remember_value, expire_at_browser_close):
# Create a test user
UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
# Login and remember me
data = {
"email": self.EMAIL,
"password": self.PASSWORD,
}
if remember_value is not None:
data["remember"] = remember_value
response = self.client.post(self.url, data)
self.assertHttpOK(response)
# Verify that the session expiration was set correctly
self.assertEqual(
self.client.session.get_expire_at_browser_close(),
expire_at_browser_close
)
def test_invalid_credentials(self):
# Create a test user
UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
# Invalid password
response = self.client.post(self.url, {
"email": self.EMAIL,
"password": "invalid"
})
self.assertHttpForbidden(response)
# Invalid email address
response = self.client.post(self.url, {
"email": "invalid@example.com",
"password": self.PASSWORD,
})
self.assertHttpForbidden(response)
def test_missing_login_params(self):
# Create a test user
UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
# Missing password
response = self.client.post(self.url, {
"email": self.EMAIL,
})
self.assertHttpBadRequest(response)
# Missing email
response = self.client.post(self.url, {
"password": self.PASSWORD,
})
self.assertHttpBadRequest(response)
# Missing both email and password
response = self.client.post(self.url, {})
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class PasswordResetViewTest(ApiTestCase):
"""Tests of the user API's password reset endpoint. """
def setUp(self):
super(PasswordResetViewTest, self).setUp()
self.url = reverse("user_api_password_reset")
@ddt.data("get", "post")
def test_auth_disabled(self, method):
self.assertAuthDisabled(method, self.url)
def test_allowed_methods(self):
self.assertAllowedMethods(self.url, ["GET", "HEAD", "OPTIONS"])
def test_put_not_allowed(self):
response = self.client.put(self.url)
self.assertHttpMethodNotAllowed(response)
def test_delete_not_allowed(self):
response = self.client.delete(self.url)
self.assertHttpMethodNotAllowed(response)
def test_patch_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_password_reset_form(self):
# Retrieve the password reset form
response = self.client.get(self.url, content_type="application/json")
self.assertHttpOK(response)
# Verify that the form description matches what we expect
form_desc = json.loads(response.content)
self.assertEqual(form_desc["method"], "post")
self.assertEqual(form_desc["submit_url"], reverse("password_change_request"))
self.assertEqual(form_desc["fields"], [
{
"name": "email",
"defaultValue": "",
"type": "email",
"required": True,
"label": "Email",
"placeholder": "username@domain.com",
"instructions": "The email address you used to register with {platform_name}".format(
platform_name=settings.PLATFORM_NAME
),
"restrictions": {
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH
},
"errorMessages": {},
}
])
@ddt.ddt
@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms')
class RegistrationViewTest(ApiTestCase):
"""Tests for the registration end-points of the User API. """
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = "password"
NAME = "Bob Smith"
EDUCATION = "m"
YEAR_OF_BIRTH = "1998"
ADDRESS = "123 Fake Street"
CITY = "Springfield"
COUNTRY = "us"
GOALS = "Learn all the things!"
def setUp(self):
super(RegistrationViewTest, self).setUp()
self.url = reverse("user_api_registration")
@ddt.data("get", "post")
def test_auth_disabled(self, method):
self.assertAuthDisabled(method, self.url)
def test_allowed_methods(self):
self.assertAllowedMethods(self.url, ["GET", "POST", "HEAD", "OPTIONS"])
def test_put_not_allowed(self):
response = self.client.put(self.url)
self.assertHttpMethodNotAllowed(response)
def test_delete_not_allowed(self):
response = self.client.delete(self.url)
self.assertHttpMethodNotAllowed(response)
def test_patch_not_allowed(self):
raise SkipTest("Django 1.4's test client does not support patch")
def test_register_form_default_fields(self):
no_extra_fields_setting = {}
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"email",
u"type": u"email",
u"required": True,
u"label": u"Email",
u"placeholder": u"username@domain.com",
u"restrictions": {
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH
},
}
)
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"name",
u"type": u"text",
u"required": True,
u"label": u"Full Name",
u"instructions": u"The name that will appear on your certificates",
u"restrictions": {
"max_length": profile_api.FULL_NAME_MAX_LENGTH,
},
}
)
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"username",
u"type": u"text",
u"required": True,
u"label": u"Username",
u"instructions": u"The name that will identify you in your courses",
u"restrictions": {
"min_length": account_api.USERNAME_MIN_LENGTH,
"max_length": account_api.USERNAME_MAX_LENGTH
},
}
)
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"password",
u"type": u"password",
u"required": True,
u"label": u"Password",
u"restrictions": {
"min_length": account_api.PASSWORD_MIN_LENGTH,
"max_length": account_api.PASSWORD_MAX_LENGTH
},
}
)
def test_register_form_third_party_auth_running(self):
no_extra_fields_setting = {}
with simulate_running_pipeline(
"user_api.views.third_party_auth.pipeline",
"google-oauth2", email="bob@example.com",
fullname="Bob", username="Bob123"
):
# Password field should be hidden
self._assert_reg_field(
no_extra_fields_setting,
{
"name": "password",
"type": "hidden",
"required": False,
}
)
# Email should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"email",
u"defaultValue": u"bob@example.com",
u"type": u"email",
u"required": True,
u"label": u"Email",
u"placeholder": u"username@domain.com",
u"restrictions": {
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH
},
}
)
# Full name should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"name",
u"defaultValue": u"Bob",
u"type": u"text",
u"required": True,
u"label": u"Full Name",
u"instructions": u"The name that will appear on your certificates",
u"restrictions": {
"max_length": profile_api.FULL_NAME_MAX_LENGTH
}
}
)
# Username should be filled in
self._assert_reg_field(
no_extra_fields_setting,
{
u"name": u"username",
u"defaultValue": u"Bob123",
u"type": u"text",
u"required": True,
u"label": u"Username",
u"placeholder": u"",
u"instructions": u"The name that will identify you in your courses",
u"restrictions": {
"min_length": account_api.USERNAME_MIN_LENGTH,
"max_length": account_api.USERNAME_MAX_LENGTH
}
}
)
def test_register_form_level_of_education(self):
self._assert_reg_field(
{"level_of_education": "optional"},
{
"name": "level_of_education",
"type": "select",
"required": False,
"label": "Highest Level of Education Completed",
"options": [
{"value": "", "name": "--", "default": True},
{"value": "p", "name": "Doctorate"},
{"value": "m", "name": "Master's or professional degree"},
{"value": "b", "name": "Bachelor's degree"},
{"value": "a", "name": "Associate's degree"},
{"value": "hs", "name": "Secondary/high school"},
{"value": "jhs", "name": "Junior secondary/junior high/middle school"},
{"value": "el", "name": "Elementary/primary school"},
{"value": "none", "name": "None"},
{"value": "other", "name": "Other"},
],
}
)
def test_register_form_gender(self):
self._assert_reg_field(
{"gender": "optional"},
{
"name": "gender",
"type": "select",
"required": False,
"label": "Gender",
"options": [
{"value": "", "name": "--", "default": True},
{"value": "m", "name": "Male"},
{"value": "f", "name": "Female"},
{"value": "o", "name": "Other"},
],
}
)
def test_register_form_year_of_birth(self):
this_year = datetime.datetime.now(UTC).year # pylint: disable=maybe-no-member
year_options = (
[{"value": "", "name": "--", "default": True}] + [
{"value": unicode(year), "name": unicode(year)}
for year in range(this_year, this_year - 120, -1)
]
)
self._assert_reg_field(
{"year_of_birth": "optional"},
{
"name": "year_of_birth",
"type": "select",
"required": False,
"label": "Year of Birth",
"options": year_options,
}
)
def test_registration_form_mailing_address(self):
self._assert_reg_field(
{"mailing_address": "optional"},
{
"name": "mailing_address",
"type": "textarea",
"required": False,
"label": "Mailing Address",
}
)
def test_registration_form_goals(self):
self._assert_reg_field(
{"goals": "optional"},
{
"name": "goals",
"type": "textarea",
"required": False,
"label": "If you'd like, tell us why you're interested in {platform_name}".format(
platform_name=settings.PLATFORM_NAME
)
}
)
def test_registration_form_city(self):
self._assert_reg_field(
{"city": "optional"},
{
"name": "city",
"type": "text",
"required": False,
"label": "City",
}
)
def test_registration_form_country(self):
country_options = (
[{"name": "--", "value": "", "default": True}] +
[
{"value": country_code, "name": unicode(country_name)}
for country_code, country_name in SORTED_COUNTRIES
]
)
self._assert_reg_field(
{"country": "required"},
{
"label": "Country",
"name": "country",
"type": "select",
"required": True,
"options": country_options,
}
)
@override_settings(
MKTG_URLS={"ROOT": "https://www.test.com/", "HONOR": "honor"},
)
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
def test_registration_honor_code_mktg_site_enabled(self):
self._assert_reg_field(
{"honor_code": "required"},
{
"label": "I agree to the {platform_name} <a href=\"https://www.test.com/honor\">Terms of Service and Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "honor_code",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"https://www.test.com/honor\">Terms of Service and Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
@override_settings(MKTG_URLS_LINK_MAP={"HONOR": "honor"})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
def test_registration_honor_code_mktg_site_disabled(self):
self._assert_reg_field(
{"honor_code": "required"},
{
"label": "I agree to the {platform_name} <a href=\"/honor\">Terms of Service and Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "honor_code",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"/honor\">Terms of Service and Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
@override_settings(MKTG_URLS={
"ROOT": "https://www.test.com/",
"HONOR": "honor",
"TOS": "tos",
})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": True})
def test_registration_separate_terms_of_service_mktg_site_enabled(self):
# Honor code field should say ONLY honor code,
# not "terms of service and honor code"
self._assert_reg_field(
{"honor_code": "required", "terms_of_service": "required"},
{
"label": "I agree to the {platform_name} <a href=\"https://www.test.com/honor\">Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "honor_code",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"https://www.test.com/honor\">Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
# Terms of service field should also be present
self._assert_reg_field(
{"honor_code": "required", "terms_of_service": "required"},
{
"label": "I agree to the {platform_name} <a href=\"https://www.test.com/tos\">Terms of Service</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "terms_of_service",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"https://www.test.com/tos\">Terms of Service</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
@override_settings(MKTG_URLS_LINK_MAP={"HONOR": "honor", "TOS": "tos"})
@mock.patch.dict(settings.FEATURES, {"ENABLE_MKTG_SITE": False})
def test_registration_separate_terms_of_service_mktg_site_disabled(self):
# Honor code field should say ONLY honor code,
# not "terms of service and honor code"
self._assert_reg_field(
{"honor_code": "required", "terms_of_service": "required"},
{
"label": "I agree to the {platform_name} <a href=\"/honor\">Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "honor_code",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"/honor\">Honor Code</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
# Terms of service field should also be present
self._assert_reg_field(
{"honor_code": "required", "terms_of_service": "required"},
{
"label": "I agree to the {platform_name} <a href=\"/tos\">Terms of Service</a>.".format(
platform_name=settings.PLATFORM_NAME
),
"name": "terms_of_service",
"defaultValue": False,
"type": "checkbox",
"required": True,
"errorMessages": {
"required": "You must agree to the {platform_name} <a href=\"/tos\">Terms of Service</a>.".format(
platform_name=settings.PLATFORM_NAME
)
}
}
)
@override_settings(REGISTRATION_EXTRA_FIELDS={
"level_of_education": "optional",
"gender": "optional",
"year_of_birth": "optional",
"mailing_address": "optional",
"goals": "optional",
"city": "optional",
"country": "required",
"honor_code": "required",
})
def test_field_order(self):
response = self.client.get(self.url)
self.assertHttpOK(response)
# Verify that all fields render in the correct order
form_desc = json.loads(response.content)
field_names = [field["name"] for field in form_desc["fields"]]
self.assertEqual(field_names, [
"email",
"name",
"username",
"password",
"city",
"country",
"level_of_education",
"gender",
"year_of_birth",
"mailing_address",
"goals",
"honor_code",
])
def test_register(self):
# Create a new registration
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Verify that the user exists
self.assertEqual(
account_api.account_info(self.USERNAME),
{
"username": self.USERNAME,
"email": self.EMAIL,
"is_active": False
}
)
# Verify that the user's full name is set
profile_info = profile_api.profile_info(self.USERNAME)
self.assertEqual(profile_info["full_name"], self.NAME)
# Verify that we've been logged in
# by trying to access a page that requires authentication
response = self.client.get(reverse("dashboard"))
self.assertHttpOK(response)
@override_settings(REGISTRATION_EXTRA_FIELDS={
"level_of_education": "optional",
"gender": "optional",
"year_of_birth": "optional",
"mailing_address": "optional",
"goals": "optional",
"city": "optional",
"country": "required",
})
def test_register_with_profile_info(self):
# Register, providing lots of demographic info
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"level_of_education": self.EDUCATION,
"mailing_address": self.ADDRESS,
"year_of_birth": self.YEAR_OF_BIRTH,
"goals": self.GOALS,
"city": self.CITY,
"country": self.COUNTRY,
"honor_code": "true",
})
self.assertHttpOK(response)
# Verify the profile information
profile_info = profile_api.profile_info(self.USERNAME)
self.assertEqual(profile_info["level_of_education"], self.EDUCATION)
self.assertEqual(profile_info["mailing_address"], self.ADDRESS)
self.assertEqual(profile_info["year_of_birth"], int(self.YEAR_OF_BIRTH))
self.assertEqual(profile_info["goals"], self.GOALS)
self.assertEqual(profile_info["city"], self.CITY)
self.assertEqual(profile_info["country"], self.COUNTRY)
def test_activation_email(self):
# Register, which should trigger an activation email
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Verify that the activation email was sent
self.assertEqual(len(mail.outbox), 1)
sent_email = mail.outbox[0]
self.assertEqual(sent_email.to, [self.EMAIL])
self.assertEqual(sent_email.subject, "Activate Your edX Account")
self.assertIn("activate your account", sent_email.body)
@ddt.data(
{"email": ""},
{"email": "invalid"},
{"name": ""},
{"username": ""},
{"username": "a"},
{"password": ""},
)
def test_register_invalid_input(self, invalid_fields):
# Initially, the field values are all valid
data = {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
}
# Override the valid fields, making the input invalid
data.update(invalid_fields)
# Attempt to create the account, expecting an error response
response = self.client.post(self.url, data)
self.assertHttpBadRequest(response)
@override_settings(REGISTRATION_EXTRA_FIELDS={"country": "required"})
@ddt.data("email", "name", "username", "password", "country")
def test_register_missing_required_field(self, missing_field):
data = {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"country": self.COUNTRY,
}
del data[missing_field]
# Send a request missing a field
response = self.client.post(self.url, data)
self.assertHttpBadRequest(response)
def test_register_duplicate_email(self):
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same email address
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": "Someone Else",
"username": "someone_else",
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.content,
"It looks like {} belongs to an existing account. Try again with a different email address.".format(
self.EMAIL
)
)
def test_register_duplicate_username(self):
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same username
response = self.client.post(self.url, {
"email": "someone+else@example.com",
"name": "Someone Else",
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.content,
"It looks like {} belongs to an existing account. Try again with a different username.".format(
self.USERNAME
)
)
def test_register_duplicate_username_and_email(self):
# Register the first user
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": self.NAME,
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertHttpOK(response)
# Try to create a second user with the same username
response = self.client.post(self.url, {
"email": self.EMAIL,
"name": "Someone Else",
"username": self.USERNAME,
"password": self.PASSWORD,
"honor_code": "true",
})
self.assertEqual(response.status_code, 409)
self.assertEqual(
response.content,
"It looks like {} and {} belong to an existing account. Try again with a different email address and username.".format(
self.EMAIL, self.USERNAME
)
)
def _assert_reg_field(self, extra_fields_setting, expected_field):
"""Retrieve the registration form description from the server and
verify that it contains the expected field.
Args:
extra_fields_setting (dict): Override the Django setting controlling
which extra fields are displayed in the form.
expected_field (dict): The field definition we expect to find in the form.
Raises:
AssertionError
"""
# Add in fields that are always present
defaults = [
("label", ""),
("instructions", ""),
("placeholder", ""),
("defaultValue", ""),
("restrictions", {}),
("errorMessages", {}),
]
for key, value in defaults:
if key not in expected_field:
expected_field[key] = value
# Retrieve the registration form description
with override_settings(REGISTRATION_EXTRA_FIELDS=extra_fields_setting):
response = self.client.get(self.url)
self.assertHttpOK(response)
# Verify that the form description matches what we'd expect
form_desc = json.loads(response.content)
self.assertIn(expected_field, form_desc["fields"])
# pylint: disable=missing-docstring
from django.conf import settings
from django.conf.urls import include, patterns, url
from rest_framework import routers
from user_api import views as user_api_views
......@@ -19,3 +21,11 @@ urlpatterns = patterns(
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"),
)
"""HTTP end-points for the User API. """
import copy
from django.conf import settings
from django.contrib.auth.models import User
from django.http import HttpResponse
from django.core.urlresolvers import reverse
from django.core.exceptions import ImproperlyConfigured
from django.utils.translation import ugettext as _
from django.utils.decorators import method_decorator
from django.views.decorators.csrf import ensure_csrf_cookie, csrf_protect
from rest_framework import authentication
from rest_framework import filters
from rest_framework import generics
from rest_framework import permissions
from rest_framework import status
from rest_framework import viewsets
from rest_framework.views import APIView
from rest_framework.exceptions import ParseError
from rest_framework.response import Response
from django_countries import countries
from user_api.serializers import UserSerializer, UserPreferenceSerializer
from user_api.models import UserPreference
from user_api.models import UserPreference, UserProfile
from django_comment_common.models import Role
from opaque_keys.edx.locations import SlashSeparatedCourseKey
from edxmako.shortcuts import marketing_link
import third_party_auth
from user_api.api import account as account_api, profile as profile_api
from user_api.helpers import FormDescription, shim_student_view, require_post_params
class ApiKeyHeaderPermission(permissions.BasePermission):
......@@ -31,6 +45,744 @@ class ApiKeyHeaderPermission(permissions.BasePermission):
)
class LoginSessionView(APIView):
"""HTTP end-points for logging in users. """
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
@method_decorator(ensure_csrf_cookie)
def get(self, request): # pylint: disable=unused-argument
"""Return a description of the login form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("user_api_login_session"))
# Translators: This label appears above a field on the login form
# meant to hold the user's email address.
email_label = _(u"Email")
# Translators: This example email address is used as a placeholder in
# a field on the login form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
# Translators: These instructions appear on the login form, immediately
# below a field meant to hold the user's email address.
email_instructions = _(
u"The email address you used to register with {platform_name}"
).format(platform_name=settings.PLATFORM_NAME)
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH,
}
)
# Translators: This label appears above a field on the login form
# meant to hold the user's password.
password_label = _(u"Password")
form_desc.add_field(
"password",
label=password_label,
field_type="password",
restrictions={
"min_length": account_api.PASSWORD_MIN_LENGTH,
"max_length": account_api.PASSWORD_MAX_LENGTH,
}
)
# Translators: This phrase appears next to a checkbox on the login form
# which the user can check in order to remain logged in after their
# session ends.
remember_label = _(u"Remember me")
form_desc.add_field(
"remember",
field_type="checkbox",
label=remember_label,
default=False,
required=False,
)
return HttpResponse(form_desc.to_json(), content_type="application/json")
@method_decorator(require_post_params(["email", "password"]))
@method_decorator(csrf_protect)
def post(self, request):
"""Log in a user.
You must send all required form fields with the request.
You can optionally send an `analytics` param with a JSON-encoded
object with additional info to include in the login analytics event.
Currently, the only supported field is "enroll_course_id" to indicate
that the user logged in while enrolling in a particular course.
Arguments:
request (HttpRequest)
Returns:
HttpResponse: 200 on success
HttpResponse: 400 if the request is not valid.
HttpResponse: 403 if authentication failed.
403 with content "third-party-auth" if the user
has successfully authenticated with a third party provider
but does not have a linked account.
HttpResponse: 302 if redirecting to another page.
Example Usage:
POST /user_api/v1/login_session
with POST params `email`, `password`, and `remember`.
200 OK
"""
# For the initial implementation, shim the existing login view
# from the student Django app.
from student.views import login_user
return shim_student_view(login_user, check_logged_in=True)(request)
class RegistrationView(APIView):
"""HTTP end-points for creating a new user. """
DEFAULT_FIELDS = ["email", "name", "username", "password"]
EXTRA_FIELDS = [
"city", "country", "level_of_education", "gender",
"year_of_birth", "mailing_address", "goals",
"honor_code", "terms_of_service",
]
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
def _is_field_visible(self, field_name):
"""Check whether a field is visible based on Django settings. """
return self._extra_fields_setting.get(field_name) in ["required", "optional"]
def _is_field_required(self, field_name):
"""Check whether a field is required based on Django settings. """
return self._extra_fields_setting.get(field_name) == "required"
def __init__(self, *args, **kwargs):
super(RegistrationView, self).__init__(*args, **kwargs)
# Backwards compatibility: Honor code is required by default, unless
# explicitly set to "optional" in Django settings.
self._extra_fields_setting = copy.deepcopy(settings.REGISTRATION_EXTRA_FIELDS)
self._extra_fields_setting["honor_code"] = self._extra_fields_setting.get("honor_code", "required")
# Check that the setting is configured correctly
for field_name in self.EXTRA_FIELDS:
if self._extra_fields_setting.get(field_name, "hidden") not in ["required", "optional", "hidden"]:
msg = u"Setting REGISTRATION_EXTRA_FIELDS values must be either required, optional, or hidden."
raise ImproperlyConfigured(msg)
# Map field names to the instance method used to add the field to the form
self.field_handlers = {}
for field_name in (self.DEFAULT_FIELDS + self.EXTRA_FIELDS):
handler = getattr(self, "_add_{field_name}_field".format(field_name=field_name))
self.field_handlers[field_name] = handler
@method_decorator(ensure_csrf_cookie)
def get(self, request):
"""Return a description of the registration form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
This is especially important for the registration form,
since different edx-platform installations might
collect different demographic information.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Arguments:
request (HttpRequest)
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("user_api_registration"))
self._apply_third_party_auth_overrides(request, form_desc)
# Default fields are always required
for field_name in self.DEFAULT_FIELDS:
self.field_handlers[field_name](form_desc, required=True)
# Extra fields configured in Django settings
# may be required, optional, or hidden
for field_name in self.EXTRA_FIELDS:
if self._is_field_visible(field_name):
self.field_handlers[field_name](
form_desc,
required=self._is_field_required(field_name)
)
return HttpResponse(form_desc.to_json(), content_type="application/json")
@method_decorator(require_post_params(DEFAULT_FIELDS))
@method_decorator(csrf_protect)
def post(self, request):
"""Create the user's account.
You must send all required form fields with the request.
You can optionally send an `analytics` param with a JSON-encoded
object with additional info to include in the registration analytics event.
Currently, the only supported field is "enroll_course_id" to indicate
that the user registered while enrolling in a particular course.
Arguments:
request (HTTPRequest)
Returns:
HttpResponse: 200 on success
HttpResponse: 400 if the request is not valid.
HttpResponse: 302 if redirecting to another page.
"""
email = request.POST.get('email')
username = request.POST.get('username')
# Handle duplicate email/username
conflicts = account_api.check_account_exists(email=email, username=username)
if conflicts:
if all(conflict in conflicts for conflict in ['email', 'username']):
# Translators: This message is shown to users who attempt to create a new
# account using both an email address and a username associated with an
# existing account.
error_msg = _(
u"It looks like {email_address} and {username} belong to an existing account. Try again with a different email address and username."
).format(email_address=email, username=username)
elif 'email' in conflicts:
# Translators: This message is shown to users who attempt to create a new
# account using an email address associated with an existing account.
error_msg = _(
u"It looks like {email_address} belongs to an existing account. Try again with a different email address."
).format(email_address=email)
else:
# Translators: This message is shown to users who attempt to create a new
# account using a username associated with an existing account.
error_msg = _(
u"It looks like {username} belongs to an existing account. Try again with a different username."
).format(username=username)
return HttpResponse(
status=409,
content=error_msg,
content_type="text/plain"
)
# For the initial implementation, shim the existing login view
# from the student Django app.
from student.views import create_account
return shim_student_view(create_account)(request)
def _add_email_field(self, form_desc, required=True):
"""Add an email field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# meant to hold the user's email address.
email_label = _(u"Email")
# Translators: This example email address is used as a placeholder in
# a field on the registration form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
restrictions={
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH,
},
required=required
)
def _add_name_field(self, form_desc, required=True):
"""Add a name field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# meant to hold the user's full name.
name_label = _(u"Full Name")
# Translators: These instructions appear on the registration form, immediately
# below a field meant to hold the user's full name.
name_instructions = _(u"The name that will appear on your certificates")
form_desc.add_field(
"name",
label=name_label,
instructions=name_instructions,
restrictions={
"max_length": profile_api.FULL_NAME_MAX_LENGTH,
},
required=required
)
def _add_username_field(self, form_desc, required=True):
"""Add a username field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# meant to hold the user's public username.
username_label = _(u"Username")
# Translators: These instructions appear on the registration form, immediately
# below a field meant to hold the user's public username.
username_instructions = _(
u"The name that will identify you in your courses"
)
form_desc.add_field(
"username",
label=username_label,
instructions=username_instructions,
restrictions={
"min_length": account_api.USERNAME_MIN_LENGTH,
"max_length": account_api.USERNAME_MAX_LENGTH,
},
required=required
)
def _add_password_field(self, form_desc, required=True):
"""Add a password field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# meant to hold the user's password.
password_label = _(u"Password")
form_desc.add_field(
"password",
label=password_label,
field_type="password",
restrictions={
"min_length": account_api.PASSWORD_MIN_LENGTH,
"max_length": account_api.PASSWORD_MAX_LENGTH,
},
required=required
)
def _add_level_of_education_field(self, form_desc, required=True):
"""Add a level of education field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
# form used to select the user's highest completed level of education.
education_level_label = _(u"Highest Level of Education Completed")
form_desc.add_field(
"level_of_education",
label=education_level_label,
field_type="select",
options=UserProfile.LEVEL_OF_EDUCATION_CHOICES,
include_default_option=True,
required=required
)
def _add_gender_field(self, form_desc, required=True):
"""Add a gender field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
# form used to select the user's gender.
gender_label = _(u"Gender")
form_desc.add_field(
"gender",
label=gender_label,
field_type="select",
options=UserProfile.GENDER_CHOICES,
include_default_option=True,
required=required
)
def _add_year_of_birth_field(self, form_desc, required=True):
"""Add a year of birth field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
# form used to select the user's year of birth.
yob_label = _(u"Year of Birth")
options = [(unicode(year), unicode(year)) for year in UserProfile.VALID_YEARS]
form_desc.add_field(
"year_of_birth",
label=yob_label,
field_type="select",
options=options,
include_default_option=True,
required=required
)
def _add_mailing_address_field(self, form_desc, required=True):
"""Add a mailing address field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# meant to hold the user's mailing address.
mailing_address_label = _(u"Mailing Address")
form_desc.add_field(
"mailing_address",
label=mailing_address_label,
field_type="textarea",
required=required
)
def _add_goals_field(self, form_desc, required=True):
"""Add a goals field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This phrase appears above a field on the registration form
# meant to hold the user's reasons for registering with edX.
goals_label = _(
u"If you'd like, tell us why you're interested in {platform_name}"
).format(platform_name=settings.PLATFORM_NAME)
form_desc.add_field(
"goals",
label=goals_label,
field_type="textarea",
required=required
)
def _add_city_field(self, form_desc, required=True):
"""Add a city field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a field on the registration form
# which allows the user to input the city in which they live.
city_label = _(u"City")
form_desc.add_field(
"city",
label=city_label,
required=required
)
def _add_country_field(self, form_desc, required=True):
"""Add a country field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This label appears above a dropdown menu on the registration
# form used to select the country in which the user lives.
country_label = _(u"Country")
sorted_countries = sorted(
countries.countries, key=lambda(__, name): unicode(name)
)
options = [
(country_code, unicode(country_name))
for country_code, country_name in sorted_countries
]
form_desc.add_field(
"country",
label=country_label,
field_type="select",
options=options,
include_default_option=True,
required=required
)
def _add_honor_code_field(self, form_desc, required=True):
"""Add an honor code field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Separate terms of service and honor code checkboxes
if self._is_field_visible("terms_of_service"):
terms_text = _(u"Honor Code")
# Combine terms of service and honor code checkboxes
else:
# Translators: This is a legal document users must agree to
# in order to register a new account.
terms_text = _(u"Terms of Service and Honor Code")
terms_link = u"<a href=\"{url}\">{terms_text}</a>".format(
url=marketing_link("HONOR"),
terms_text=terms_text
)
# Translators: "Terms of Service" is a legal document users must agree to
# in order to register a new account.
label = _(
u"I agree to the {platform_name} {terms_of_service}."
).format(
platform_name=settings.PLATFORM_NAME,
terms_of_service=terms_link
)
# Translators: "Terms of Service" is a legal document users must agree to
# in order to register a new account.
error_msg = _(
u"You must agree to the {platform_name} {terms_of_service}."
).format(
platform_name=settings.PLATFORM_NAME,
terms_of_service=terms_link
)
form_desc.add_field(
"honor_code",
label=label,
field_type="checkbox",
default=False,
required=required,
error_messages={
"required": error_msg
}
)
def _add_terms_of_service_field(self, form_desc, required=True):
"""Add a terms of service field to a form description.
Arguments:
form_desc: A form description
Keyword Arguments:
required (Boolean): Whether this field is required; defaults to True
"""
# Translators: This is a legal document users must agree to
# in order to register a new account.
terms_text = _(u"Terms of Service")
terms_link = u"<a href=\"{url}\">{terms_text}</a>".format(
url=marketing_link("TOS"),
terms_text=terms_text
)
# Translators: "Terms of service" is a legal document users must agree to
# in order to register a new account.
label = _(
u"I agree to the {platform_name} {terms_of_service}."
).format(
platform_name=settings.PLATFORM_NAME,
terms_of_service=terms_link
)
# Translators: "Terms of service" is a legal document users must agree to
# in order to register a new account.
error_msg = _(
u"You must agree to the {platform_name} {terms_of_service}."
).format(
platform_name=settings.PLATFORM_NAME,
terms_of_service=terms_link
)
form_desc.add_field(
"terms_of_service",
label=label,
field_type="checkbox",
default=False,
required=required,
error_messages={
"required": error_msg
}
)
def _apply_third_party_auth_overrides(self, request, form_desc):
"""Modify the registration form if the user has authenticated with a third-party provider.
If a user has successfully authenticated with a third-party provider,
but does not yet have an account with EdX, we want to fill in
the registration form with any info that we get from the
provider.
This will also hide the password field, since we assign users a default
(random) password on the assumption that they will be using
third-party auth to log in.
Arguments:
request (HttpRequest): The request for the registration form, used
to determine if the user has successfully authenticated
with a third-party provider.
form_desc (FormDescription): The registration form description
"""
if third_party_auth.is_enabled():
running_pipeline = third_party_auth.pipeline.get(request)
if running_pipeline:
current_provider = third_party_auth.provider.Registry.get_by_backend_name(running_pipeline.get('backend'))
# Override username / email / full name
field_overrides = current_provider.get_register_form_data(
running_pipeline.get('kwargs')
)
for field_name in self.DEFAULT_FIELDS:
if field_name in field_overrides:
form_desc.override_field_properties(
field_name, default=field_overrides[field_name]
)
# Hide the password field
form_desc.override_field_properties(
"password",
default="",
field_type="hidden",
required=False,
label="",
instructions="",
restrictions={}
)
class PasswordResetView(APIView):
"""HTTP end-point for GETting a description of the password reset form. """
# This end-point is available to anonymous users,
# so do not require authentication.
authentication_classes = []
@method_decorator(ensure_csrf_cookie)
def get(self, request): # pylint: disable=unused-argument
"""Return a description of the password reset form.
This decouples clients from the API definition:
if the API decides to modify the form, clients won't need
to be updated.
See `user_api.helpers.FormDescription` for examples
of the JSON-encoded form description.
Returns:
HttpResponse
"""
form_desc = FormDescription("post", reverse("password_change_request"))
# Translators: This label appears above a field on the password reset
# form meant to hold the user's email address.
email_label = _(u"Email")
# Translators: This example email address is used as a placeholder in
# a field on the password reset form meant to hold the user's email address.
email_placeholder = _(u"username@domain.com")
# Translators: These instructions appear on the password reset form,
# immediately below a field meant to hold the user's email address.
email_instructions = _(
u"The email address you used to register with {platform_name}"
).format(platform_name=settings.PLATFORM_NAME)
form_desc.add_field(
"email",
field_type="email",
label=email_label,
placeholder=email_placeholder,
instructions=email_instructions,
restrictions={
"min_length": account_api.EMAIL_MIN_LENGTH,
"max_length": account_api.EMAIL_MAX_LENGTH,
}
)
return HttpResponse(form_desc.to_json(), content_type="application/json")
class UserViewSet(viewsets.ReadOnlyModelViewSet):
authentication_classes = (authentication.SessionAuthentication,)
permission_classes = (ApiKeyHeaderPermission,)
......
......@@ -19,19 +19,39 @@ class UrlResetMixin(object):
that affect the contents of urls.py
"""
def _reset_urls(self, urlconf=None):
if urlconf is None:
urlconf = settings.ROOT_URLCONF
if urlconf in sys.modules:
reload(sys.modules[urlconf])
def _reset_urls(self, urlconf_modules):
"""Reset `urls.py` for a set of Django apps."""
for urlconf in urlconf_modules:
if urlconf in sys.modules:
reload(sys.modules[urlconf])
clear_url_caches()
# Resolve a URL so that the new urlconf gets loaded
resolve('/')
def setUp(self, **kwargs):
"""Reset django default urlconf before tests and after tests"""
def setUp(self, *args, **kwargs):
"""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)
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:
- js/vendor/jquery.truncate.js
- js/vendor/mustache.js
- js/vendor/underscore-min.js
- js/vendor/underscore.string.min.js
- js/vendor/backbone-min.js
- js/vendor/jquery.timeago.js
- js/vendor/URI.min.js
......@@ -46,6 +47,7 @@ lib_paths:
src_paths:
- coffee/src
- js/src
- js/utils
- js/capa/src
# Paths to spec (test) JavaScript files
......
......@@ -3,7 +3,7 @@ Course about page (with registration button)
"""
from .course_page import CoursePage
from .register import RegisterPage
from .login_and_register import RegisterPage
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.
from textwrap import dedent
from unittest import skip
from nose.plugins.attrib import attr
from bok_choy.web_app_test import WebAppTest
from ..helpers import UniqueCourseTest, load_data_str
from ...pages.lms.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage
from ...pages.lms.find_courses import FindCoursesPage
from ...pages.lms.course_about import CourseAboutPage
from ...pages.lms.course_info import CourseInfoPage
......@@ -19,6 +21,7 @@ from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.problem import ProblemPage
from ...pages.lms.video.video import VideoPage
from ...pages.lms.courseware import CoursewarePage
from ...pages.lms.login_and_register import CombinedLoginAndRegisterPage
from ...fixtures.course import CourseFixture, XBlockFixtureDesc, CourseUpdateDesc
......@@ -64,6 +67,166 @@ class RegistrationTest(UniqueCourseTest):
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):
"""
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
......@@ -4,23 +4,34 @@
import re
from unittest import skipUnless
from urllib import urlencode
import json
from mock import patch
import mock
import ddt
from django.test import TestCase
from django.conf import settings
from django.core.urlresolvers import reverse
from django.core import mail
from django.test.utils import override_settings
from util.testing import UrlResetMixin
from third_party_auth.tests.testutil import simulate_running_pipeline
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
from xmodule.modulestore.tests.django_utils import (
ModuleStoreTestCase, mixed_store_config
)
from xmodule.modulestore.tests.factories import CourseFactory
from student.tests.factories import CourseModeFactory
MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False)
@ddt.ddt
class StudentAccountViewTest(UrlResetMixin, TestCase):
""" Tests for the student account views. """
class StudentAccountUpdateTest(UrlResetMixin, TestCase):
""" Tests for the student account views that update the user's account information. """
USERNAME = u"heisenberg"
ALTERNATE_USERNAME = u"walt"
......@@ -50,9 +61,9 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
INVALID_KEY = u"123abc"
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
@mock.patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
def setUp(self):
super(StudentAccountViewTest, self).setUp()
super(StudentAccountUpdateTest, self).setUp("student_account.urls")
# Create/activate a new account
activation_key = account_api.create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL)
......@@ -66,7 +77,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
response = self.client.get(reverse('account_index'))
self.assertContains(response, "Student Account")
def test_email_change(self):
def test_change_email(self):
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
self.assertEquals(response.status_code, 200)
......@@ -112,7 +123,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
def test_email_change_request_no_user(self):
# Patch account API to raise an internal error when an email change is requested
with patch('student_account.views.account_api.request_email_change') as mock_call:
with mock.patch('student_account.views.account_api.request_email_change') as mock_call:
mock_call.side_effect = account_api.AccountUserNotFound
response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD)
......@@ -183,7 +194,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD)
# Patch account API to return an internal error
with patch('student_account.views.account_api.confirm_email_change') as mock_call:
with mock.patch('student_account.views.account_api.confirm_email_change') as mock_call:
mock_call.side_effect = account_api.AccountInternalError
response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key}))
......@@ -359,3 +370,201 @@ class StudentAccountViewTest(UrlResetMixin, TestCase):
data['email'] = email
return self.client.post(path=reverse('password_change_request'), data=data)
@ddt.ddt
@override_settings(MODULESTORE=MODULESTORE_CONFIG)
class StudentAccountLoginAndRegistrationTest(ModuleStoreTestCase):
""" Tests for the student account views that update the user's account information. """
USERNAME = "bob"
EMAIL = "bob@example.com"
PASSWORD = "password"
@ddt.data(
("account_login", "login"),
("account_register", "register"),
)
@ddt.unpack
def test_login_and_registration_form(self, url_name, initial_mode):
response = self.client.get(reverse(url_name))
expected_data = u"data-initial-mode=\"{mode}\"".format(mode=initial_mode)
self.assertContains(response, expected_data)
@ddt.data("account_login", "account_register")
def test_login_and_registration_form_already_authenticated(self, url_name):
# Create/activate a new account and log in
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
account_api.activate_account(activation_key)
result = self.client.login(username=self.USERNAME, password=self.PASSWORD)
self.assertTrue(result)
# Verify that we're redirected to the dashboard
response = self.client.get(reverse(url_name))
self.assertRedirects(response, reverse("dashboard"))
@mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False})
@ddt.data("account_login", "account_register")
def test_third_party_auth_disabled(self, url_name):
response = self.client.get(reverse(url_name))
self._assert_third_party_auth_data(response, None, [])
@ddt.data(
("account_login", None, None),
("account_register", None, None),
("account_login", "google-oauth2", "Google"),
("account_register", "google-oauth2", "Google"),
("account_login", "facebook", "Facebook"),
("account_register", "facebook", "Facebook"),
)
@ddt.unpack
def test_third_party_auth(self, url_name, current_backend, current_provider):
# Simulate a running pipeline
if current_backend is not None:
pipeline_target = "student_account.views.third_party_auth.pipeline"
with simulate_running_pipeline(pipeline_target, current_backend):
response = self.client.get(reverse(url_name))
# Do NOT simulate a running pipeline
else:
response = self.client.get(reverse(url_name))
# This relies on the THIRD_PARTY_AUTH configuration in the test settings
expected_providers = [
{
"name": "Facebook",
"iconClass": "icon-facebook",
"loginUrl": self._third_party_login_url("facebook", "account_login"),
"registerUrl": self._third_party_login_url("facebook", "account_register")
},
{
"name": "Google",
"iconClass": "icon-google-plus",
"loginUrl": self._third_party_login_url("google-oauth2", "account_login"),
"registerUrl": self._third_party_login_url("google-oauth2", "account_register")
}
]
self._assert_third_party_auth_data(response, current_provider, expected_providers)
@ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"])
def test_third_party_auth_course_id_verified(self, modes):
# Create a course with the specified course modes
course = CourseFactory.create()
for slug in modes:
CourseModeFactory.create(
course_id=course.id,
mode_slug=slug,
mode_display_name=slug
)
# Verify that the entry URL for third party auth
# contains the course ID and redirects to the track selection page.
course_modes_choose_url = reverse(
"course_modes_choose",
kwargs={"course_id": unicode(course.id)}
)
expected_providers = [
{
"name": "Facebook",
"iconClass": "icon-facebook",
"loginUrl": self._third_party_login_url(
"facebook", "account_login",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
),
"registerUrl": self._third_party_login_url(
"facebook", "account_register",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
)
},
{
"name": "Google",
"iconClass": "icon-google-plus",
"loginUrl": self._third_party_login_url(
"google-oauth2", "account_login",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
),
"registerUrl": self._third_party_login_url(
"google-oauth2", "account_register",
course_id=unicode(course.id),
redirect_url=course_modes_choose_url
)
}
]
# Verify that the login page contains the correct provider URLs
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
self._assert_third_party_auth_data(response, None, expected_providers)
def test_third_party_auth_course_id_shopping_cart(self):
# Create a course with a white-label course mode
course = CourseFactory.create()
CourseModeFactory.create(
course_id=course.id,
mode_slug="honor",
mode_display_name="Honor",
min_price=100
)
# Verify that the entry URL for third party auth
# contains the course ID and redirects to the shopping cart
shoppingcart_url = reverse("shoppingcart.views.show_cart")
expected_providers = [
{
"name": "Facebook",
"iconClass": "icon-facebook",
"loginUrl": self._third_party_login_url(
"facebook", "account_login",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
),
"registerUrl": self._third_party_login_url(
"facebook", "account_register",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
)
},
{
"name": "Google",
"iconClass": "icon-google-plus",
"loginUrl": self._third_party_login_url(
"google-oauth2", "account_login",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
),
"registerUrl": self._third_party_login_url(
"google-oauth2", "account_register",
course_id=unicode(course.id),
redirect_url=shoppingcart_url
)
}
]
# Verify that the login page contains the correct provider URLs
response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)})
self._assert_third_party_auth_data(response, None, expected_providers)
def _assert_third_party_auth_data(self, response, current_provider, providers):
"""Verify that third party auth info is rendered correctly in a DOM data attribute. """
expected_data = u"data-third-party-auth='{auth_info}'".format(
auth_info=json.dumps({
"currentProvider": current_provider,
"providers": providers
})
)
self.assertContains(response, expected_data)
def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redirect_url=None):
"""Construct the login URL to start third party authentication. """
params = [("auth_entry", auth_entry)]
if redirect_url:
params.append(("next", redirect_url))
if course_id:
params.append(("enroll_course_id", course_id))
return u"{url}?{params}".format(
url=reverse("social:begin", kwargs={"backend": backend_name}),
params=urlencode(params)
)
from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
'student_account.views',
url(r'^$', 'index', name='account_index'),
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'^password$', 'password_change_request_handler', name='password_change_request'),
)
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',
url(r'^$', 'index', name='account_index'),
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'),
)
""" Views for a student's account information. """
import logging
import json
from django.conf import settings
from django.http import (
HttpResponse, HttpResponseBadRequest,
HttpResponseForbidden
HttpResponse, HttpResponseBadRequest, HttpResponseForbidden
)
from django.shortcuts import redirect
from django.core.urlresolvers import reverse
from django.core.mail import send_mail
from django_future.csrf import ensure_csrf_cookie
from django.contrib.auth.decorators import login_required
from django.views.decorators.http import require_http_methods
from edxmako.shortcuts import render_to_response, render_to_string
from microsite_configuration import microsite
import third_party_auth
from user_api.api import account as account_api
from user_api.api import profile as profile_api
from util.bad_request_rate_limiter import BadRequestRateLimiter
from student_account.helpers import auth_pipeline_urls
AUDIT_LOG = logging.getLogger("audit")
......@@ -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
@require_http_methods(['POST'])
@ensure_csrf_cookie
......@@ -234,3 +266,50 @@ def password_change_request_handler(request):
return HttpResponse(status=200)
else:
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):
@patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True})
def setUp(self):
super(StudentProfileViewTest, self).setUp()
super(StudentProfileViewTest, self).setUp("student_profile.urls")
# Create/activate a new account
activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL)
......
from django.conf.urls import patterns, url
from django.conf import settings
urlpatterns = patterns(
'student_profile.views',
url(r'^$', 'index', name='profile_index'),
url(r'^preferences$', 'preference_handler', name='preference_handler'),
url(r'^preferences/languages$', 'language_info', name='language_info'),
)
urlpatterns = []
if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
urlpatterns = patterns(
'student_profile.views',
url(r'^$', 'index', name='profile_index'),
url(r'^preferences$', 'preference_handler', name='preference_handler'),
url(r'^preferences/languages$', 'language_info', name='language_info'),
)
......@@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required
from edxmako.shortcuts import render_to_response
from user_api.api import profile as profile_api
from lang_pref import LANGUAGE_KEY, api as language_api
from third_party_auth import pipeline
import third_party_auth
@login_required
......@@ -60,8 +60,8 @@ def _get_profile(request):
'disable_courseware_js': True
}
if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
context['provider_user_states'] = pipeline.get_provider_user_states(user)
if third_party_auth.is_enabled():
context['provider_user_states'] = third_party_auth.pipeline.get_provider_user_states(user)
return render_to_response('student_profile/index.html', context)
......
......@@ -204,7 +204,6 @@ class TestVerifyView(ModuleStoreTestCase):
url = reverse('verify_student_verify',
kwargs={"course_id": unicode(self.course_key)})
response = self.client.get(url)
self.assertIn("You are now enrolled in", response.content)
def test_valid_course_upgrade_text(self):
......
......@@ -69,6 +69,7 @@
"ENABLE_INSTRUCTOR_ANALYTICS": true,
"ENABLE_S3_GRADE_DOWNLOADS": true,
"ENABLE_THIRD_PARTY_AUTH": true,
"ENABLE_COMBINED_LOGIN_REGISTRATION": true,
"PREVIEW_LMS_BASE": "localhost:8003",
"SUBDOMAIN_BRANDING": false,
"SUBDOMAIN_COURSE_LISTINGS": false
......@@ -82,6 +83,17 @@
"MEDIA_URL": "",
"MKTG_URL_LINK_MAP": {},
"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,
"SERVER_EMAIL": "devops@example.com",
"SESSION_COOKIE_DOMAIN": null,
......
......@@ -280,6 +280,9 @@ FEATURES = {
# Enable the new dashboard, account, and profile pages
'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
# to allow an upload of a CSV file that contains a list of new accounts to create
# and register for course.
......@@ -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
# These are not courseware, so they do not need many of the courseware-specific
# JavaScript modules.
student_account_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_account/**/*.js'))
student_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'))
PIPELINE_CSS = {
......@@ -1555,6 +1574,7 @@ REGISTRATION_EXTRA_FIELDS = {
'mailing_address': 'optional',
'goals': 'optional',
'honor_code': 'required',
'terms_of_service': 'hidden',
'city': 'hidden',
'country': 'hidden',
}
......
......@@ -53,6 +53,8 @@ FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True
# Toggles embargo on for testing
FEATURES['EMBARGO'] = True
FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True
# Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it.
WIKI_ENABLED = True
......
......@@ -7,7 +7,7 @@ import xblock.reference.plugins
from django.core.urlresolvers import reverse
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.x_module import ModuleSystem
from xmodule.partitions.partitions_service import PartitionService
......@@ -144,7 +144,7 @@ class UserTagsService(object):
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):
self.runtime = runtime
......@@ -161,11 +161,13 @@ class UserTagsService(object):
scope: the current scope of the runtime
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))
return user_service.get_course_tag(self._get_current_user(),
self.runtime.course_id, key)
return user_course_tag_api.get_course_tag(
self._get_current_user(),
self.runtime.course_id, key
)
def set_tag(self, scope, key, value):
"""
......@@ -175,11 +177,13 @@ class UserTagsService(object):
key: the key that to the value to be 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))
return user_service.set_course_tag(self._get_current_user(),
self.runtime.course_id, key, value)
return user_course_tag_api.set_course_tag(
self._get_current_user(),
self.runtime.course_id, key, value
)
class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method
......
......@@ -23,6 +23,7 @@
'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill',
'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents',
'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',
'date': 'xmodule_js/common_static/js/vendor/date',
'underscore': 'xmodule_js/common_static/js/vendor/underscore-min',
......@@ -43,7 +44,6 @@
'jasmine.async': 'xmodule_js/common_static/js/vendor/jasmine.async',
'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly.pkgd',
'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',
'youtube': '//www.youtube.com/player_api?noext',
'tender': '//edxedge.tenderapp.com/tender_widget',
......@@ -65,7 +65,17 @@
'js/views/cohort_editor': 'js/views/cohort_editor',
'js/views/cohorts': 'js/views/cohorts',
'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: {
'gettext': {
......@@ -133,11 +143,31 @@
deps: ['jquery', 'tinymce'],
exports: 'jQuery.fn.tinymce'
},
'jquery.url': {
deps: ['jquery'],
exports: 'jQuery.fn.url'
},
'datepair': {
deps: ['jquery.ui', 'jquery.timepicker']
},
'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': {
deps: ['underscore', 'jquery'],
......@@ -231,6 +261,7 @@
exports: 'js/dashboard/donation',
deps: ['jquery', 'underscore', 'gettext']
},
// Backbone classes loaded explicitly until they are converted to use RequireJS
'js/models/cohort': {
exports: 'CohortModel',
......@@ -257,8 +288,85 @@
'js/views/notification': {
exports: 'NotificationView',
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?
......@@ -269,8 +377,14 @@
'lms/include/js/spec/staff_debug_actions_spec.js',
'lms/include/js/spec/views/notification_spec.js',
'lms/include/js/spec/dashboard/donation.js',
'lms/include/js/spec/student_account/account.js',
'lms/include/js/spec/student_profile/profile.js'
'lms/include/js/spec/student_account/account_spec.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);
define([
'jquery',
'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers',
'js/student_account/views/AccessView',
'js/student_account/views/FormView',
'js/student_account/enrollment',
'js/student_account/shoppingcart'
], function($, TemplateHelpers, AjaxHelpers, AccessView, FormView, EnrollmentInterface, ShoppingCartInterface) {
describe('edx.student.account.AccessView', function() {
'use strict';
var requests = null,
view = null,
AJAX_INFO = {
register: {
url: '/user_api/v1/account/registration/',
requestIndex: 1
},
login: {
url: '/user_api/v1/account/login_session/',
requestIndex: 0
},
password_reset: {
url: '/user_api/v1/account/password_reset/',
requestIndex: 1
}
},
FORM_DESCRIPTION = {
method: 'post',
submit_url: '/submit',
fields: [
{
name: 'email',
label: 'Email',
defaultValue: '',
type: 'text',
required: true,
placeholder: 'xsy@edx.org',
instructions: 'Enter your email here.',
restrictions: {},
},
{
name: 'username',
label: 'Username',
defaultValue: '',
type: 'text',
required: true,
placeholder: 'Xsy',
instructions: 'Enter your username here.',
restrictions: {
max_length: 200
}
}
]
},
FORWARD_URL = '/courseware/next',
COURSE_KEY = 'edx/DemoX/Fall';
var ajaxAssertAndRespond = function(url, requestIndex) {
// Verify that the client contacts the server as expected
AjaxHelpers.expectJsonRequest(requests, 'GET', url, null, requestIndex);
/* Simulate a response from the server containing
/* a dummy form description
*/
AjaxHelpers.respondWithJson(requests, FORM_DESCRIPTION);
};
var ajaxSpyAndInitialize = function(that, mode) {
// Spy on AJAX requests
requests = AjaxHelpers.requests(that);
// Initialize the access view
view = new AccessView({
mode: mode,
thirdPartyAuth: {
currentProvider: null,
providers: []
},
platformName: 'edX'
});
// Mock the redirect call
spyOn( view, 'redirect' ).andCallFake( function() {} );
// Mock the enrollment and shopping cart interfaces
spyOn( EnrollmentInterface, 'enroll' ).andCallFake( function() {} );
spyOn( ShoppingCartInterface, 'addCourseToCart' ).andCallFake( function() {} );
// Initialize the subview
ajaxAssertAndRespond(AJAX_INFO[mode].url);
};
var assertForms = function(visibleType, hiddenType) {
expect($(visibleType)).not.toHaveClass('hidden');
expect($(hiddenType)).toHaveClass('hidden');
expect($('#password-reset-wrapper')).toBeEmpty();
};
var selectForm = function(type) {
// Create a fake change event to control form toggling
var changeEvent = $.Event('change');
changeEvent.currentTarget = $('#' + type + '-option');
// Load form corresponding to the change event
view.toggleForm(changeEvent);
ajaxAssertAndRespond(AJAX_INFO[type].url, AJAX_INFO[type].requestIndex);
};
/**
* Simulate query string params.
*
* @param {object} params Parameters to set, each of which
* should be prefixed with '?'
*/
var setFakeQueryParams = function( params ) {
spyOn( $, 'url' ).andCallFake(function( requestedParam ) {
if ( params.hasOwnProperty(requestedParam) ) {
return params[requestedParam];
}
});
};
beforeEach(function() {
setFixtures('<div id="login-and-registration-container"></div>');
TemplateHelpers.installTemplate('templates/student_account/access');
TemplateHelpers.installTemplate('templates/student_account/login');
TemplateHelpers.installTemplate('templates/student_account/register');
TemplateHelpers.installTemplate('templates/student_account/password_reset');
TemplateHelpers.installTemplate('templates/student_account/form_field');
// Stub analytics tracking
// TODO: use RequireJS to ensure that this is loaded correctly
window.analytics = window.analytics || {};
window.analytics.track = window.analytics.track || function() {};
});
it('can initially display the login form', function() {
ajaxSpyAndInitialize(this, 'login');
/* Verify that the login form is expanded, and that the
/* registration form is collapsed.
*/
assertForms('#login-form', '#register-form');
});
it('can initially display the registration form', function() {
ajaxSpyAndInitialize(this, 'register');
/* Verify that the registration form is expanded, and that the
/* login form is collapsed.
*/
assertForms('#register-form', '#login-form');
});
it('toggles between the login and registration forms', function() {
ajaxSpyAndInitialize(this, 'login');
// Simulate selection of the registration form
selectForm('register');
assertForms('#register-form', '#login-form');
// Simulate selection of the login form
selectForm('login');
assertForms('#login-form', '#register-form');
});
it('displays the reset password form', function() {
ajaxSpyAndInitialize(this, 'login');
// Simulate a click on the reset password link
view.resetPassword();
ajaxAssertAndRespond(
AJAX_INFO.password_reset.url,
AJAX_INFO.password_reset.requestIndex
);
// Verify that the password reset wrapper is populated
expect($('#password-reset-wrapper')).not.toBeEmpty();
});
it('enrolls the user on auth complete', function() {
ajaxSpyAndInitialize(this, 'login');
// Simulate providing enrollment query string params
setFakeQueryParams({
'?enrollment_action': 'enroll',
'?course_id': COURSE_KEY
});
// Trigger auth complete on the login view
view.subview.login.trigger('auth-complete');
// Expect that the view tried to enroll the student
expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith( COURSE_KEY );
});
it('adds a white-label course to the shopping cart on auth complete', function() {
ajaxSpyAndInitialize(this, 'register');
// Simulate providing "add to cart" query string params
setFakeQueryParams({
'?enrollment_action': 'add_to_cart',
'?course_id': COURSE_KEY
});
// Trigger auth complete on the register view
view.subview.register.trigger('auth-complete');
// Expect that the view tried to add the course to the user's shopping cart
expect( ShoppingCartInterface.addCourseToCart ).toHaveBeenCalledWith( COURSE_KEY );
});
it('redirects the user to the dashboard on auth complete', function() {
ajaxSpyAndInitialize(this, 'register');
// Trigger auth complete
view.subview.register.trigger('auth-complete');
// Since we did not provide a ?next query param, expect a redirect to the dashboard.
expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' );
});
it('redirects the user to the next page on auth complete', function() {
ajaxSpyAndInitialize(this, 'register');
// Simulate providing a ?next query string parameter
setFakeQueryParams({ '?next': FORWARD_URL });
// Trigger auth complete
view.subview.register.trigger('auth-complete');
// Verify that we were redirected
expect( view.redirect ).toHaveBeenCalledWith( FORWARD_URL );
});
it('ignores redirect to external URLs', function() {
ajaxSpyAndInitialize(this, 'register');
// Simulate providing a ?next query string parameter
// that goes to an external URL
setFakeQueryParams({ '?next': "http://www.example.com" });
// Trigger auth complete
view.subview.register.trigger('auth-complete');
// Expect that we ignore the external URL and redirect to the dashboard
expect( view.redirect ).toHaveBeenCalledWith( "/dashboard" );
});
it('displays an error if a form definition could not be loaded', function() {
// Spy on AJAX requests
requests = AjaxHelpers.requests(this);
// Init AccessView
view = new AccessView({
mode: 'login',
thirdPartyAuth: {
currentProvider: null,
providers: []
},
platformName: 'edX'
});
// Simulate an error from the LMS servers
AjaxHelpers.respondWithError(requests);
// Error message should be displayed
expect( $('#form-load-fail').hasClass('hidden') ).toBe(false);
});
});
}
);
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([
'jquery',
'underscore',
'js/common_helpers/template_helpers',
'js/common_helpers/ajax_helpers',
'js/student_account/models/RegisterModel',
'js/student_account/views/RegisterView'
], function($, _, TemplateHelpers, AjaxHelpers, RegisterModel, RegisterView) {
'use strict';
describe('edx.student.account.RegisterView', function() {
var model = null,
view = null,
requests = null,
authComplete = false,
PLATFORM_NAME = 'edX',
COURSE_ID = "edX/DemoX/Fall",
USER_DATA = {
email: 'xsy@edx.org',
name: 'Xsy M. Education',
username: 'Xsy',
password: 'xsyisawesome',
level_of_education: 'p',
gender: 'm',
year_of_birth: 2014,
mailing_address: '141 Portland',
goals: 'To boldly learn what no letter of the alphabet has learned before',
honor_code: 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/registration/',
fields: [
{
name: 'email',
label: 'Email',
defaultValue: '',
type: 'email',
required: true,
placeholder: 'place@holder.org',
instructions: 'Enter your email.',
restrictions: {}
},
{
name: 'name',
label: 'Full Name',
defaultValue: '',
type: 'text',
required: true,
instructions: 'Enter your username.',
restrictions: {}
},
{
name: 'username',
label: 'Username',
defaultValue: '',
type: 'text',
required: true,
instructions: 'Enter your username.',
restrictions: {}
},
{
name: 'password',
label: 'Password',
defaultValue: '',
type: 'password',
required: true,
instructions: 'Enter your password.',
restrictions: {}
},
{
name: 'level_of_education',
label: 'Highest Level of Education Completed',
defaultValue: '',
type: 'select',
options: [
{value: "", name: "--"},
{value: "p", name: "Doctorate"},
{value: "m", name: "Master's or professional degree"},
{value: "b", name: "Bachelor's degree"},
],
required: false,
instructions: 'Select your education level.',
restrictions: {}
},
{
name: 'gender',
label: 'Gender',
defaultValue: '',
type: 'select',
options: [
{value: "", name: "--"},
{value: "m", name: "Male"},
{value: "f", name: "Female"},
{value: "o", name: "Other"},
],
required: false,
instructions: 'Select your gender.',
restrictions: {}
},
{
name: 'year_of_birth',
label: 'Year of Birth',
defaultValue: '',
type: 'select',
options: [
{value: "", name: "--"},
{value: 1900, name: "1900"},
{value: 1950, name: "1950"},
{value: 2014, name: "2014"},
],
required: false,
instructions: 'Select your year of birth.',
restrictions: {}
},
{
name: 'mailing_address',
label: 'Mailing Address',
defaultValue: '',
type: 'textarea',
required: false,
instructions: 'Enter your mailing address.',
restrictions: {}
},
{
name: 'goals',
label: 'Goals',
defaultValue: '',
type: 'textarea',
required: false,
instructions: "If you'd like, tell us why you're interested in edX.",
restrictions: {}
},
{
name: 'honor_code',
label: 'I agree to the <a href="/honor">Terms of Service and Honor Code</a>',
defaultValue: '',
type: 'checkbox',
required: true,
instructions: '',
restrictions: {}
}
]
};
var createRegisterView = function(that) {
// Initialize the register model
model = new RegisterModel({}, {
url: FORM_DESCRIPTION.submit_url,
method: FORM_DESCRIPTION.method
});
// Initialize the register view
view = new RegisterView({
fields: FORM_DESCRIPTION.fields,
model: model,
thirdPartyAuth: THIRD_PARTY_AUTH,
platformName: PLATFORM_NAME
});
// Spy on AJAX requests
requests = AjaxHelpers.requests(that);
// Intercept events from the view
authComplete = false;
view.on("auth-complete", function() {
authComplete = true;
});
};
var submitForm = function(validationSuccess) {
// Simulate manual entry of registration form data
$('#register-email').val(USER_DATA.email);
$('#register-name').val(USER_DATA.name);
$('#register-username').val(USER_DATA.username);
$('#register-password').val(USER_DATA.password);
$('#register-level_of_education').val(USER_DATA.level_of_education);
$('#register-gender').val(USER_DATA.gender);
$('#register-year_of_birth').val(USER_DATA.year_of_birth);
$('#register-mailing_address').val(USER_DATA.mailing_address);
$('#register-goals').val(USER_DATA.goals);
// Check the honor code checkbox
$('#register-honor_code').prop('checked', USER_DATA.honor_code);
// 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="register-form"></div>');
TemplateHelpers.installTemplate('templates/student_account/register');
TemplateHelpers.installTemplate('templates/student_account/form_field');
});
it('registers a new user', function() {
createRegisterView(this);
// Submit the form, with successful validation
submitForm( true );
// 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);
// Form button should be disabled on success.
expect(view.$submitButton).toHaveAttr('disabled');
});
it('sends analytics info containing the enrolled course ID', function() {
createRegisterView( 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 register
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 registration buttons', function() {
createRegisterView(this);
// Verify that Google and Facebook registration buttons are displayed
expect($('.button-Google')).toBeVisible();
expect($('.button-Facebook')).toBeVisible();
});
it('validates registration form fields', function() {
createRegisterView(this);
// Submit the form, with successful validation
submitForm(true);
// Verify that validation of form fields occurred
expect(view.validate).toHaveBeenCalledWith($('#register-email')[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-name')[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-username')[0]);
expect(view.validate).toHaveBeenCalledWith($('#register-password')[0]);
// Verify that no submission errors are visible
expect(view.$errors).toHaveClass('hidden');
// Form button should be disabled on success.
expect(view.$submitButton).toHaveAttr('disabled');
});
it('displays registration form validation errors', function() {
createRegisterView(this);
// Submit the form, with failed validation
submitForm(false);
// Verify that submission errors are visible
expect(view.$errors).not.toHaveClass('hidden');
// Expect that auth complete is NOT triggered
expect(authComplete).toBe(false);
// Form button should be re-enabled on error.
expect(view.$submitButton).not.toHaveAttr('disabled');
});
it('displays an error if the server returns an error while registering', function() {
createRegisterView(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);
// If we try again and succeed, the error should go away
submitForm();
// This time, respond with status code 200
AjaxHelpers.respondWithJson(requests, {});
// Expect that the error is hidden and that auth complete is triggered
expect(view.$errors).toHaveClass('hidden');
expect(authComplete).toBe(true);
// Form button should be disabled on success.
expect(view.$submitButton).toHaveAttr('disabled');
});
});
});
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 || {};
'use strict';
edx.student = edx.student || {};
edx.student.account = {};
edx.student.account = edx.student.account || {};
edx.student.account.AccountModel = Backbone.Model.extend({
// 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);
var edx = edx || {};
(function($, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.PasswordResetView = edx.student.account.FormView.extend({
el: '#password-reset-wrapper',
tpl: '#password_reset-tpl',
events: {
'click .js-reset': 'submitForm'
},
formType: 'password-reset',
requiredStr: '',
submitButton: '.js-reset',
preRender: function() {
this.listenTo( this.model, 'sync', this.saveSuccess );
},
toggleErrorMsg: function( show ) {
if ( show ) {
this.setErrors();
this.toggleDisableButton(false);
} else {
this.element.hide( this.$errors );
}
},
saveSuccess: function() {
var $el = $(this.el),
$msg = $el.find('.js-reset-success');
this.element.hide( $el.find('#password-reset-form') );
this.element.show( $msg );
this.element.scrollTop( $msg );
}
});
})(jQuery, gettext);
var edx = edx || {};
(function($, _, gettext) {
'use strict';
edx.student = edx.student || {};
edx.student.account = edx.student.account || {};
edx.student.account.RegisterView = edx.student.account.FormView.extend({
el: '#register-form',
tpl: '#register-tpl',
events: {
'click .js-register': 'submitForm',
'click .login-provider': 'thirdPartyAuth'
},
formType: 'register',
submitButton: '.js-register',
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;
},
thirdPartyAuth: function( event ) {
var providerUrl = $(event.target).data('provider-url') || '';
if ( providerUrl ) {
window.location.href = providerUrl;
}
},
saveSuccess: function() {
this.trigger('auth-complete');
}
});
})(jQuery, _, gettext);
......@@ -4,7 +4,7 @@ var edx = edx || {};
'use strict';
edx.student = edx.student || {};
edx.student.profile = {};
edx.student.profile = edx.student.profile || {};
var syncErrorMessage = gettext("The data could not be saved.");
......
......@@ -41,6 +41,7 @@ lib_paths:
- xmodule_js/common_static/js/vendor/flot/jquery.flot.js
- xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js
- xmodule_js/common_static/js/vendor/URI.min.js
- xmodule_js/common_static/js/vendor/url.min.js
- xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js
- xmodule_js/common_static/coffee/src/xblock
- xmodule_js/common_static/js/vendor/sinon-1.7.1.js
......@@ -49,6 +50,7 @@ lib_paths:
- xmodule_js/src/xmodule.js
- xmodule_js/common_static/js/src/
- xmodule_js/common_static/js/vendor/underscore-min.js
- xmodule_js/common_static/js/vendor/underscore.string.min.js
- xmodule_js/common_static/js/vendor/backbone-min.js
# Paths to source JavaScript files
......
......@@ -7,6 +7,7 @@
@import 'bourbon/bourbon'; // lib - bourbon
@import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages
// BASE *default edX offerings*
// ====================
......@@ -44,6 +45,7 @@
@import 'elements/system-feedback';
// base - specific views
@import 'views/login-register';
@import 'views/verification';
@import 'views/shoppingcart';
......
......@@ -6,7 +6,6 @@ $max-width: 1200px;
$border-box-sizing: false;
/* Breakpoints */
$mobile: new-breakpoint(max-width 320px 4);
$tablet: new-breakpoint(min-width 321px max-width 768px, 8);
......
......@@ -22,6 +22,12 @@ $monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace;
$body-font-family: $sans-serif;
$serif: $georgia;
// FONT-WEIGHTS
$font-light: 300;
$font-regular: 400;
$font-semibold: 600;
$font-bold: 700;
// ====================
// MISC: base fonts/colors
......
......@@ -585,7 +585,7 @@
list-style: none;
li {
margin: 0 0 ($baseline/4) 0;
margin: 0; /*0 0 ($baseline/4) 0;*/
}
}
}
......@@ -598,10 +598,6 @@
.message-title {
color: shade($red, 10%) !important;
}
.message-copy {
}
}
// misc
......
......@@ -371,6 +371,7 @@ $edx-footer-bg-color: rgb(252,252,252);
@extend %edx-footer-reset;
@extend %edx-footer-section;
width: flex-grid(3,12);
}
.footer-follow-title {
......
......@@ -634,15 +634,15 @@ header.global-new {
&.rwd {
nav {
max-width: 1180px;
width: 100%;
width: 320px;
}
.mobile-menu-button {
@extend %t-action1;
display: inline;
float: left;
text-decoration: none;
color: $m-gray;
font-size: 18px;
margin-top: 9px;
&:hover,
......@@ -666,7 +666,7 @@ header.global-new {
.nav-global,
.nav-courseware {
a {
@extend %t-action3;
font-size: 14px;
&.nav-courseware-button {
width: 86px;
......@@ -720,12 +720,6 @@ header.global-new {
}
}
@include media( 320px ) {
nav {
width: 320px;
}
}
@include media( $desktop ) {
nav {
width: 100%;
......
// lms - views - login/register view
// ====================
@import '../base/grid-settings';
@import "neat/neat"; // lib - Neat
%heading-4 {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0 !important;
color: $m-gray-d2;
}
%body-text {
font-size: 15px;
margin: 0 0 $baseline 0;
color: $base-font-color;
line-height: lh(1);
}
.section-bkg-wrapper {
background: $m-gray-l4;
}
.login-register {
@include box-sizing(border-box);
@include outer-container;
$grid-columns: 12;
background: white;
width: 100%;
h2 {
line-height: 16px;
margin: 0;
font-family: $sans-serif;
}
/* Temp. fix until applied globally */
> {
@include box-sizing(border-box);
}
/* Remove autocomplete yellow background */
input:-webkit-autofill {
-webkit-box-shadow:0 0 0 50px white inset;
-webkit-text-fill-color: #333;
}
input:-webkit-autofill:focus {
-webkit-box-shadow: white, 0 0 0 50px white inset;
-webkit-text-fill-color: #333;
}
.header {
@include outer-container;
border-bottom: 1px solid $gray-l4;
width: 100%;
padding-top: 35px;
padding-bottom: 35px;
overflow: hidden;
.headline {
@include box-sizing(border-box);
@include font-size(35);
padding: 0 10px;
font-family: $sans-serif;
font-weight: $font-semibold;
text-align: left;
margin-bottom: 0;
color: $m-blue-d5;
}
.tagline {
@include box-sizing(border-box);
@include font-size(24);
padding: 0 10px;
font-family: $sans-serif;
font-weight: $font-regular;
}
}
.form-toggle {
margin: 0;
}
.form-type {
@include box-sizing(border-box);
@include span-columns(12);
padding: 25px 10px;
&:nth-of-type(2) {
border-bottom: 1px solid $gray-l4;
}
}
.note {
@extend %t-copy-sub2;
display: block;
font-weight: normal;
color: $gray;
margin-top: 15px;
}
/** The forms **/
.form-wrapper {
padding-top: 25px;
form {
@include clearfix;
clear: both;
}
}
.login-form {
margin-bottom: 20px;
}
%bold-label {
@include font-size(16);
font-family: $sans-serif;
font-weight: $font-semibold;
font-style: normal;
text-transform: none;
}
.form-label {
@extend %bold-label;
padding: 0 0 0 5px;
letter-spacing: 1px;
}
.action-label {
@extend %bold-label;
margin-bottom: 20px;
}
.form-field {
@include clearfix;
clear: both;
position: relative;
width: 100%;
margin: 0 0 $baseline 0;
/** FROM _accounts.scss - start **/
label,
input,
textarea {
border-radius: 0;
height: auto;
font-family: $sans-serif;
font-style: normal;
font-weight: 500;
color: $base-font-color;
}
label {
@include transition(color 0.15s ease-in-out 0s);
margin: 0 0 ($baseline/4) 0;
color: tint($black, 20%);
font-weight: $font-semibold;
&.inline {
display: inline;
}
&.error {
color: $red;
}
a {
font-family: $sans-serif;
}
}
.field-link {
position: relative;
color: $link-color-d1;
font-weight: $font-regular;
text-decoration: none !important; // needed but nasty
font-family: $sans-serif;
font-size: 0.8em;
padding-top: 7px;
}
input,
textarea {
display: block;
width: 100%;
margin: 0;
padding: ($baseline/2) ($baseline*.75);
&.long {
width: 100%;
}
&.short {
width: 25%;
}
&.checkbox {
display: inline;
width: auto;
margin-right: 5px;
}
&.error {
border-color: tint($red,50%);
}
}
textarea.long {
height: ($baseline*5);
}
select {
width: 100%;
&.error {
border-color: tint($red,50%);
}
}
/** FROM _accounts.scss - end **/
}
.input-block {
width: 100%;
}
.input-inline {
display: inline;
}
.desc {
@include transition(color 0.15s ease-in-out 0s);
display: block;
margin-top: ($baseline/4);
color: $lighter-base-font-color;
font-size: em(13);
}
.action-primary {
@extend %m-btn-primary;
width: 100%;
text-transform: none;
color: white;
}
.login-provider {
@extend %btn-secondary-blue-outline;
width: 100%;
margin-bottom: 20px;
text-shadow: none;
text-transform: none;
.icon {
color: inherit;
margin-right: $baseline/2;
}
&:last-child {
margin-bottom: 20px;
}
&.button-Google:hover,
&.button-Google:focus {
background-color: #dd4b39;
border: 1px solid #A5382B;
}
&.button-Google:hover {
box-shadow: 0 2px 1px 0 #8D3024;
}
&.button-Facebook:hover,
&.button-Facebook:focus {
background-color: #3b5998;
border: 1px solid #263A62;
}
&.button-Facebook:hover {
box-shadow: 0 2px 1px 0 #30487C;
}
&.button-LinkedIn:hover,
&.button-LinkedIn:focus {
background-color: #0077b5;
border: 1px solid #06527D;
}
&.button-LinkedIn:hover {
box-shadow: 0 2px 1px 0 #005D8E;
}
}
/** Error Container - from _account.scss **/
.status {
@include box-sizing(border-box);
margin: 0 0 $baseline 0;
border-bottom: 3px solid shade($yellow, 10%);
padding: $baseline $baseline;
background: tint($yellow,20%);
.message-title {
@extend %heading-4;
font-family: $sans-serif;
margin: 0 0 ($baseline/4) 0;
font-size: em(14);
font-weight: 600;
}
.message-copy {
@extend %body-text;
font-family: $sans-serif;
margin: 0 !important;
padding: 0;
list-style: none;
li {
margin: 0 0 ($baseline/4) 0;
}
}
}
.submission-error, .system-error {
box-shadow: inset 0 -1px 2px 0 tint($red, 85%);
border-bottom: 3px solid shade($red, 10%);
background: tint($red,95%);
.message-title {
color: shade($red, 10%) !important;
}
.message-copy {
}
}
.submission-success {
box-shadow: inset 0 -1px 2px 0 tint($green, 85%);
border-bottom: 3px solid shade($green, 10%);
background: tint($green, 95%);
.message-title {
color: shade($green, 10%) !important;
}
.message-copy {
}
}
/** RWD **/
@include media( $tablet ) {
$grid-columns: 8;
%inline-form-field-tablet {
clear: none;
display: inline-block;
float: left;
}
.header .headline,
.header .tagline,
.form-type {
@include span-columns(6);
@include shift(1);
}
.form-toggle {
margin-right: 5px;
}
.form-field {
&.select-gender {
@extend %inline-form-field-tablet;
width: calc( 50% - 10px );
margin-right: 20px;
}
&.select-year_of_birth {
@extend %inline-form-field-tablet;
width: calc( 50% - 10px );
}
.field-link {
position: absolute;
top: 0;
right: 0;
}
}
}
@include media( $desktop ) {
$grid-columns: 12;
%inline-form-field-desktop {
clear: none;
display: inline-block;
float: left;
}
.header .headline,
.header .tagline,
.form-type {
width: 600px;
margin-left: calc( 50% - 300px );
margin-right: calc( 50% - 300px );
}
.form-toggle {
margin-right: 10px;
}
.form-field {
&.select-level_of_education {
@extend %inline-form-field-desktop;
width: 300px;
margin-right: 20px;
}
&.select-gender {
@extend %inline-form-field-desktop;
width: 60px;
margin-right: 20px;
}
&.select-year_of_birth {
@extend %inline-form-field-desktop;
width: 100px;
}
.field-link {
position: absolute;
top: 0;
right: 0;
}
}
.login-provider {
@include span-columns(6);
/* Node uses last-child which is not specific enough */
&:nth-of-type(2n) {
margin-right: 0;
}
}
}
}
......@@ -29,7 +29,7 @@
window.top.location.href = "${reverse('dashboard')}";
}
} else if (xhr.status == 403) {
window.top.location.href = "${reverse('register_user')}?course_id=${course.id | u}&enrollment_action=enroll";
window.top.location.href = $("a.register").attr("href") || "${reverse('register_user')}?course_id=${course.id | u}&enrollment_action=enroll";
} else {
$('#register_error').html(
(xhr.responseText ? xhr.responseText : "${_("An error occurred. Please try again later.")}")
......
<%! from django.utils.translation import ugettext as _ %>
<%! from django.template import RequestContext %>
<%! import third_party_auth %>
<%! from third_party_auth import pipeline %>
<%! from microsite_configuration import microsite %>
......@@ -95,7 +96,7 @@
<%include file='dashboard/_dashboard_info_language.html' />
%endif
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
<li class="controls--account">
<span class="title">
## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account.
......
......@@ -4,6 +4,7 @@
<%! from django.core.urlresolvers import reverse %>
<%! from django.utils.translation import ugettext as _ %>
<%! import third_party_auth %>
<%! from third_party_auth import provider, pipeline %>
<%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)}</%block>
......@@ -48,8 +49,16 @@
$('#login-form').on('ajax:error', function(event, request, status_string) {
toggleSubmitButton(true);
$('.third-party-signin.message').addClass('is-shown').focus();
$('.third-party-signin.message .instructions').html(request.responseText);
if (request.status === 403) {
$('.message.submission-error').removeClass('is-shown');
$('.third-party-signin.message').addClass('is-shown').focus();
$('.third-party-signin.message .instructions').html(request.responseText);
} else {
$('.third-party-signin.message').removeClass('is-shown');
$('.message.submission-error').addClass('is-shown').focus();
$('.message.submission-error').html(gettext("Your request could not be completed. Please try again."));
}
});
$('#login-form').on('ajax:success', function(event, json, xhr) {
......@@ -190,11 +199,11 @@
% endif
<div class="form-actions">
<button name="submit" type="submit" id="submit" class="action action-primary action-update"></button>
<button name="submit" type="submit" id="submit" class="action action-primary action-update login-button"></button>
</div>
</form>
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
<span class="deco-divider">
## Developers: this is a sentence fragment, which is usually frowned upon. The design of the pags uses this fragment to provide an "else" clause underneath a number of choices. It's OK to leave it.
......@@ -206,7 +215,7 @@
% for enabled in provider.Registry.enabled():
## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn).
<button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button>
<button type="submit" class="button button-primary button-${enabled.NAME} login-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_url[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign in with {provider_name}').format(provider_name=enabled.NAME)}</button>
% endfor
</div>
......
......@@ -112,6 +112,7 @@
<![endif]-->
<%include file="widgets/optimizely.html" />
<%include file="widgets/segment-io.html" />
<meta name="path_prefix" content="${EDX_ROOT_URL}">
<meta name="google-site-verification" content="_mipQ4AtZQDNmbtOkwehQDOgCxUUV2fb_C0b6wbiRHY" />
......@@ -153,8 +154,6 @@
% endif
<%block name="js_extra"/>
<%include file="widgets/segment-io.html" />
</body>
</html>
......
......@@ -12,6 +12,7 @@
<%! from django.utils.translation import ugettext as _ %>
<%! from student.models import UserProfile %>
<%! from datetime import date %>
<%! import third_party_auth %>
<%! from third_party_auth import pipeline, provider %>
<%! import calendar %>
......@@ -116,7 +117,7 @@
<ul class="message-copy"> </ul>
</div>
% if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')):
% if third_party_auth.is_enabled():
% if not running_pipeline:
......@@ -124,7 +125,7 @@
% for enabled in provider.Registry.enabled():
## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn).
<button type="submit" class="button button-primary button-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.NAME)}</button>
<button type="submit" class="button button-primary button-${enabled.NAME} register-${enabled.NAME}" onclick="thirdPartySignin(event, '${pipeline_urls[enabled.NAME]}');"><span class="icon ${enabled.ICON_CLASS}"></span>${_('Sign up with {provider_name}').format(provider_name=enabled.NAME)}</button>
% endfor
</div>
......@@ -182,7 +183,7 @@
<span class="tip tip-input" id="username-tip">${_('Will be shown in any discussions or forums you participate in')} <strong>(${_('cannot be changed later')})</strong></span>
</li>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and running_pipeline:
% if third_party_auth.is_enabled() and running_pipeline:
<li class="is-disabled field optional password" id="field-password" hidden>
<label for="password">${_('Password')}</label>
......@@ -361,7 +362,7 @@
% endif
<div class="form-actions">
<button name="submit" type="submit" id="submit" class="action action-primary action-update">${_('Register')} <span class="orn-plus">+</span> ${_('Create My Account')}</button>
<button name="submit" type="submit" id="submit" class="action action-primary action-update register-button">${_('Register')} <span class="orn-plus">+</span> ${_('Create My Account')}</button>
</div>
</form>
</section>
......
<header class="js-login-register-header header">
<h1 class="headline"><%- gettext("Welcome!") %></h1>
<p class="tagline"><%- gettext("Log in or register to take courses from the world's best universities.") %></p>
</header>
<section id="form-load-fail" class="form-type hidden" aria-hidden="true">
<div class="status submission-error">
<p class="message-copy"><%- gettext("Sorry, we're having some technical problems. Wait a few minutes and try again.") %></p>
</div>
</section>
<% if ( mode === 'login' ) { %>
<section id="register-anchor" class="form-type">
<span>
<input type="radio" name="form" id="register-option" value="register" class="form-toggle" <% if ( mode === 'register' ) { %>checked<% } %> >
<label for="register-option" class="form-label"><%- gettext("I am a new user") %></label>
</span>
<div id="register-form" class="form-wrapper <% if ( mode !== 'register' ) { %>hidden" aria-hidden="true<% } %>"></div>
</section>
<% } %>
<section id="login-anchor" class="form-type">
<span>
<input type="radio" name="form" id="login-option" value="login" class="form-toggle" <% if ( mode === 'login' ) { %>checked<% } %>>
<label for="login-option" class="form-label"><%- gettext("I am a returning user") %></label>
</span>
<div id="login-form" class="form-wrapper <% if ( mode !== 'login' ) { %>hidden" aria-hidden="true<% } %>"></div>
</section>
<% if ( mode === 'register' ) { %>
<section id="register-anchor" class="form-type">
<span>
<input type="radio" name="form" id="register-option" value="register" class="form-toggle" <% if ( mode === 'register' ) { %>checked<% } %> >
<label for="register-option" class="form-label"><%- gettext("I am a new user") %></label>
</span>
<div id="register-form" class="form-wrapper <% if ( mode !== 'register' ) { %>hidden" aria-hidden="true<% } %>"></div>
</section>
<% } %>
<div id="password-reset-wrapper"></div>
\ No newline at end of file
<p class="form-field <%=type%>-<%= name %>">
<% if ( type !== 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>">
<%= label %>
<% if ( required && requiredStr ) { %> <%= requiredStr %></label><% } %>
</label>
<% } %>
<% if ( type === 'select' ) { %>
<select id="<%= form %>-<%= name %>"
name="<%= name %>"
class="input-inline"
aria-describedby="<%= form %>-<%= name %>-desc"
<% if ( required ) { %> aria-required="true" required<% } %>>
<% _.each(options, function(el) { %>
<option value="<%= el.value%>"<% if ( el.default ) { %> data-isdefault="true"<% } %>><%= el.name %></option>
<% }); %>
</select>
<% } else if ( type === 'textarea' ) { %>
<textarea id="<%= form %>-<%= name %>"
type="<%= type %>"
name="<%= name %>"
class="input-block"
aria-describedby="<%= form %>-<%= name %>-desc"
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %>
<% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>"
<% });
} %>
<% if ( required ) { %> aria-required="true" required<% } %> ></textarea>
<% } else { %>
<input id="<%= form %>-<%= name %>"
type="<%= type %>"
name="<%= name %>"
class="input-block <% if ( type === 'checkbox' ) { %>checkbox<% } %>"
aria-describedby="<%= form %>-<%= name %>-desc"
<% if ( restrictions.min_length ) { %> minlength="<%= restrictions.min_length %>"<% } %>
<% if ( restrictions.max_length ) { %> maxlength="<%= restrictions.max_length %>"<% } %>
<% if ( required ) { %> aria-required="true" required<% } %>
<% if ( typeof errorMessages !== 'undefined' ) {
_.each(errorMessages, function( msg, type ) {%>
data-errormsg-<%= type %>="<%= msg %>"
<% });
} %>
value="<%- defaultValue %>"
/>
<% } %>
<% if ( type === 'checkbox' ) { %>
<label for="<%= form %>-<%= name %>" class="inline">
<%= label %>
<% if ( required && requiredStr ) { %> <%= requiredStr %><% } %>
</label>
<% } %>
<% if( form === 'login' && name === 'password' ) { %>
<a href="#" class="forgot-password field-link"><%- gettext("Forgot password?") %></a>
<% } %>
<span id="<%= form %>-<%= name %>-desc" class="desc"><%= instructions %></span>
</p>
<form id="login" class="login-form">
<div class="status already-authenticated-msg hidden" aria-hidden="true">
<% if (context.currentProvider) { %>
<p class="message-copy">
<%- _.sprintf(gettext("You've successfully logged into %(currentProvider)s, but your %(currentProvider)s account isn't linked with an %(platformName)s account. To link your accounts, go to your %(platformName)s dashboard."), context) %>
</p>
<% } %>
</div>
<div class="status submission-error hidden" aria-hidden="true" aria-live="polite">
<h4 class="message-title"><%- gettext("We couldn't log you in.") %></h4>
<ul class="message-copy"></ul>
</div>
<%= context.fields %>
<button class="action action-primary action-update js-login login-button"><%- gettext("Log in") %></button>
</form>
<% _.each( context.providers, function( provider ) {
if ( provider.loginUrl ) { %>
<button type="submit" class="button button-primary button-<%- provider.name %> login-provider login-<%- provider.name %>" data-provider-url="<%- provider.loginUrl %>">
<span class="icon <%- provider.iconClass %>" aria-hidden="true"></span>
<%- _.sprintf(gettext("Log in using %(name)s"), provider) %>
</button>
<% }
}); %>
<%! from django.utils.translation import ugettext as _ %>
<%namespace name='static' file='/static_content.html'/>
<%inherit file="../main.html" />
<%block name="pagetitle">${_("Log in or Register")}</%block>
<%block name="js_extra">
<script src="${static.url('js/vendor/underscore-min.js')}"></script>
<script src="${static.url('js/vendor/underscore.string.min.js')}"></script>
<script src="${static.url('js/vendor/backbone-min.js')}"></script>
<script src="${static.url('js/vendor/url.min.js')}"></script>
<%static:js group='student_account'/>
</%block>
<%block name="header_extras">
% for template_name in ["account", "access", "form_field", "login", "register", "password_reset"]:
<script type="text/template" id="${template_name}-tpl">
<%static:include path="student_account/${template_name}.underscore" />
</script>
% endfor
</%block>
<div class="section-bkg-wrapper">
<div id="login-and-registration-container"
class="login-register"
data-initial-mode="${initial_mode}"
data-third-party-auth='${third_party_auth}'
data-platform-name='${platform_name}'
/>
</div>
<header class="header">
<h1 class="headline"><%- gettext("Reset Password") %></h1>
</header>
<section class="form-type">
<div id="password-reset-form" class="form-wrapper">
<p class="action-label"><%- gettext("Enter the email address you used to create your account. We'll send you a link you can use to reset your password.") %></p>
<form id="password-reset-form">
<div class="status submission-error hidden" aria-hidden="true" aria-live="polite">
<h4 class="message-title"><%- gettext("An error occurred.") %></h4>
<ul class="message-copy"></ul>
</div>
<%= fields %>
<button class="action action-primary action-update js-reset"><%- gettext("Reset password") %></button>
</form>
</div>
<div class="js-reset-success status submission-success hidden" aria-hidden="true">
<h4 class="message-title"><%- gettext("Password Reset Email Sent") %></h4>
<div class="message-copy">
<p>
<%- gettext("We've sent instructions for resetting your password to the email address you provided.") %>
</p>
</div>
</div>
</section>
<% if (context.currentProvider) { %>
<div class="status" aria-hidden="false">
<p class="message-copy">
<%- _.sprintf(gettext("You've successfully logged into %(currentProvider)s."), context) %>
<%- _.sprintf(gettext("We just need a little more information before you start learning with %(platformName)s."), context) %>
</p>
</div>
<% } else {
_.each( context.providers, function( provider) {
if ( provider.registerUrl ) { %>
<button type="submit" class="button button-primary button-<%- provider.name %> login-provider register-<%- provider.name %>" data-provider-url="<%- provider.registerUrl %>">
<span class="icon <%- provider.iconClass %>" aria-hidden="true"></span>
<%- _.sprintf(gettext("Register using %(name)s"), provider) %>
</button>
<% }
});
} %>
<form id="register" autocomplete="off">
<div class="status submission-error hidden" aria-hidden="true" aria-live="polite">
<h4 class="message-title"><%- gettext("We couldn't complete your registration.") %></h4>
<ul class="message-copy"></ul>
</div>
<%= context.fields %>
<button class="action action-primary action-update js-register register-button"><%- gettext("Register") %></button>
<p class="note">* <%- gettext("Required field") %></p>
</form>
<%! from django.utils.translation import ugettext as _ %>
<%! import third_party_auth %>
<%namespace name='static' file='/static_content.html'/>
<%inherit file="../main.html" />
......@@ -25,6 +26,6 @@
<div id="profile-container"></div>
% if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
% if third_party_auth.is_enabled():
<%include file="third_party_auth.html" />
% endif
......@@ -73,6 +73,9 @@ urlpatterns = ('', # nopep8
# Feedback Form endpoint
url(r'^submit_feedback$', 'util.views.submit_feedback'),
# Enrollment API RESTful endpoints
url(r'^enrollment/v0/', include('enrollment.urls')),
)
if settings.FEATURES["ENABLE_MOBILE_REST_API"]:
......@@ -370,6 +373,10 @@ if settings.COURSEWARE_ENABLED:
# LTI endpoints listing
url(r'^courses/{}/lti_rest_endpoints/'.format(settings.COURSE_ID_PATTERN),
'courseware.views.get_course_lti_endpoints', name='lti_rest_endpoints'),
# Student account and profile
url(r'^account/', include('student_account.urls')),
url(r'^profile/', include('student_profile.urls')),
)
# allow course staff to change to student view of courseware
......@@ -537,12 +544,6 @@ if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'):
url(r'^login_oauth_token/(?P<backend>[^/]+)/$', 'student.views.login_oauth_token'),
)
# If enabled, expose the URLs for the new dashboard, account, and profile pages
if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'):
urlpatterns += (
url(r'^profile/', include('student_profile.urls')),
url(r'^account/', include('student_account.urls')),
)
urlpatterns = patterns(*urlpatterns)
......
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