Merge pull request #5948 from edx/will/combine-reg-login-form
Combined Login/Registration Form
This commit is contained in:
@@ -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),
|
||||
|
||||
0
common/djangoapps/enrollment/__init__.py
Normal file
0
common/djangoapps/enrollment/__init__.py
Normal file
374
common/djangoapps/enrollment/api.py
Normal file
374
common/djangoapps/enrollment/api.py
Normal file
@@ -0,0 +1,374 @@
|
||||
"""
|
||||
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)
|
||||
104
common/djangoapps/enrollment/data.py
Normal file
104
common/djangoapps/enrollment/data.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
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)
|
||||
4
common/djangoapps/enrollment/models.py
Normal file
4
common/djangoapps/enrollment/models.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""
|
||||
A models.py is required to make this an app (until we move to Django 1.7)
|
||||
|
||||
"""
|
||||
83
common/djangoapps/enrollment/serializers.py
Normal file
83
common/djangoapps/enrollment/serializers.py
Normal file
@@ -0,0 +1,83 @@
|
||||
"""
|
||||
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()
|
||||
0
common/djangoapps/enrollment/tests/__init__.py
Normal file
0
common/djangoapps/enrollment/tests/__init__.py
Normal file
102
common/djangoapps/enrollment/tests/fake_data_api.py
Normal file
102
common/djangoapps/enrollment/tests/fake_data_api.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
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 = []
|
||||
151
common/djangoapps/enrollment/tests/test_api.py
Normal file
151
common/djangoapps/enrollment/tests/test_api.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""
|
||||
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')
|
||||
174
common/djangoapps/enrollment/tests/test_data.py
Normal file
174
common/djangoapps/enrollment/tests/test_data.py
Normal file
@@ -0,0 +1,174 @@
|
||||
"""
|
||||
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,
|
||||
)
|
||||
169
common/djangoapps/enrollment/tests/test_views.py
Normal file
169
common/djangoapps/enrollment/tests/test_views.py
Normal file
@@ -0,0 +1,169 @@
|
||||
"""
|
||||
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
|
||||
21
common/djangoapps/enrollment/urls.py
Normal file
21
common/djangoapps/enrollment/urls.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
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'
|
||||
),
|
||||
)
|
||||
126
common/djangoapps/enrollment/views.py
Normal file
126
common/djangoapps/enrollment/views.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""
|
||||
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()
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""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
|
||||
|
||||
253
common/djangoapps/user_api/tests/test_constants.py
Normal file
253
common/djangoapps/user_api/tests/test_constants.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""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')
|
||||
]
|
||||
@@ -1,10 +1,10 @@
|
||||
"""
|
||||
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)
|
||||
@@ -1,19 +1,25 @@
|
||||
"""
|
||||
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):
|
||||
|
||||
@@ -1,15 +1,29 @@
|
||||
import base64
|
||||
"""Tests for the user API at the HTTP request level. """
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test.utils import override_settings
|
||||
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
|
||||
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"])
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
# 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"),
|
||||
)
|
||||
|
||||
@@ -1,17 +1,31 @@
|
||||
"""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))
|
||||
|
||||
192
common/static/js/spec/edx.utils.validate_spec.js
Normal file
192
common/static/js/spec/edx.utils.validate_spec.js
Normal file
@@ -0,0 +1,192 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
186
common/static/js/utils/edx.utils.validate.js
Normal file
186
common/static/js/utils/edx.utils.validate.js
Normal file
@@ -0,0 +1,186 @@
|
||||
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 );
|
||||
97
common/static/js/utils/rwd_header_footer.js
Normal file
97
common/static/js/utils/rwd_header_footer.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* 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);
|
||||
1
common/static/js/vendor/url.min.js
vendored
Normal file
1
common/static/js/vendor/url.min.js
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/*! 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)}});
|
||||
@@ -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):
|
||||
|
||||
262
common/test/acceptance/pages/lms/login_and_register.py
Normal file
262
common/test/acceptance/pages/lms/login_and_register.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""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()
|
||||
@@ -1,61 +0,0 @@
|
||||
"""
|
||||
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
|
||||
|
||||
4
lms/djangoapps/student_account/helpers.py
Normal file
4
lms/djangoapps/student_account/helpers.py
Normal file
@@ -0,0 +1,4 @@
|
||||
"""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)
|
||||
)
|
||||
|
||||
@@ -1,9 +1,21 @@
|
||||
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'),
|
||||
)
|
||||
|
||||
@@ -1,23 +1,27 @@
|
||||
""" 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)
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
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);
|
||||
|
||||
276
lms/static/js/spec/student_account/access_spec.js
Normal file
276
lms/static/js/spec/student_account/access_spec.js
Normal file
@@ -0,0 +1,276 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
49
lms/static/js/spec/student_account/enrollment_spec.js
Normal file
49
lms/static/js/spec/student_account/enrollment_spec.js
Normal file
@@ -0,0 +1,49 @@
|
||||
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 );
|
||||
});
|
||||
|
||||
});
|
||||
}
|
||||
);
|
||||
252
lms/static/js/spec/student_account/login_spec.js
Normal file
252
lms/static/js/spec/student_account/login_spec.js
Normal file
@@ -0,0 +1,252 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
139
lms/static/js/spec/student_account/password_reset_spec.js
Normal file
139
lms/static/js/spec/student_account/password_reset_spec.js
Normal file
@@ -0,0 +1,139 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
345
lms/static/js/spec/student_account/register_spec.js
Normal file
345
lms/static/js/spec/student_account/register_spec.js
Normal file
@@ -0,0 +1,345 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
48
lms/static/js/spec/student_account/shoppingcart_spec.js
Normal file
48
lms/static/js/spec/student_account/shoppingcart_spec.js
Normal file
@@ -0,0 +1,48 @@
|
||||
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 );
|
||||
});
|
||||
});
|
||||
}
|
||||
);
|
||||
14
lms/static/js/student_account/accessApp.js
Normal file
14
lms/static/js/student_account/accessApp.js
Normal file
@@ -0,0 +1,14 @@
|
||||
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
|
||||
|
||||
63
lms/static/js/student_account/enrollment.js
Normal file
63
lms/static/js/student_account/enrollment.js
Normal file
@@ -0,0 +1,63 @@
|
||||
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);
|
||||
58
lms/static/js/student_account/models/LoginModel.js
Normal file
58
lms/static/js/student_account/models/LoginModel.js
Normal file
@@ -0,0 +1,58 @@
|
||||
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);
|
||||
44
lms/static/js/student_account/models/PasswordResetModel.js
Normal file
44
lms/static/js/student_account/models/PasswordResetModel.js
Normal file
@@ -0,0 +1,44 @@
|
||||
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);
|
||||
64
lms/static/js/student_account/models/RegisterModel.js
Normal file
64
lms/static/js/student_account/models/RegisterModel.js
Normal file
@@ -0,0 +1,64 @@
|
||||
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);
|
||||
49
lms/static/js/student_account/shoppingcart.js
Normal file
49
lms/static/js/student_account/shoppingcart.js
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* 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);
|
||||
283
lms/static/js/student_account/views/AccessView.js
Normal file
283
lms/static/js/student_account/views/AccessView.js
Normal file
@@ -0,0 +1,283 @@
|
||||
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);
|
||||
262
lms/static/js/student_account/views/FormView.js
Normal file
262
lms/static/js/student_account/views/FormView.js
Normal file
@@ -0,0 +1,262 @@
|
||||
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);
|
||||
110
lms/static/js/student_account/views/LoginView.js
Normal file
110
lms/static/js/student_account/views/LoginView.js
Normal file
@@ -0,0 +1,110 @@
|
||||
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);
|
||||
47
lms/static/js/student_account/views/PasswordResetView.js
Normal file
47
lms/static/js/student_account/views/PasswordResetView.js
Normal file
@@ -0,0 +1,47 @@
|
||||
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);
|
||||
64
lms/static/js/student_account/views/RegisterView.js
Normal file
64
lms/static/js/student_account/views/RegisterView.js
Normal file
@@ -0,0 +1,64 @@
|
||||
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%;
|
||||
|
||||
452
lms/static/sass/views/_login-register.scss
Normal file
452
lms/static/sass/views/_login-register.scss
Normal file
@@ -0,0 +1,452 @@
|
||||
// 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.")}")
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<%! 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>
|
||||
|
||||
40
lms/templates/student_account/access.underscore
Normal file
40
lms/templates/student_account/access.underscore
Normal file
@@ -0,0 +1,40 @@
|
||||
<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>
|
||||
63
lms/templates/student_account/form_field.underscore
Normal file
63
lms/templates/student_account/form_field.underscore
Normal file
@@ -0,0 +1,63 @@
|
||||
<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>
|
||||
27
lms/templates/student_account/login.underscore
Normal file
27
lms/templates/student_account/login.underscore
Normal file
@@ -0,0 +1,27 @@
|
||||
<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>
|
||||
<% }
|
||||
}); %>
|
||||
31
lms/templates/student_account/login_and_register.html
Normal file
31
lms/templates/student_account/login_and_register.html
Normal file
@@ -0,0 +1,31 @@
|
||||
<%! 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>
|
||||
28
lms/templates/student_account/password_reset.underscore
Normal file
28
lms/templates/student_account/password_reset.underscore
Normal file
@@ -0,0 +1,28 @@
|
||||
<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>
|
||||
29
lms/templates/student_account/register.underscore
Normal file
29
lms/templates/student_account/register.underscore
Normal file
@@ -0,0 +1,29 @@
|
||||
<% 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>
|
||||
@@ -1,4 +1,5 @@
|
||||
<%! 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
|
||||
|
||||
13
lms/urls.py
13
lms/urls.py
@@ -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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user