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
|
||||
|
||||
Reference in New Issue
Block a user