diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index 70ee329610..11a1cd3540 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -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), diff --git a/common/djangoapps/enrollment/__init__.py b/common/djangoapps/enrollment/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py new file mode 100644 index 0000000000..e5511989bc --- /dev/null +++ b/common/djangoapps/enrollment/api.py @@ -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) diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py new file mode 100644 index 0000000000..fe1e07552f --- /dev/null +++ b/common/djangoapps/enrollment/data.py @@ -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) diff --git a/common/djangoapps/enrollment/models.py b/common/djangoapps/enrollment/models.py new file mode 100644 index 0000000000..3f0363134b --- /dev/null +++ b/common/djangoapps/enrollment/models.py @@ -0,0 +1,4 @@ +""" +A models.py is required to make this an app (until we move to Django 1.7) + +""" diff --git a/common/djangoapps/enrollment/serializers.py b/common/djangoapps/enrollment/serializers.py new file mode 100644 index 0000000000..7ec8b6bc16 --- /dev/null +++ b/common/djangoapps/enrollment/serializers.py @@ -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() diff --git a/common/djangoapps/enrollment/tests/__init__.py b/common/djangoapps/enrollment/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/djangoapps/enrollment/tests/fake_data_api.py b/common/djangoapps/enrollment/tests/fake_data_api.py new file mode 100644 index 0000000000..80140aaec5 --- /dev/null +++ b/common/djangoapps/enrollment/tests/fake_data_api.py @@ -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 = [] diff --git a/common/djangoapps/enrollment/tests/test_api.py b/common/djangoapps/enrollment/tests/test_api.py new file mode 100644 index 0000000000..fe996bd6dc --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_api.py @@ -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') diff --git a/common/djangoapps/enrollment/tests/test_data.py b/common/djangoapps/enrollment/tests/test_data.py new file mode 100644 index 0000000000..e39b3ea74e --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_data.py @@ -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, + ) diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py new file mode 100644 index 0000000000..06e55a6d02 --- /dev/null +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -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 diff --git a/common/djangoapps/enrollment/urls.py b/common/djangoapps/enrollment/urls.py new file mode 100644 index 0000000000..e3621a2e94 --- /dev/null +++ b/common/djangoapps/enrollment/urls.py @@ -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' + ), + ) diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py new file mode 100644 index 0000000000..c3970cfe58 --- /dev/null +++ b/common/djangoapps/enrollment/views.py @@ -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) diff --git a/common/djangoapps/student/tests/test_login_registration_forms.py b/common/djangoapps/student/tests/test_login_registration_forms.py index 598c920021..346ed4cdd9 100644 --- a/common/djangoapps/student/tests/test_login_registration_forms.py +++ b/common/djangoapps/student/tests/test_login_registration_forms.py @@ -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): diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 734f0c3e62..9429502cae 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -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']) diff --git a/common/djangoapps/third_party_auth/middleware.py b/common/djangoapps/third_party_auth/middleware.py index 677534d0fc..fe843f6a7b 100644 --- a/common/djangoapps/third_party_auth/middleware.py +++ b/common/djangoapps/third_party_auth/middleware.py @@ -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 diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index bc4a5b35c5..6fb7951fb5 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -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 diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 071700376d..da29b9b9a3 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -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, diff --git a/common/djangoapps/third_party_auth/tests/testutil.py b/common/djangoapps/third_party_auth/tests/testutil.py index f4f3600df7..eb3f84e5e6 100644 --- a/common/djangoapps/third_party_auth/tests/testutil.py +++ b/common/djangoapps/third_party_auth/tests/testutil.py @@ -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() diff --git a/common/djangoapps/user_api/api/account.py b/common/djangoapps/user_api/api/account.py index 277e1bcb38..118838c7b4 100644 --- a/common/djangoapps/user_api/api/account.py +++ b/common/djangoapps/user_api/api/account.py @@ -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. diff --git a/common/djangoapps/user_api/user_service.py b/common/djangoapps/user_api/api/course_tag.py similarity index 93% rename from common/djangoapps/user_api/user_service.py rename to common/djangoapps/user_api/api/course_tag.py index 7c7a01ce34..43907fbe72 100644 --- a/common/djangoapps/user_api/user_service.py +++ b/common/djangoapps/user_api/api/course_tag.py @@ -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? diff --git a/common/djangoapps/user_api/api/profile.py b/common/djangoapps/user_api/api/profile.py index fc14e47273..35356b6256 100644 --- a/common/djangoapps/user_api/api/profile.py +++ b/common/djangoapps/user_api/api/profile.py @@ -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 diff --git a/common/djangoapps/user_api/helpers.py b/common/djangoapps/user_api/helpers.py index 1cae378786..04bd7065e0 100644 --- a/common/djangoapps/user_api/helpers.py +++ b/common/djangoapps/user_api/helpers.py @@ -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 diff --git a/common/djangoapps/user_api/tests/test_constants.py b/common/djangoapps/user_api/tests/test_constants.py new file mode 100644 index 0000000000..7508965559 --- /dev/null +++ b/common/djangoapps/user_api/tests/test_constants.py @@ -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') +] diff --git a/common/djangoapps/user_api/tests/test_user_service.py b/common/djangoapps/user_api/tests/test_course_tag_api.py similarity index 58% rename from common/djangoapps/user_api/tests/test_user_service.py rename to common/djangoapps/user_api/tests/test_course_tag_api.py index 366a2cb27b..51b48a56a4 100644 --- a/common/djangoapps/user_api/tests/test_user_service.py +++ b/common/djangoapps/user_api/tests/test_course_tag_api.py @@ -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) diff --git a/common/djangoapps/user_api/tests/test_helpers.py b/common/djangoapps/user_api/tests/test_helpers.py index 7daf5c2a4b..76fcce7ddf 100644 --- a/common/djangoapps/user_api/tests/test_helpers.py +++ b/common/djangoapps/user_api/tests/test_helpers.py @@ -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) diff --git a/common/djangoapps/user_api/tests/test_profile_api.py b/common/djangoapps/user_api/tests/test_profile_api.py index 823985588c..5f78c0a832 100644 --- a/common/djangoapps/user_api/tests/test_profile_api.py +++ b/common/djangoapps/user_api/tests/test_profile_api.py @@ -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): diff --git a/common/djangoapps/user_api/tests/test_views.py b/common/djangoapps/user_api/tests/test_views.py index ddebd1e5f4..01886c0093 100644 --- a/common/djangoapps/user_api/tests/test_views.py +++ b/common/djangoapps/user_api/tests/test_views.py @@ -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} Terms of Service and Honor Code.".format( + platform_name=settings.PLATFORM_NAME + ), + "name": "honor_code", + "defaultValue": False, + "type": "checkbox", + "required": True, + "errorMessages": { + "required": "You must agree to the {platform_name} Terms of Service and Honor Code.".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} Terms of Service and Honor Code.".format( + platform_name=settings.PLATFORM_NAME + ), + "name": "honor_code", + "defaultValue": False, + "type": "checkbox", + "required": True, + "errorMessages": { + "required": "You must agree to the {platform_name} Terms of Service and Honor Code.".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} Honor Code.".format( + platform_name=settings.PLATFORM_NAME + ), + "name": "honor_code", + "defaultValue": False, + "type": "checkbox", + "required": True, + "errorMessages": { + "required": "You must agree to the {platform_name} Honor Code.".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} Terms of Service.".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} Terms of Service.".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} Honor Code.".format( + platform_name=settings.PLATFORM_NAME + ), + "name": "honor_code", + "defaultValue": False, + "type": "checkbox", + "required": True, + "errorMessages": { + "required": "You must agree to the {platform_name} Honor Code.".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} Terms of Service.".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} Terms of Service.".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"]) diff --git a/common/djangoapps/user_api/urls.py b/common/djangoapps/user_api/urls.py index 9fd20194ea..55a2e077cd 100644 --- a/common/djangoapps/user_api/urls.py +++ b/common/djangoapps/user_api/urls.py @@ -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"), + ) diff --git a/common/djangoapps/user_api/views.py b/common/djangoapps/user_api/views.py index d007d7a9aa..ac3511b341 100644 --- a/common/djangoapps/user_api/views.py +++ b/common/djangoapps/user_api/views.py @@ -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"{terms_text}".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"{terms_text}".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,) diff --git a/common/djangoapps/util/testing.py b/common/djangoapps/util/testing.py index fdd61a2a6a..ccfd29e91d 100644 --- a/common/djangoapps/util/testing.py +++ b/common/djangoapps/util/testing.py @@ -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)) diff --git a/common/static/js/spec/edx.utils.validate_spec.js b/common/static/js/spec/edx.utils.validate_spec.js new file mode 100644 index 0000000000..2983f421b2 --- /dev/null +++ b/common/static/js/spec/edx.utils.validate_spec.js @@ -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(''); + + 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 = [ + '' + ].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); + }); +}); diff --git a/common/static/js/utils/edx.utils.validate.js b/common/static/js/utils/edx.utils.validate.js new file mode 100644 index 0000000000..5a2afa7b02 --- /dev/null +++ b/common/static/js/utils/edx.utils.validate.js @@ -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: '
  • <%- gettext("The email address you\'ve provided isn\'t formatted correctly.") %>
  • ', + min: '
  • <%- _.sprintf(gettext("%(field)s must have at least %(count)d characters."), context) %>
  • ', + max: '
  • <%- _.sprintf(gettext("%(field)s can only contain up to %(count)d characters."), context) %>
  • ', + required: '
  • <%- _.sprintf(gettext("The %(field)s field cannot be empty."), context) %>
  • ', + custom: '
  • <%= content %>
  • ' + }, + + 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 ); diff --git a/common/static/js/utils/rwd_header_footer.js b/common/static/js/utils/rwd_header_footer.js new file mode 100644 index 0000000000..5770381472 --- /dev/null +++ b/common/static/js/utils/rwd_header_footer.js @@ -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([ + '', + '', + '' + ].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.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); diff --git a/common/static/js/vendor/url.min.js b/common/static/js/vendor/url.min.js new file mode 100644 index 0000000000..796e0f6791 --- /dev/null +++ b/common/static/js/vendor/url.min.js @@ -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)}}); \ No newline at end of file diff --git a/common/static/js_test.yml b/common/static/js_test.yml index d6f0db4b7f..b83c3f6bd1 100644 --- a/common/static/js_test.yml +++ b/common/static/js_test.yml @@ -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 diff --git a/common/test/acceptance/pages/lms/course_about.py b/common/test/acceptance/pages/lms/course_about.py index 6290229a81..83ec4ffcd4 100644 --- a/common/test/acceptance/pages/lms/course_about.py +++ b/common/test/acceptance/pages/lms/course_about.py @@ -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): diff --git a/common/test/acceptance/pages/lms/login_and_register.py b/common/test/acceptance/pages/lms/login_and_register.py new file mode 100644 index 0000000000..1bda4fa2d8 --- /dev/null +++ b/common/test/acceptance/pages/lms/login_and_register.py @@ -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() diff --git a/common/test/acceptance/pages/lms/register.py b/common/test/acceptance/pages/lms/register.py deleted file mode 100644 index e2711c1cd1..0000000000 --- a/common/test/acceptance/pages/lms/register.py +++ /dev/null @@ -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 diff --git a/common/test/acceptance/tests/lms/test_lms.py b/common/test/acceptance/tests/lms/test_lms.py index 54f9aae3b5..09853250bf 100644 --- a/common/test/acceptance/tests/lms/test_lms.py +++ b/common/test/acceptance/tests/lms/test_lms.py @@ -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 diff --git a/lms/djangoapps/student_account/helpers.py b/lms/djangoapps/student_account/helpers.py new file mode 100644 index 0000000000..cd2e8d947f --- /dev/null +++ b/lms/djangoapps/student_account/helpers.py @@ -0,0 +1,4 @@ +"""Helper functions for the student account app. """ + +# TODO: move this function here instead of importing it from student # pylint: disable=fixme +from student.helpers import auth_pipeline_urls # pylint: disable=unused-import diff --git a/lms/djangoapps/student_account/test/test_views.py b/lms/djangoapps/student_account/test/test_views.py index 5b5a6517d5..b293292f3f 100644 --- a/lms/djangoapps/student_account/test/test_views.py +++ b/lms/djangoapps/student_account/test/test_views.py @@ -4,23 +4,34 @@ import re from unittest import skipUnless from urllib import urlencode +import json -from mock import patch +import mock import ddt from django.test import TestCase from django.conf import settings from django.core.urlresolvers import reverse from django.core import mail +from django.test.utils import override_settings from util.testing import UrlResetMixin +from third_party_auth.tests.testutil import simulate_running_pipeline from user_api.api import account as account_api from user_api.api import profile as profile_api from util.bad_request_rate_limiter import BadRequestRateLimiter +from xmodule.modulestore.tests.django_utils import ( + ModuleStoreTestCase, mixed_store_config +) +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import CourseModeFactory + + +MODULESTORE_CONFIG = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}, include_xml=False) @ddt.ddt -class StudentAccountViewTest(UrlResetMixin, TestCase): - """ Tests for the student account views. """ +class StudentAccountUpdateTest(UrlResetMixin, TestCase): + """ Tests for the student account views that update the user's account information. """ USERNAME = u"heisenberg" ALTERNATE_USERNAME = u"walt" @@ -50,9 +61,9 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): INVALID_KEY = u"123abc" - @patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True}) + @mock.patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True}) def setUp(self): - super(StudentAccountViewTest, self).setUp() + super(StudentAccountUpdateTest, self).setUp("student_account.urls") # Create/activate a new account activation_key = account_api.create_account(self.USERNAME, self.OLD_PASSWORD, self.OLD_EMAIL) @@ -66,7 +77,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): response = self.client.get(reverse('account_index')) self.assertContains(response, "Student Account") - def test_email_change(self): + def test_change_email(self): response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD) self.assertEquals(response.status_code, 200) @@ -112,7 +123,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): def test_email_change_request_no_user(self): # Patch account API to raise an internal error when an email change is requested - with patch('student_account.views.account_api.request_email_change') as mock_call: + with mock.patch('student_account.views.account_api.request_email_change') as mock_call: mock_call.side_effect = account_api.AccountUserNotFound response = self._change_email(self.NEW_EMAIL, self.OLD_PASSWORD) @@ -183,7 +194,7 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): activation_key = account_api.request_email_change(self.USERNAME, self.NEW_EMAIL, self.OLD_PASSWORD) # Patch account API to return an internal error - with patch('student_account.views.account_api.confirm_email_change') as mock_call: + with mock.patch('student_account.views.account_api.confirm_email_change') as mock_call: mock_call.side_effect = account_api.AccountInternalError response = self.client.get(reverse('email_change_confirm', kwargs={'key': activation_key})) @@ -359,3 +370,201 @@ class StudentAccountViewTest(UrlResetMixin, TestCase): data['email'] = email return self.client.post(path=reverse('password_change_request'), data=data) + + +@ddt.ddt +@override_settings(MODULESTORE=MODULESTORE_CONFIG) +class StudentAccountLoginAndRegistrationTest(ModuleStoreTestCase): + """ Tests for the student account views that update the user's account information. """ + + USERNAME = "bob" + EMAIL = "bob@example.com" + PASSWORD = "password" + + @ddt.data( + ("account_login", "login"), + ("account_register", "register"), + ) + @ddt.unpack + def test_login_and_registration_form(self, url_name, initial_mode): + response = self.client.get(reverse(url_name)) + expected_data = u"data-initial-mode=\"{mode}\"".format(mode=initial_mode) + self.assertContains(response, expected_data) + + @ddt.data("account_login", "account_register") + def test_login_and_registration_form_already_authenticated(self, url_name): + # Create/activate a new account and log in + activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) + account_api.activate_account(activation_key) + result = self.client.login(username=self.USERNAME, password=self.PASSWORD) + self.assertTrue(result) + + # Verify that we're redirected to the dashboard + response = self.client.get(reverse(url_name)) + self.assertRedirects(response, reverse("dashboard")) + + @mock.patch.dict(settings.FEATURES, {"ENABLE_THIRD_PARTY_AUTH": False}) + @ddt.data("account_login", "account_register") + def test_third_party_auth_disabled(self, url_name): + response = self.client.get(reverse(url_name)) + self._assert_third_party_auth_data(response, None, []) + + @ddt.data( + ("account_login", None, None), + ("account_register", None, None), + ("account_login", "google-oauth2", "Google"), + ("account_register", "google-oauth2", "Google"), + ("account_login", "facebook", "Facebook"), + ("account_register", "facebook", "Facebook"), + ) + @ddt.unpack + def test_third_party_auth(self, url_name, current_backend, current_provider): + # Simulate a running pipeline + if current_backend is not None: + pipeline_target = "student_account.views.third_party_auth.pipeline" + with simulate_running_pipeline(pipeline_target, current_backend): + response = self.client.get(reverse(url_name)) + + # Do NOT simulate a running pipeline + else: + response = self.client.get(reverse(url_name)) + + # This relies on the THIRD_PARTY_AUTH configuration in the test settings + expected_providers = [ + { + "name": "Facebook", + "iconClass": "icon-facebook", + "loginUrl": self._third_party_login_url("facebook", "account_login"), + "registerUrl": self._third_party_login_url("facebook", "account_register") + }, + { + "name": "Google", + "iconClass": "icon-google-plus", + "loginUrl": self._third_party_login_url("google-oauth2", "account_login"), + "registerUrl": self._third_party_login_url("google-oauth2", "account_register") + } + ] + self._assert_third_party_auth_data(response, current_provider, expected_providers) + + @ddt.data([], ["honor"], ["honor", "verified", "audit"], ["professional"]) + def test_third_party_auth_course_id_verified(self, modes): + # Create a course with the specified course modes + course = CourseFactory.create() + for slug in modes: + CourseModeFactory.create( + course_id=course.id, + mode_slug=slug, + mode_display_name=slug + ) + + # Verify that the entry URL for third party auth + # contains the course ID and redirects to the track selection page. + course_modes_choose_url = reverse( + "course_modes_choose", + kwargs={"course_id": unicode(course.id)} + ) + expected_providers = [ + { + "name": "Facebook", + "iconClass": "icon-facebook", + "loginUrl": self._third_party_login_url( + "facebook", "account_login", + course_id=unicode(course.id), + redirect_url=course_modes_choose_url + ), + "registerUrl": self._third_party_login_url( + "facebook", "account_register", + course_id=unicode(course.id), + redirect_url=course_modes_choose_url + ) + }, + { + "name": "Google", + "iconClass": "icon-google-plus", + "loginUrl": self._third_party_login_url( + "google-oauth2", "account_login", + course_id=unicode(course.id), + redirect_url=course_modes_choose_url + ), + "registerUrl": self._third_party_login_url( + "google-oauth2", "account_register", + course_id=unicode(course.id), + redirect_url=course_modes_choose_url + ) + } + ] + + # Verify that the login page contains the correct provider URLs + response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)}) + self._assert_third_party_auth_data(response, None, expected_providers) + + def test_third_party_auth_course_id_shopping_cart(self): + # Create a course with a white-label course mode + course = CourseFactory.create() + CourseModeFactory.create( + course_id=course.id, + mode_slug="honor", + mode_display_name="Honor", + min_price=100 + ) + + # Verify that the entry URL for third party auth + # contains the course ID and redirects to the shopping cart + shoppingcart_url = reverse("shoppingcart.views.show_cart") + expected_providers = [ + { + "name": "Facebook", + "iconClass": "icon-facebook", + "loginUrl": self._third_party_login_url( + "facebook", "account_login", + course_id=unicode(course.id), + redirect_url=shoppingcart_url + ), + "registerUrl": self._third_party_login_url( + "facebook", "account_register", + course_id=unicode(course.id), + redirect_url=shoppingcart_url + ) + }, + { + "name": "Google", + "iconClass": "icon-google-plus", + "loginUrl": self._third_party_login_url( + "google-oauth2", "account_login", + course_id=unicode(course.id), + redirect_url=shoppingcart_url + ), + "registerUrl": self._third_party_login_url( + "google-oauth2", "account_register", + course_id=unicode(course.id), + redirect_url=shoppingcart_url + ) + } + ] + + # Verify that the login page contains the correct provider URLs + response = self.client.get(reverse("account_login"), {"course_id": unicode(course.id)}) + self._assert_third_party_auth_data(response, None, expected_providers) + + def _assert_third_party_auth_data(self, response, current_provider, providers): + """Verify that third party auth info is rendered correctly in a DOM data attribute. """ + expected_data = u"data-third-party-auth='{auth_info}'".format( + auth_info=json.dumps({ + "currentProvider": current_provider, + "providers": providers + }) + ) + self.assertContains(response, expected_data) + + def _third_party_login_url(self, backend_name, auth_entry, course_id=None, redirect_url=None): + """Construct the login URL to start third party authentication. """ + params = [("auth_entry", auth_entry)] + if redirect_url: + params.append(("next", redirect_url)) + if course_id: + params.append(("enroll_course_id", course_id)) + + return u"{url}?{params}".format( + url=reverse("social:begin", kwargs={"backend": backend_name}), + params=urlencode(params) + ) diff --git a/lms/djangoapps/student_account/urls.py b/lms/djangoapps/student_account/urls.py index 7ffeae5520..945edbd9cd 100644 --- a/lms/djangoapps/student_account/urls.py +++ b/lms/djangoapps/student_account/urls.py @@ -1,9 +1,21 @@ from django.conf.urls import patterns, url +from django.conf import settings -urlpatterns = patterns( - 'student_account.views', - url(r'^$', 'index', name='account_index'), - url(r'^email$', 'email_change_request_handler', name='email_change_request'), - url(r'^email/confirmation/(?P[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'), - url(r'^password$', 'password_change_request_handler', name='password_change_request'), -) + +urlpatterns = [] + +if settings.FEATURES.get('ENABLE_COMBINED_LOGIN_REGISTRATION'): + urlpatterns += patterns( + 'student_account.views', + url(r'^login/$', 'login_and_registration_form', {'initial_mode': 'login'}, name='account_login'), + url(r'^register/$', 'login_and_registration_form', {'initial_mode': 'register'}, name='account_register'), + url(r'^password$', 'password_change_request_handler', name='password_change_request'), + ) + +if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'): + urlpatterns += patterns( + 'student_account.views', + url(r'^$', 'index', name='account_index'), + url(r'^email$', 'email_change_request_handler', name='email_change_request'), + url(r'^email/confirmation/(?P[^/]*)$', 'email_change_confirmation_handler', name='email_change_confirm'), + ) diff --git a/lms/djangoapps/student_account/views.py b/lms/djangoapps/student_account/views.py index bb6d60cb36..5239f8f27a 100644 --- a/lms/djangoapps/student_account/views.py +++ b/lms/djangoapps/student_account/views.py @@ -1,23 +1,27 @@ """ Views for a student's account information. """ import logging - +import json from django.conf import settings from django.http import ( - HttpResponse, HttpResponseBadRequest, - HttpResponseForbidden + HttpResponse, HttpResponseBadRequest, HttpResponseForbidden ) +from django.shortcuts import redirect +from django.core.urlresolvers import reverse from django.core.mail import send_mail from django_future.csrf import ensure_csrf_cookie from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from edxmako.shortcuts import render_to_response, render_to_string from microsite_configuration import microsite +import third_party_auth from user_api.api import account as account_api from user_api.api import profile as profile_api from util.bad_request_rate_limiter import BadRequestRateLimiter +from student_account.helpers import auth_pipeline_urls + AUDIT_LOG = logging.getLogger("audit") @@ -47,6 +51,34 @@ def index(request): ) +@require_http_methods(['GET']) +@ensure_csrf_cookie +def login_and_registration_form(request, initial_mode="login"): + """Render the combined login/registration form, defaulting to login + + This relies on the JS to asynchronously load the actual form from + the user_api. + + Keyword Args: + initial_mode (string): Either "login" or "registration". + + """ + # If we're already logged in, redirect to the dashboard + if request.user.is_authenticated(): + return redirect(reverse('dashboard')) + + # Otherwise, render the combined login/registration page + context = { + 'disable_courseware_js': True, + 'initial_mode': initial_mode, + 'third_party_auth': json.dumps(_third_party_auth_context(request)), + 'platform_name': settings.PLATFORM_NAME, + 'responsive': True + } + + return render_to_response('student_account/login_and_register.html', context) + + @login_required @require_http_methods(['POST']) @ensure_csrf_cookie @@ -234,3 +266,50 @@ def password_change_request_handler(request): return HttpResponse(status=200) else: return HttpResponseBadRequest("No email address provided.") + + +def _third_party_auth_context(request): + """Context for third party auth providers and the currently running pipeline. + + Arguments: + request (HttpRequest): The request, used to determine if a pipeline + is currently running. + + Returns: + dict + + """ + context = { + "currentProvider": None, + "providers": [] + } + + course_id = request.GET.get("course_id") + login_urls = auth_pipeline_urls( + third_party_auth.pipeline.AUTH_ENTRY_LOGIN_2, + course_id=course_id + ) + register_urls = auth_pipeline_urls( + third_party_auth.pipeline.AUTH_ENTRY_REGISTER_2, + course_id=course_id + ) + + if third_party_auth.is_enabled(): + context["providers"] = [ + { + "name": enabled.NAME, + "iconClass": enabled.ICON_CLASS, + "loginUrl": login_urls[enabled.NAME], + "registerUrl": register_urls[enabled.NAME] + } + for enabled in third_party_auth.provider.Registry.enabled() + ] + + running_pipeline = third_party_auth.pipeline.get(request) + if running_pipeline is not None: + current_provider = third_party_auth.provider.Registry.get_by_backend_name( + running_pipeline.get('backend') + ) + context["currentProvider"] = current_provider.NAME + + return context diff --git a/lms/djangoapps/student_profile/test/test_views.py b/lms/djangoapps/student_profile/test/test_views.py index db30146986..c24bd0ea0a 100644 --- a/lms/djangoapps/student_profile/test/test_views.py +++ b/lms/djangoapps/student_profile/test/test_views.py @@ -35,7 +35,7 @@ class StudentProfileViewTest(UrlResetMixin, TestCase): @patch.dict(settings.FEATURES, {'ENABLE_NEW_DASHBOARD': True}) def setUp(self): - super(StudentProfileViewTest, self).setUp() + super(StudentProfileViewTest, self).setUp("student_profile.urls") # Create/activate a new account activation_key = account_api.create_account(self.USERNAME, self.PASSWORD, self.EMAIL) diff --git a/lms/djangoapps/student_profile/urls.py b/lms/djangoapps/student_profile/urls.py index ba4cebeba1..48d20711d9 100644 --- a/lms/djangoapps/student_profile/urls.py +++ b/lms/djangoapps/student_profile/urls.py @@ -1,8 +1,14 @@ from django.conf.urls import patterns, url +from django.conf import settings -urlpatterns = patterns( - 'student_profile.views', - url(r'^$', 'index', name='profile_index'), - url(r'^preferences$', 'preference_handler', name='preference_handler'), - url(r'^preferences/languages$', 'language_info', name='language_info'), -) + +urlpatterns = [] + + +if settings.FEATURES.get('ENABLE_NEW_DASHBOARD'): + urlpatterns = patterns( + 'student_profile.views', + url(r'^$', 'index', name='profile_index'), + url(r'^preferences$', 'preference_handler', name='preference_handler'), + url(r'^preferences/languages$', 'language_info', name='language_info'), + ) diff --git a/lms/djangoapps/student_profile/views.py b/lms/djangoapps/student_profile/views.py index 4ec52f3f40..a7a6a3c0d0 100644 --- a/lms/djangoapps/student_profile/views.py +++ b/lms/djangoapps/student_profile/views.py @@ -13,7 +13,7 @@ from django.contrib.auth.decorators import login_required from edxmako.shortcuts import render_to_response from user_api.api import profile as profile_api from lang_pref import LANGUAGE_KEY, api as language_api -from third_party_auth import pipeline +import third_party_auth @login_required @@ -60,8 +60,8 @@ def _get_profile(request): 'disable_courseware_js': True } - if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): - context['provider_user_states'] = pipeline.get_provider_user_states(user) + if third_party_auth.is_enabled(): + context['provider_user_states'] = third_party_auth.pipeline.get_provider_user_states(user) return render_to_response('student_profile/index.html', context) diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index 73151aee17..245c9f8de0 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -204,7 +204,6 @@ class TestVerifyView(ModuleStoreTestCase): url = reverse('verify_student_verify', kwargs={"course_id": unicode(self.course_key)}) response = self.client.get(url) - self.assertIn("You are now enrolled in", response.content) def test_valid_course_upgrade_text(self): diff --git a/lms/envs/bok_choy.env.json b/lms/envs/bok_choy.env.json index fe5d509386..41455f9280 100644 --- a/lms/envs/bok_choy.env.json +++ b/lms/envs/bok_choy.env.json @@ -69,6 +69,7 @@ "ENABLE_INSTRUCTOR_ANALYTICS": true, "ENABLE_S3_GRADE_DOWNLOADS": true, "ENABLE_THIRD_PARTY_AUTH": true, + "ENABLE_COMBINED_LOGIN_REGISTRATION": true, "PREVIEW_LMS_BASE": "localhost:8003", "SUBDOMAIN_BRANDING": false, "SUBDOMAIN_COURSE_LISTINGS": false @@ -82,6 +83,17 @@ "MEDIA_URL": "", "MKTG_URL_LINK_MAP": {}, "PLATFORM_NAME": "edX", + "REGISTRATION_EXTRA_FIELDS": { + "level_of_education": "optional", + "gender": "optional", + "year_of_birth": "optional", + "mailing_address": "optional", + "goals": "optional", + "honor_code": "required", + "terms_of_service": "hidden", + "city": "hidden", + "country": "required" + }, "SEGMENT_IO_LMS": true, "SERVER_EMAIL": "devops@example.com", "SESSION_COOKIE_DOMAIN": null, diff --git a/lms/envs/common.py b/lms/envs/common.py index edfe6454bc..ec73c49a11 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -280,6 +280,9 @@ FEATURES = { # Enable the new dashboard, account, and profile pages 'ENABLE_NEW_DASHBOARD': False, + # Enable the combined login/registration form + 'ENABLE_COMBINED_LOGIN_REGISTRATION': False, + # Show a section in the membership tab of the instructor dashboard # to allow an upload of a CSV file that contains a list of new accounts to create # and register for course. @@ -1037,7 +1040,23 @@ instructor_dash_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/ins # JavaScript used by the student account and profile pages # These are not courseware, so they do not need many of the courseware-specific # JavaScript modules. -student_account_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_account/**/*.js')) +student_account_js = [ + 'js/utils/rwd_header_footer.js', + 'js/utils/edx.utils.validate.js', + 'js/src/utility.js', + 'js/student_account/enrollment.js', + 'js/student_account/shoppingcart.js', + 'js/student_account/models/LoginModel.js', + 'js/student_account/models/RegisterModel.js', + 'js/student_account/models/PasswordResetModel.js', + 'js/student_account/views/FormView.js', + 'js/student_account/views/LoginView.js', + 'js/student_account/views/RegisterView.js', + 'js/student_account/views/PasswordResetView.js', + 'js/student_account/views/AccessView.js', + 'js/student_account/accessApp.js', +] + student_profile_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'js/student_profile/**/*.js')) PIPELINE_CSS = { @@ -1555,6 +1574,7 @@ REGISTRATION_EXTRA_FIELDS = { 'mailing_address': 'optional', 'goals': 'optional', 'honor_code': 'required', + 'terms_of_service': 'hidden', 'city': 'hidden', 'country': 'hidden', } diff --git a/lms/envs/test.py b/lms/envs/test.py index 2810337e63..8682477044 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -53,6 +53,8 @@ FEATURES['ALLOW_COURSE_STAFF_GRADE_DOWNLOADS'] = True # Toggles embargo on for testing FEATURES['EMBARGO'] = True +FEATURES['ENABLE_COMBINED_LOGIN_REGISTRATION'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True diff --git a/lms/lib/xblock/runtime.py b/lms/lib/xblock/runtime.py index f02657c511..2c1729ec49 100644 --- a/lms/lib/xblock/runtime.py +++ b/lms/lib/xblock/runtime.py @@ -7,7 +7,7 @@ import xblock.reference.plugins from django.core.urlresolvers import reverse from django.conf import settings -from user_api import user_service +from user_api.api import course_tag as user_course_tag_api from xmodule.modulestore.django import modulestore from xmodule.x_module import ModuleSystem from xmodule.partitions.partitions_service import PartitionService @@ -144,7 +144,7 @@ class UserTagsService(object): the current course id and current user. """ - COURSE_SCOPE = user_service.COURSE_SCOPE + COURSE_SCOPE = user_course_tag_api.COURSE_SCOPE def __init__(self, runtime): self.runtime = runtime @@ -161,11 +161,13 @@ class UserTagsService(object): scope: the current scope of the runtime key: the key for the value we want """ - if scope != user_service.COURSE_SCOPE: + if scope != user_course_tag_api.COURSE_SCOPE: raise ValueError("unexpected scope {0}".format(scope)) - return user_service.get_course_tag(self._get_current_user(), - self.runtime.course_id, key) + return user_course_tag_api.get_course_tag( + self._get_current_user(), + self.runtime.course_id, key + ) def set_tag(self, scope, key, value): """ @@ -175,11 +177,13 @@ class UserTagsService(object): key: the key that to the value to be set value: the value to set """ - if scope != user_service.COURSE_SCOPE: + if scope != user_course_tag_api.COURSE_SCOPE: raise ValueError("unexpected scope {0}".format(scope)) - return user_service.set_course_tag(self._get_current_user(), - self.runtime.course_id, key, value) + return user_course_tag_api.set_course_tag( + self._get_current_user(), + self.runtime.course_id, key, value + ) class LmsModuleSystem(LmsHandlerUrls, ModuleSystem): # pylint: disable=abstract-method diff --git a/lms/static/js/spec/main.js b/lms/static/js/spec/main.js index 30ce47a9e9..f081eb38e1 100644 --- a/lms/static/js/spec/main.js +++ b/lms/static/js/spec/main.js @@ -23,6 +23,7 @@ 'jquery.inputnumber': 'xmodule_js/common_static/js/vendor/html5-input-polyfills/number-polyfill', 'jquery.immediateDescendents': 'xmodule_js/common_static/coffee/src/jquery.immediateDescendents', 'jquery.simulate': 'xmodule_js/common_static/js/vendor/jquery.simulate', + 'jquery.url': 'xmodule_js/common_static/js/vendor/url.min', 'datepair': 'xmodule_js/common_static/js/vendor/timepicker/datepair', 'date': 'xmodule_js/common_static/js/vendor/date', 'underscore': 'xmodule_js/common_static/js/vendor/underscore-min', @@ -43,7 +44,6 @@ 'jasmine.async': 'xmodule_js/common_static/js/vendor/jasmine.async', 'draggabilly': 'xmodule_js/common_static/js/vendor/draggabilly.pkgd', 'domReady': 'xmodule_js/common_static/js/vendor/domReady', - 'URI': 'xmodule_js/common_static/js/vendor/URI.min', 'mathjax': '//edx-static.s3.amazonaws.com/mathjax-MathJax-727332c/MathJax.js?config=TeX-MML-AM_HTMLorMML-full&delayStartupUntil=configured', 'youtube': '//www.youtube.com/player_api?noext', 'tender': '//edxedge.tenderapp.com/tender_widget', @@ -65,7 +65,17 @@ 'js/views/cohort_editor': 'js/views/cohort_editor', 'js/views/cohorts': 'js/views/cohorts', 'js/views/notification': 'js/views/notification', - 'js/models/notification': 'js/models/notification' + 'js/models/notification': 'js/models/notification', + 'js/student_account/account': 'js/student_account/account', + 'js/student_account/views/FormView': 'js/student_account/views/FormView', + 'js/student_account/models/LoginModel': 'js/student_account/models/LoginModel', + 'js/student_account/views/LoginView': 'js/student_account/views/LoginView', + 'js/student_account/models/PasswordResetModel': 'js/student_account/models/PasswordResetModel', + 'js/student_account/views/PasswordResetView': 'js/student_account/views/PasswordResetView', + 'js/student_account/models/RegisterModel': 'js/student_account/models/RegisterModel', + 'js/student_account/views/RegisterView': 'js/student_account/views/RegisterView', + 'js/student_account/views/AccessView': 'js/student_account/views/AccessView', + 'js/student_profile/profile': 'js/student_profile/profile' }, shim: { 'gettext': { @@ -133,11 +143,31 @@ deps: ['jquery', 'tinymce'], exports: 'jQuery.fn.tinymce' }, + 'jquery.url': { + deps: ['jquery'], + exports: 'jQuery.fn.url' + }, 'datepair': { deps: ['jquery.ui', 'jquery.timepicker'] }, 'underscore': { - exports: '_' + deps: ['underscore.string'], + exports: '_', + init: function(UnderscoreString) { + /* Mix non-conflicting functions from underscore.string + * (all but include, contains, and reverse) into the + * Underscore namespace. This allows the login, register, + * and password reset templates to render independent of the + * access view. + */ + _.mixin(UnderscoreString.exports()); + + /* Since the access view is not using RequireJS, we also + * expose underscore.string at _.str, so that the access + * view can perform the mixin on its own. + */ + _.str = UnderscoreString; + } }, 'backbone': { deps: ['underscore', 'jquery'], @@ -231,6 +261,7 @@ exports: 'js/dashboard/donation', deps: ['jquery', 'underscore', 'gettext'] }, + // Backbone classes loaded explicitly until they are converted to use RequireJS 'js/models/cohort': { exports: 'CohortModel', @@ -257,8 +288,85 @@ 'js/views/notification': { exports: 'NotificationView', deps: ['backbone', 'jquery', 'underscore'] + }, + 'js/student_account/enrollment': { + exports: 'edx.student.account.EnrollmentInterface', + deps: ['jquery', 'jquery.cookie'] + }, + 'js/student_account/shoppingcart': { + exports: 'edx.student.account.ShoppingCartInterface', + deps: ['jquery', 'jquery.cookie', 'underscore'] + }, + // Student account registration/login + // Loaded explicitly until these are converted to RequireJS + 'js/student_account/views/FormView': { + exports: 'edx.student.account.FormView', + deps: ['jquery', 'underscore', 'backbone', 'gettext'] + }, + 'js/student_account/models/LoginModel': { + exports: 'edx.student.account.LoginModel', + deps: ['jquery', 'jquery.cookie', 'backbone'] + }, + 'js/student_account/views/LoginView': { + exports: 'edx.student.account.LoginView', + deps: [ + 'jquery', + 'jquery.url', + 'underscore', + 'gettext', + 'js/student_account/models/LoginModel', + 'js/student_account/views/FormView' + ] + }, + 'js/student_account/models/PasswordResetModel': { + exports: 'edx.student.account.PasswordResetModel', + deps: ['jquery', 'jquery.cookie', 'backbone'] + }, + 'js/student_account/views/PasswordResetView': { + exports: 'edx.student.account.PasswordResetView', + deps: [ + 'jquery', + 'underscore', + 'gettext', + 'js/student_account/models/PasswordResetModel', + 'js/student_account/views/FormView' + ] + }, + 'js/student_account/models/RegisterModel': { + exports: 'edx.student.account.RegisterModel', + deps: ['jquery', 'jquery.cookie', 'backbone'] + }, + 'js/student_account/views/RegisterView': { + exports: 'edx.student.account.RegisterView', + deps: [ + 'jquery', + 'jquery.url', + 'underscore', + 'gettext', + 'js/student_account/models/RegisterModel', + 'js/student_account/views/FormView' + ] + }, + 'js/student_account/views/AccessView': { + exports: 'edx.student.account.AccessView', + deps: [ + 'jquery', + 'underscore', + 'backbone', + 'gettext', + 'utility', + 'js/student_account/views/LoginView', + 'js/student_account/views/PasswordResetView', + 'js/student_account/views/RegisterView', + 'js/student_account/models/LoginModel', + 'js/student_account/models/PasswordResetModel', + 'js/student_account/models/RegisterModel', + 'js/student_account/views/FormView', + 'js/student_account/enrollment', + 'js/student_account/shoppingcart', + ] } - }, + } }); // TODO: why do these need 'lms/include' at the front but the CMS equivalent logic doesn't? @@ -269,8 +377,14 @@ 'lms/include/js/spec/staff_debug_actions_spec.js', 'lms/include/js/spec/views/notification_spec.js', 'lms/include/js/spec/dashboard/donation.js', - 'lms/include/js/spec/student_account/account.js', - 'lms/include/js/spec/student_profile/profile.js' + 'lms/include/js/spec/student_account/account_spec.js', + 'lms/include/js/spec/student_account/access_spec.js', + 'lms/include/js/spec/student_account/login_spec.js', + 'lms/include/js/spec/student_account/register_spec.js', + 'lms/include/js/spec/student_account/password_reset_spec.js', + 'lms/include/js/spec/student_account/enrollment_spec.js', + 'lms/include/js/spec/student_account/shoppingcart_spec.js', + 'lms/include/js/spec/student_profile/profile_spec.js' ]); }).call(this, requirejs, define); diff --git a/lms/static/js/spec/student_account/access_spec.js b/lms/static/js/spec/student_account/access_spec.js new file mode 100644 index 0000000000..896ef75cd8 --- /dev/null +++ b/lms/static/js/spec/student_account/access_spec.js @@ -0,0 +1,276 @@ +define([ + 'jquery', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/student_account/views/AccessView', + 'js/student_account/views/FormView', + 'js/student_account/enrollment', + 'js/student_account/shoppingcart' +], function($, TemplateHelpers, AjaxHelpers, AccessView, FormView, EnrollmentInterface, ShoppingCartInterface) { + describe('edx.student.account.AccessView', function() { + 'use strict'; + + var requests = null, + view = null, + AJAX_INFO = { + register: { + url: '/user_api/v1/account/registration/', + requestIndex: 1 + }, + login: { + url: '/user_api/v1/account/login_session/', + requestIndex: 0 + }, + password_reset: { + url: '/user_api/v1/account/password_reset/', + requestIndex: 1 + } + }, + FORM_DESCRIPTION = { + method: 'post', + submit_url: '/submit', + fields: [ + { + name: 'email', + label: 'Email', + defaultValue: '', + type: 'text', + required: true, + placeholder: 'xsy@edx.org', + instructions: 'Enter your email here.', + restrictions: {}, + }, + { + name: 'username', + label: 'Username', + defaultValue: '', + type: 'text', + required: true, + placeholder: 'Xsy', + instructions: 'Enter your username here.', + restrictions: { + max_length: 200 + } + } + ] + }, + FORWARD_URL = '/courseware/next', + COURSE_KEY = 'edx/DemoX/Fall'; + + var ajaxAssertAndRespond = function(url, requestIndex) { + // Verify that the client contacts the server as expected + AjaxHelpers.expectJsonRequest(requests, 'GET', url, null, requestIndex); + + /* Simulate a response from the server containing + /* a dummy form description + */ + AjaxHelpers.respondWithJson(requests, FORM_DESCRIPTION); + }; + + var ajaxSpyAndInitialize = function(that, mode) { + // Spy on AJAX requests + requests = AjaxHelpers.requests(that); + + // Initialize the access view + view = new AccessView({ + mode: mode, + thirdPartyAuth: { + currentProvider: null, + providers: [] + }, + platformName: 'edX' + }); + + // Mock the redirect call + spyOn( view, 'redirect' ).andCallFake( function() {} ); + + // Mock the enrollment and shopping cart interfaces + spyOn( EnrollmentInterface, 'enroll' ).andCallFake( function() {} ); + spyOn( ShoppingCartInterface, 'addCourseToCart' ).andCallFake( function() {} ); + + // Initialize the subview + ajaxAssertAndRespond(AJAX_INFO[mode].url); + }; + + var assertForms = function(visibleType, hiddenType) { + expect($(visibleType)).not.toHaveClass('hidden'); + expect($(hiddenType)).toHaveClass('hidden'); + expect($('#password-reset-wrapper')).toBeEmpty(); + }; + + var selectForm = function(type) { + // Create a fake change event to control form toggling + var changeEvent = $.Event('change'); + changeEvent.currentTarget = $('#' + type + '-option'); + + // Load form corresponding to the change event + view.toggleForm(changeEvent); + + ajaxAssertAndRespond(AJAX_INFO[type].url, AJAX_INFO[type].requestIndex); + }; + + /** + * Simulate query string params. + * + * @param {object} params Parameters to set, each of which + * should be prefixed with '?' + */ + var setFakeQueryParams = function( params ) { + spyOn( $, 'url' ).andCallFake(function( requestedParam ) { + if ( params.hasOwnProperty(requestedParam) ) { + return params[requestedParam]; + } + }); + }; + + beforeEach(function() { + setFixtures('
    '); + TemplateHelpers.installTemplate('templates/student_account/access'); + TemplateHelpers.installTemplate('templates/student_account/login'); + TemplateHelpers.installTemplate('templates/student_account/register'); + TemplateHelpers.installTemplate('templates/student_account/password_reset'); + TemplateHelpers.installTemplate('templates/student_account/form_field'); + + // Stub analytics tracking + // TODO: use RequireJS to ensure that this is loaded correctly + window.analytics = window.analytics || {}; + window.analytics.track = window.analytics.track || function() {}; + }); + + it('can initially display the login form', function() { + ajaxSpyAndInitialize(this, 'login'); + + /* Verify that the login form is expanded, and that the + /* registration form is collapsed. + */ + assertForms('#login-form', '#register-form'); + }); + + it('can initially display the registration form', function() { + ajaxSpyAndInitialize(this, 'register'); + + /* Verify that the registration form is expanded, and that the + /* login form is collapsed. + */ + assertForms('#register-form', '#login-form'); + }); + + it('toggles between the login and registration forms', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate selection of the registration form + selectForm('register'); + assertForms('#register-form', '#login-form'); + + // Simulate selection of the login form + selectForm('login'); + assertForms('#login-form', '#register-form'); + }); + + it('displays the reset password form', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate a click on the reset password link + view.resetPassword(); + + ajaxAssertAndRespond( + AJAX_INFO.password_reset.url, + AJAX_INFO.password_reset.requestIndex + ); + + // Verify that the password reset wrapper is populated + expect($('#password-reset-wrapper')).not.toBeEmpty(); + }); + + it('enrolls the user on auth complete', function() { + ajaxSpyAndInitialize(this, 'login'); + + // Simulate providing enrollment query string params + setFakeQueryParams({ + '?enrollment_action': 'enroll', + '?course_id': COURSE_KEY + }); + + // Trigger auth complete on the login view + view.subview.login.trigger('auth-complete'); + + // Expect that the view tried to enroll the student + expect( EnrollmentInterface.enroll ).toHaveBeenCalledWith( COURSE_KEY ); + }); + + it('adds a white-label course to the shopping cart on auth complete', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate providing "add to cart" query string params + setFakeQueryParams({ + '?enrollment_action': 'add_to_cart', + '?course_id': COURSE_KEY + }); + + // Trigger auth complete on the register view + view.subview.register.trigger('auth-complete'); + + // Expect that the view tried to add the course to the user's shopping cart + expect( ShoppingCartInterface.addCourseToCart ).toHaveBeenCalledWith( COURSE_KEY ); + }); + + it('redirects the user to the dashboard on auth complete', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Trigger auth complete + view.subview.register.trigger('auth-complete'); + + // Since we did not provide a ?next query param, expect a redirect to the dashboard. + expect( view.redirect ).toHaveBeenCalledWith( '/dashboard' ); + }); + + it('redirects the user to the next page on auth complete', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate providing a ?next query string parameter + setFakeQueryParams({ '?next': FORWARD_URL }); + + // Trigger auth complete + view.subview.register.trigger('auth-complete'); + + // Verify that we were redirected + expect( view.redirect ).toHaveBeenCalledWith( FORWARD_URL ); + }); + + it('ignores redirect to external URLs', function() { + ajaxSpyAndInitialize(this, 'register'); + + // Simulate providing a ?next query string parameter + // that goes to an external URL + setFakeQueryParams({ '?next': "http://www.example.com" }); + + // Trigger auth complete + view.subview.register.trigger('auth-complete'); + + // Expect that we ignore the external URL and redirect to the dashboard + expect( view.redirect ).toHaveBeenCalledWith( "/dashboard" ); + }); + + it('displays an error if a form definition could not be loaded', function() { + // Spy on AJAX requests + requests = AjaxHelpers.requests(this); + + // Init AccessView + view = new AccessView({ + mode: 'login', + thirdPartyAuth: { + currentProvider: null, + providers: [] + }, + platformName: 'edX' + }); + + // Simulate an error from the LMS servers + AjaxHelpers.respondWithError(requests); + + // Error message should be displayed + expect( $('#form-load-fail').hasClass('hidden') ).toBe(false); + }); + }); + } +); diff --git a/lms/static/js/spec/student_account/account.js b/lms/static/js/spec/student_account/account_spec.js similarity index 100% rename from lms/static/js/spec/student_account/account.js rename to lms/static/js/spec/student_account/account_spec.js diff --git a/lms/static/js/spec/student_account/enrollment_spec.js b/lms/static/js/spec/student_account/enrollment_spec.js new file mode 100644 index 0000000000..05119f1414 --- /dev/null +++ b/lms/static/js/spec/student_account/enrollment_spec.js @@ -0,0 +1,49 @@ +define(['js/common_helpers/ajax_helpers', 'js/student_account/enrollment'], + function( AjaxHelpers, EnrollmentInterface ) { + 'use strict'; + + describe( 'edx.student.account.EnrollmentInterface', function() { + + var COURSE_KEY = 'edX/DemoX/Fall', + ENROLL_URL = '/enrollment/v0/course/edX/DemoX/Fall', + FORWARD_URL = '/course_modes/choose/edX/DemoX/Fall/'; + + beforeEach(function() { + // Mock the redirect call + spyOn(EnrollmentInterface, 'redirect').andCallFake(function() {}); + }); + + it('enrolls a user in a course', function() { + // Spy on Ajax requests + var requests = AjaxHelpers.requests( this ); + + // Attempt to enroll the user + EnrollmentInterface.enroll( COURSE_KEY ); + + // Expect that the correct request was made to the server + AjaxHelpers.expectRequest( requests, 'POST', ENROLL_URL ); + + // Simulate a successful response from the server + AjaxHelpers.respondWithJson(requests, {}); + + // Verify that the user was redirected correctly + expect( EnrollmentInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL ); + }); + + it('redirects the user if enrollment fails', function() { + // Spy on Ajax requests + var requests = AjaxHelpers.requests( this ); + + // Attempt to enroll the user + EnrollmentInterface.enroll( COURSE_KEY ); + + // Simulate an error response from the server + AjaxHelpers.respondWithError(requests); + + // Verify that the user was still redirected + expect(EnrollmentInterface.redirect).toHaveBeenCalledWith( FORWARD_URL ); + }); + + }); + } +); diff --git a/lms/static/js/spec/student_account/login_spec.js b/lms/static/js/spec/student_account/login_spec.js new file mode 100644 index 0000000000..428e09d340 --- /dev/null +++ b/lms/static/js/spec/student_account/login_spec.js @@ -0,0 +1,252 @@ +define([ + 'jquery', + 'underscore', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/student_account/models/LoginModel', + 'js/student_account/views/LoginView' +], function($, _, TemplateHelpers, AjaxHelpers, LoginModel, LoginView) { + 'use strict'; + describe('edx.student.account.LoginView', function() { + + var model = null, + view = null, + requests = null, + authComplete = false, + PLATFORM_NAME = 'edX', + USER_DATA = { + email: 'xsy@edx.org', + password: 'xsyisawesome', + remember: true + }, + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [ + { + name: 'Google', + iconClass: 'icon-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + name: 'Facebook', + iconClass: 'icon-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }, + FORM_DESCRIPTION = { + method: 'post', + submit_url: '/user_api/v1/account/login_session/', + fields: [ + { + name: 'email', + label: 'Email', + defaultValue: '', + type: 'email', + required: true, + placeholder: 'place@holder.org', + instructions: 'Enter your email.', + restrictions: {} + }, + { + name: 'password', + label: 'Password', + defaultValue: '', + type: 'password', + required: true, + instructions: 'Enter your password.', + restrictions: {} + }, + { + name: 'remember', + label: 'Remember me', + defaultValue: '', + type: 'checkbox', + required: true, + instructions: "Agree to the terms of service.", + restrictions: {} + } + ] + }, + COURSE_ID = "edX/demoX/Fall"; + + var createLoginView = function(test) { + // Initialize the login model + model = new LoginModel({}, { + url: FORM_DESCRIPTION.submit_url, + method: FORM_DESCRIPTION.method + }); + + // Initialize the login view + view = new LoginView({ + fields: FORM_DESCRIPTION.fields, + model: model, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME + }); + + // Spy on AJAX requests + requests = AjaxHelpers.requests(test); + + // Intercept events from the view + authComplete = false; + view.on("auth-complete", function() { + authComplete = true; + }); + }; + + var submitForm = function(validationSuccess) { + // Simulate manual entry of login form data + $('#login-email').val(USER_DATA.email); + $('#login-password').val(USER_DATA.password); + + // Check the "Remember me" checkbox + $('#login-remember').prop('checked', USER_DATA.remember); + + // Create a fake click event + var clickEvent = $.Event('click'); + + // If validationSuccess isn't passed, we avoid + // spying on `view.validate` twice + if ( !_.isUndefined(validationSuccess) ) { + // Force validation to return as expected + spyOn(view, 'validate').andReturn({ + isValid: validationSuccess, + message: 'Submission was validated.' + }); + } + + // Submit the email address + view.submitForm(clickEvent); + }; + + beforeEach(function() { + setFixtures('
    '); + TemplateHelpers.installTemplate('templates/student_account/login'); + TemplateHelpers.installTemplate('templates/student_account/form_field'); + }); + + it('logs the user in', function() { + createLoginView(this); + + // Submit the form, with successful validation + submitForm(true); + + // Form button should be disabled on success. + expect(view.$submitButton).toHaveAttr('disabled'); + + // Verify that the client contacts the server with the expected data + AjaxHelpers.expectRequest( + requests, 'POST', + FORM_DESCRIPTION.submit_url, + $.param( USER_DATA ) + ); + + // Respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Verify that auth-complete is triggered + expect(authComplete).toBe(true); + }); + + it('sends analytics info containing the enrolled course ID', function() { + createLoginView( this ); + + // Simulate that the user is attempting to enroll in a course + // by setting the course_id query string param. + spyOn($, 'url').andCallFake(function( param ) { + if (param === "?course_id") { + return encodeURIComponent( COURSE_ID ); + } + }); + + // Attempt to login + submitForm( true ); + + // Verify that the client sent the course ID for analytics + var expectedData = {}; + $.extend(expectedData, USER_DATA, { + analytics: JSON.stringify({ + enroll_course_id: COURSE_ID + }) + }); + + AjaxHelpers.expectRequest( + requests, 'POST', + FORM_DESCRIPTION.submit_url, + $.param( expectedData ) + ); + }); + + it('displays third-party auth login buttons', function() { + createLoginView(this); + + // Verify that Google and Facebook registration buttons are displayed + expect($('.button-Google')).toBeVisible(); + expect($('.button-Facebook')).toBeVisible(); + }); + + it('displays a link to the password reset form', function() { + createLoginView(this); + + // Verify that the password reset link is displayed + expect($('.forgot-password')).toBeVisible(); + }); + + it('validates login form fields', function() { + createLoginView(this); + + submitForm(true); + + // Verify that validation of form fields occurred + expect(view.validate).toHaveBeenCalledWith($('#login-email')[0]); + expect(view.validate).toHaveBeenCalledWith($('#login-password')[0]); + }); + + it('displays login form validation errors', function() { + createLoginView(this); + + // Submit the form, with failed validation + submitForm(false); + + // Verify that submission errors are visible + expect(view.$errors).not.toHaveClass('hidden'); + + // Expect auth complete NOT to have been triggered + expect(authComplete).toBe(false); + // Form button should be re-enabled when errors occur + expect(view.$submitButton).not.toHaveAttr('disabled'); + }); + + it('displays an error if the server returns an error while logging in', function() { + createLoginView(this); + + // Submit the form, with successful validation + submitForm(true); + + // Simulate an error from the LMS servers + AjaxHelpers.respondWithError(requests); + + // Expect that an error is displayed and that auth complete is not triggered + expect(view.$errors).not.toHaveClass('hidden'); + expect(authComplete).toBe(false); + // Form button should be re-enabled on server failure. + expect(view.$submitButton).not.toHaveAttr('disabled'); + + // If we try again and succeed, the error should go away + submitForm(); + + // Form button should be disabled on success. + expect(view.$submitButton).toHaveAttr('disabled'); + + // This time, respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Expect that the error is hidden and auth complete is triggered + expect(view.$errors).toHaveClass('hidden'); + expect(authComplete).toBe(true); + }); + }); +}); diff --git a/lms/static/js/spec/student_account/password_reset_spec.js b/lms/static/js/spec/student_account/password_reset_spec.js new file mode 100644 index 0000000000..37a6ab4b67 --- /dev/null +++ b/lms/static/js/spec/student_account/password_reset_spec.js @@ -0,0 +1,139 @@ +define([ + 'jquery', + 'underscore', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/student_account/models/PasswordResetModel', + 'js/student_account/views/PasswordResetView', +], function($, _, TemplateHelpers, AjaxHelpers, PasswordResetModel, PasswordResetView) { + describe('edx.student.account.PasswordResetView', function() { + 'use strict'; + + var model = null, + view = null, + requests = null, + EMAIL = 'xsy@edx.org', + FORM_DESCRIPTION = { + method: 'post', + submit_url: '/account/password', + fields: [{ + name: 'email', + label: 'Email', + defaultValue: '', + type: 'text', + required: true, + placeholder: 'place@holder.org', + instructions: 'Enter your email.', + restrictions: {} + }] + }; + + var createPasswordResetView = function(that) { + // Initialize the password reset model + model = new PasswordResetModel({}, { + url: FORM_DESCRIPTION.submit_url, + method: FORM_DESCRIPTION.method + }); + + // Initialize the password reset view + view = new PasswordResetView({ + fields: FORM_DESCRIPTION.fields, + model: model + }); + + // Spy on AJAX requests + requests = AjaxHelpers.requests(that); + }; + + var submitEmail = function(validationSuccess) { + // Simulate manual entry of an email address + $('#password-reset-email').val(EMAIL); + + // Create a fake click event + var clickEvent = $.Event('click'); + + // If validationSuccess isn't passed, we avoid + // spying on `view.validate` twice + if ( !_.isUndefined(validationSuccess) ) { + // Force validation to return as expected + spyOn(view, 'validate').andReturn({ + isValid: validationSuccess, + message: 'Submission was validated.' + }); + } + + // Submit the email address + view.submitForm(clickEvent); + }; + + beforeEach(function() { + setFixtures('
    '); + TemplateHelpers.installTemplate('templates/student_account/password_reset'); + TemplateHelpers.installTemplate('templates/student_account/form_field'); + }); + + it('allows the user to request a new password', function() { + createPasswordResetView(this); + + // Submit the form, with successful validation + submitEmail(true); + + // Verify that the client contacts the server with the expected data + AjaxHelpers.expectRequest( + requests, 'POST', + FORM_DESCRIPTION.submit_url, + $.param({ email: EMAIL }) + ); + + // Respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Verify that the success message is visible + expect($('.js-reset-success')).not.toHaveClass('hidden'); + }); + + it('validates the email field', function() { + createPasswordResetView(this); + + // Submit the form, with successful validation + submitEmail(true); + + // Verify that validation of the email field occurred + expect(view.validate).toHaveBeenCalledWith($('#password-reset-email')[0]); + + // Verify that no submission errors are visible + expect(view.$errors).toHaveClass('hidden'); + }); + + it('displays password reset validation errors', function() { + createPasswordResetView(this); + + // Submit the form, with failed validation + submitEmail(false); + + // Verify that submission errors are visible + expect(view.$errors).not.toHaveClass('hidden'); + }); + + it('displays an error if the server returns an error while sending a password reset email', function() { + createPasswordResetView(this); + submitEmail(true); + + // Simulate an error from the LMS servers + AjaxHelpers.respondWithError(requests); + + // Expect that an error is displayed + expect(view.$errors).not.toHaveClass('hidden'); + + // If we try again and succeed, the error should go away + submitEmail(); + + // This time, respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Expect that the error is hidden + expect(view.$errors).toHaveClass('hidden'); + }); + }); + } +); diff --git a/lms/static/js/spec/student_account/register_spec.js b/lms/static/js/spec/student_account/register_spec.js new file mode 100644 index 0000000000..cd6d59ea7b --- /dev/null +++ b/lms/static/js/spec/student_account/register_spec.js @@ -0,0 +1,345 @@ +define([ + 'jquery', + 'underscore', + 'js/common_helpers/template_helpers', + 'js/common_helpers/ajax_helpers', + 'js/student_account/models/RegisterModel', + 'js/student_account/views/RegisterView' +], function($, _, TemplateHelpers, AjaxHelpers, RegisterModel, RegisterView) { + 'use strict'; + + describe('edx.student.account.RegisterView', function() { + + var model = null, + view = null, + requests = null, + authComplete = false, + PLATFORM_NAME = 'edX', + COURSE_ID = "edX/DemoX/Fall", + USER_DATA = { + email: 'xsy@edx.org', + name: 'Xsy M. Education', + username: 'Xsy', + password: 'xsyisawesome', + level_of_education: 'p', + gender: 'm', + year_of_birth: 2014, + mailing_address: '141 Portland', + goals: 'To boldly learn what no letter of the alphabet has learned before', + honor_code: true + }, + THIRD_PARTY_AUTH = { + currentProvider: null, + providers: [ + { + name: 'Google', + iconClass: 'icon-google-plus', + loginUrl: '/auth/login/google-oauth2/?auth_entry=account_login', + registerUrl: '/auth/login/google-oauth2/?auth_entry=account_register' + }, + { + name: 'Facebook', + iconClass: 'icon-facebook', + loginUrl: '/auth/login/facebook/?auth_entry=account_login', + registerUrl: '/auth/login/facebook/?auth_entry=account_register' + } + ] + }, + FORM_DESCRIPTION = { + method: 'post', + submit_url: '/user_api/v1/account/registration/', + fields: [ + { + name: 'email', + label: 'Email', + defaultValue: '', + type: 'email', + required: true, + placeholder: 'place@holder.org', + instructions: 'Enter your email.', + restrictions: {} + }, + { + name: 'name', + label: 'Full Name', + defaultValue: '', + type: 'text', + required: true, + instructions: 'Enter your username.', + restrictions: {} + }, + { + name: 'username', + label: 'Username', + defaultValue: '', + type: 'text', + required: true, + instructions: 'Enter your username.', + restrictions: {} + }, + { + name: 'password', + label: 'Password', + defaultValue: '', + type: 'password', + required: true, + instructions: 'Enter your password.', + restrictions: {} + }, + { + name: 'level_of_education', + label: 'Highest Level of Education Completed', + defaultValue: '', + type: 'select', + options: [ + {value: "", name: "--"}, + {value: "p", name: "Doctorate"}, + {value: "m", name: "Master's or professional degree"}, + {value: "b", name: "Bachelor's degree"}, + ], + required: false, + instructions: 'Select your education level.', + restrictions: {} + }, + { + name: 'gender', + label: 'Gender', + defaultValue: '', + type: 'select', + options: [ + {value: "", name: "--"}, + {value: "m", name: "Male"}, + {value: "f", name: "Female"}, + {value: "o", name: "Other"}, + ], + required: false, + instructions: 'Select your gender.', + restrictions: {} + }, + { + name: 'year_of_birth', + label: 'Year of Birth', + defaultValue: '', + type: 'select', + options: [ + {value: "", name: "--"}, + {value: 1900, name: "1900"}, + {value: 1950, name: "1950"}, + {value: 2014, name: "2014"}, + ], + required: false, + instructions: 'Select your year of birth.', + restrictions: {} + }, + { + name: 'mailing_address', + label: 'Mailing Address', + defaultValue: '', + type: 'textarea', + required: false, + instructions: 'Enter your mailing address.', + restrictions: {} + }, + { + name: 'goals', + label: 'Goals', + defaultValue: '', + type: 'textarea', + required: false, + instructions: "If you'd like, tell us why you're interested in edX.", + restrictions: {} + }, + { + name: 'honor_code', + label: 'I agree to the Terms of Service and Honor Code', + defaultValue: '', + type: 'checkbox', + required: true, + instructions: '', + restrictions: {} + } + ] + }; + + var createRegisterView = function(that) { + // Initialize the register model + model = new RegisterModel({}, { + url: FORM_DESCRIPTION.submit_url, + method: FORM_DESCRIPTION.method + }); + + // Initialize the register view + view = new RegisterView({ + fields: FORM_DESCRIPTION.fields, + model: model, + thirdPartyAuth: THIRD_PARTY_AUTH, + platformName: PLATFORM_NAME + }); + + // Spy on AJAX requests + requests = AjaxHelpers.requests(that); + + // Intercept events from the view + authComplete = false; + view.on("auth-complete", function() { + authComplete = true; + }); + }; + + var submitForm = function(validationSuccess) { + // Simulate manual entry of registration form data + $('#register-email').val(USER_DATA.email); + $('#register-name').val(USER_DATA.name); + $('#register-username').val(USER_DATA.username); + $('#register-password').val(USER_DATA.password); + $('#register-level_of_education').val(USER_DATA.level_of_education); + $('#register-gender').val(USER_DATA.gender); + $('#register-year_of_birth').val(USER_DATA.year_of_birth); + $('#register-mailing_address').val(USER_DATA.mailing_address); + $('#register-goals').val(USER_DATA.goals); + + // Check the honor code checkbox + $('#register-honor_code').prop('checked', USER_DATA.honor_code); + + // Create a fake click event + var clickEvent = $.Event('click'); + + // If validationSuccess isn't passed, we avoid + // spying on `view.validate` twice + if ( !_.isUndefined(validationSuccess) ) { + // Force validation to return as expected + spyOn(view, 'validate').andReturn({ + isValid: validationSuccess, + message: 'Submission was validated.' + }); + } + + // Submit the email address + view.submitForm(clickEvent); + }; + + beforeEach(function() { + setFixtures('
    '); + TemplateHelpers.installTemplate('templates/student_account/register'); + TemplateHelpers.installTemplate('templates/student_account/form_field'); + }); + + it('registers a new user', function() { + createRegisterView(this); + + // Submit the form, with successful validation + submitForm( true ); + + // Verify that the client contacts the server with the expected data + AjaxHelpers.expectRequest( + requests, 'POST', + FORM_DESCRIPTION.submit_url, + $.param( USER_DATA ) + ); + + // Respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Verify that auth complete is triggered + expect(authComplete).toBe(true); + // Form button should be disabled on success. + expect(view.$submitButton).toHaveAttr('disabled'); + }); + + it('sends analytics info containing the enrolled course ID', function() { + createRegisterView( this ); + + // Simulate that the user is attempting to enroll in a course + // by setting the course_id query string param. + spyOn($, 'url').andCallFake(function( param ) { + if (param === "?course_id") { + return encodeURIComponent( COURSE_ID ); + } + }); + + // Attempt to register + submitForm( true ); + + // Verify that the client sent the course ID for analytics + var expectedData = {}; + $.extend(expectedData, USER_DATA, { + analytics: JSON.stringify({ + enroll_course_id: COURSE_ID + }) + }); + + AjaxHelpers.expectRequest( + requests, 'POST', + FORM_DESCRIPTION.submit_url, + $.param( expectedData ) + ); + }); + + it('displays third-party auth registration buttons', function() { + createRegisterView(this); + + // Verify that Google and Facebook registration buttons are displayed + expect($('.button-Google')).toBeVisible(); + expect($('.button-Facebook')).toBeVisible(); + }); + + it('validates registration form fields', function() { + createRegisterView(this); + + // Submit the form, with successful validation + submitForm(true); + + // Verify that validation of form fields occurred + expect(view.validate).toHaveBeenCalledWith($('#register-email')[0]); + expect(view.validate).toHaveBeenCalledWith($('#register-name')[0]); + expect(view.validate).toHaveBeenCalledWith($('#register-username')[0]); + expect(view.validate).toHaveBeenCalledWith($('#register-password')[0]); + + // Verify that no submission errors are visible + expect(view.$errors).toHaveClass('hidden'); + // Form button should be disabled on success. + expect(view.$submitButton).toHaveAttr('disabled'); + }); + + it('displays registration form validation errors', function() { + createRegisterView(this); + + // Submit the form, with failed validation + submitForm(false); + + // Verify that submission errors are visible + expect(view.$errors).not.toHaveClass('hidden'); + + // Expect that auth complete is NOT triggered + expect(authComplete).toBe(false); + // Form button should be re-enabled on error. + expect(view.$submitButton).not.toHaveAttr('disabled'); + }); + + it('displays an error if the server returns an error while registering', function() { + createRegisterView(this); + + // Submit the form, with successful validation + submitForm(true); + + // Simulate an error from the LMS servers + AjaxHelpers.respondWithError(requests); + + // Expect that an error is displayed and that auth complete is NOT triggered + expect(view.$errors).not.toHaveClass('hidden'); + expect(authComplete).toBe(false); + + // If we try again and succeed, the error should go away + submitForm(); + + // This time, respond with status code 200 + AjaxHelpers.respondWithJson(requests, {}); + + // Expect that the error is hidden and that auth complete is triggered + expect(view.$errors).toHaveClass('hidden'); + expect(authComplete).toBe(true); + // Form button should be disabled on success. + expect(view.$submitButton).toHaveAttr('disabled'); + }); + }); +}); diff --git a/lms/static/js/spec/student_account/shoppingcart_spec.js b/lms/static/js/spec/student_account/shoppingcart_spec.js new file mode 100644 index 0000000000..5936f76481 --- /dev/null +++ b/lms/static/js/spec/student_account/shoppingcart_spec.js @@ -0,0 +1,48 @@ +define(['js/common_helpers/ajax_helpers', 'js/student_account/shoppingcart'], + function(AjaxHelpers, ShoppingCartInterface) { + 'use strict'; + + describe( 'edx.student.account.ShoppingCartInterface', function() { + + var COURSE_KEY = "edX/DemoX/Fall", + ADD_COURSE_URL = "/shoppingcart/add/course/edX/DemoX/Fall/", + FORWARD_URL = "/shoppingcart/"; + + beforeEach(function() { + // Mock the redirect call + spyOn(ShoppingCartInterface, 'redirect').andCallFake(function() {}); + }); + + it('adds a course to the cart', function() { + // Spy on Ajax requests + var requests = AjaxHelpers.requests( this ); + + // Attempt to add a course to the cart + ShoppingCartInterface.addCourseToCart( COURSE_KEY ); + + // Expect that the correct request was made to the server + AjaxHelpers.expectRequest( requests, 'POST', ADD_COURSE_URL ); + + // Simulate a successful response from the server + AjaxHelpers.respondWithJson( requests, {} ); + + // Expect that the user was redirected to the shopping cart + expect( ShoppingCartInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL ); + }); + + it('redirects the user on a server error', function() { + // Spy on Ajax requests + var requests = AjaxHelpers.requests( this ); + + // Attempt to add a course to the cart + ShoppingCartInterface.addCourseToCart( COURSE_KEY ); + + // Simulate an error response from the server + AjaxHelpers.respondWithError( requests ); + + // Expect that the user was redirected to the shopping cart + expect( ShoppingCartInterface.redirect ).toHaveBeenCalledWith( FORWARD_URL ); + }); + }); + } +); diff --git a/lms/static/js/spec/student_profile/profile.js b/lms/static/js/spec/student_profile/profile_spec.js similarity index 100% rename from lms/static/js/spec/student_profile/profile.js rename to lms/static/js/spec/student_profile/profile_spec.js diff --git a/lms/static/js/student_account/accessApp.js b/lms/static/js/student_account/accessApp.js new file mode 100644 index 0000000000..9b41e41439 --- /dev/null +++ b/lms/static/js/student_account/accessApp.js @@ -0,0 +1,14 @@ +var edx = edx || {}; + +(function($) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + return new edx.student.account.AccessView({ + mode: $('#login-and-registration-container').data('initial-mode'), + thirdPartyAuth: $('#login-and-registration-container').data('third-party-auth'), + platformName: $('#login-and-registration-container').data('platform-name') + }); +})(jQuery); diff --git a/lms/static/js/student_account/account.js b/lms/static/js/student_account/account.js index 21906d9706..9569ed42c9 100644 --- a/lms/static/js/student_account/account.js +++ b/lms/static/js/student_account/account.js @@ -4,7 +4,7 @@ var edx = edx || {}; 'use strict'; edx.student = edx.student || {}; - edx.student.account = {}; + edx.student.account = edx.student.account || {}; edx.student.account.AccountModel = Backbone.Model.extend({ // These should be the same length limits enforced by the server diff --git a/lms/static/js/student_account/enrollment.js b/lms/static/js/student_account/enrollment.js new file mode 100644 index 0000000000..f16e78fcbf --- /dev/null +++ b/lms/static/js/student_account/enrollment.js @@ -0,0 +1,63 @@ +var edx = edx || {}; + +(function($) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.EnrollmentInterface = { + + urls: { + course: '/enrollment/v0/course/', + trackSelection: '/course_modes/choose/' + }, + + headers: { + 'X-CSRFToken': $.cookie('csrftoken') + }, + + /** + * Enroll a user in a course, then redirect the user + * to the track selection page. + * @param {string} courseKey Slash-separated course key. + */ + enroll: function( courseKey ) { + $.ajax({ + url: this.courseEnrollmentUrl( courseKey ), + type: 'POST', + data: {}, + headers: this.headers, + context: this + }).always(function() { + this.redirect( this.trackSelectionUrl( courseKey ) ); + }); + }, + + /** + * Construct the URL to the track selection page for a course. + * @param {string} courseKey Slash-separated course key. + * @return {string} The URL to the track selection page. + */ + trackSelectionUrl: function( courseKey ) { + return this.urls.trackSelection + courseKey + '/'; + }, + + /** + * Construct a URL to enroll in a course. + * @param {string} courseKey Slash-separated course key. + * @return {string} The URL to enroll in a course. + */ + courseEnrollmentUrl: function( courseKey ) { + return this.urls.course + courseKey; + }, + + /** + * Redirect to a URL. Mainly useful for mocking out in tests. + * @param {string} url The URL to redirect to. + */ + redirect: function(url) { + window.location.href = url; + } + }; +})(jQuery); diff --git a/lms/static/js/student_account/models/LoginModel.js b/lms/static/js/student_account/models/LoginModel.js new file mode 100644 index 0000000000..5e994f2f61 --- /dev/null +++ b/lms/static/js/student_account/models/LoginModel.js @@ -0,0 +1,58 @@ +var edx = edx || {}; + +(function($, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.LoginModel = Backbone.Model.extend({ + + defaults: { + email: '', + password: '', + remember: false + }, + + ajaxType: '', + + urlRoot: '', + + initialize: function( attributes, options ) { + this.ajaxType = options.method; + this.urlRoot = options.url; + }, + + sync: function(method, model) { + var headers = { 'X-CSRFToken': $.cookie('csrftoken') }, + data = {}, + analytics, + courseId = $.url( '?course_id' ); + + // If there is a course ID in the query string param, + // send that to the server as well so it can be included + // in analytics events. + if ( courseId ) { + analytics = JSON.stringify({ + enroll_course_id: decodeURIComponent( courseId ) + }); + } + + // Include all form fields and analytics info in the data sent to the server + $.extend( data, model.attributes, { analytics: analytics }); + + $.ajax({ + url: model.urlRoot, + type: model.ajaxType, + data: data, + headers: headers, + success: function() { + model.trigger('sync'); + }, + error: function( error ) { + model.trigger('error', error); + } + }); + } + }); +})(jQuery, Backbone); diff --git a/lms/static/js/student_account/models/PasswordResetModel.js b/lms/static/js/student_account/models/PasswordResetModel.js new file mode 100644 index 0000000000..60858dc630 --- /dev/null +++ b/lms/static/js/student_account/models/PasswordResetModel.js @@ -0,0 +1,44 @@ +var edx = edx || {}; + +(function($, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.PasswordResetModel = Backbone.Model.extend({ + + defaults: { + email: '' + }, + + ajaxType: '', + + urlRoot: '', + + initialize: function( attributes, options ) { + this.ajaxType = options.method; + this.urlRoot = options.url; + }, + + sync: function(method, model) { + var headers = { + 'X-CSRFToken': $.cookie('csrftoken') + }; + + // Only expects an email address. + $.ajax({ + url: model.urlRoot, + type: model.ajaxType, + data: model.attributes, + headers: headers, + success: function() { + model.trigger('sync'); + }, + error: function( error ) { + model.trigger('error', error); + } + }); + } + }); +})(jQuery, Backbone); diff --git a/lms/static/js/student_account/models/RegisterModel.js b/lms/static/js/student_account/models/RegisterModel.js new file mode 100644 index 0000000000..ca8b953c5b --- /dev/null +++ b/lms/static/js/student_account/models/RegisterModel.js @@ -0,0 +1,64 @@ +var edx = edx || {}; + +(function($, Backbone) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.RegisterModel = Backbone.Model.extend({ + + defaults: { + email: '', + name: '', + username: '', + password: '', + level_of_education: '', + gender: '', + year_of_birth: '', + mailing_address: '', + goals: '', + }, + + ajaxType: '', + + urlRoot: '', + + initialize: function( attributes, options ) { + this.ajaxType = options.method; + this.urlRoot = options.url; + }, + + sync: function(method, model) { + var headers = { 'X-CSRFToken': $.cookie('csrftoken') }, + data = {}, + analytics, + courseId = $.url( '?course_id' ); + + // If there is a course ID in the query string param, + // send that to the server as well so it can be included + // in analytics events. + if ( courseId ) { + analytics = JSON.stringify({ + enroll_course_id: decodeURIComponent( courseId ) + }); + } + + // Include all form fields and analytics info in the data sent to the server + $.extend( data, model.attributes, { analytics: analytics }); + + $.ajax({ + url: model.urlRoot, + type: model.ajaxType, + data: data, + headers: headers, + success: function() { + model.trigger('sync'); + }, + error: function( error ) { + model.trigger('error', error); + } + }); + } + }); +})(jQuery, Backbone); diff --git a/lms/static/js/student_account/shoppingcart.js b/lms/static/js/student_account/shoppingcart.js new file mode 100644 index 0000000000..18ec197914 --- /dev/null +++ b/lms/static/js/student_account/shoppingcart.js @@ -0,0 +1,49 @@ +/** +* Use the shopping cart to purchase courses. +*/ + +var edx = edx || {}; + +(function($) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.ShoppingCartInterface = { + + urls: { + viewCart: "/shoppingcart/", + addCourse: "/shoppingcart/add/course/" + }, + + headers: { + 'X-CSRFToken': $.cookie('csrftoken') + }, + + /** + * Add a course to a cart, then redirect to the view cart page. + * @param {string} courseId The slash-separated course ID to add to the cart. + */ + addCourseToCart: function( courseId ) { + $.ajax({ + url: this.urls.addCourse + courseId + "/", + type: 'POST', + data: {}, + headers: this.headers, + context: this + }).always(function() { + this.redirect( this.urls.viewCart ); + }); + }, + + /** + * Redirect to a URL. Mainly useful for mocking out in tests. + * @param {string} url The URL to redirect to. + */ + redirect: function( url ) { + window.location.href = url; + } + }; + +})(jQuery); diff --git a/lms/static/js/student_account/views/AccessView.js b/lms/static/js/student_account/views/AccessView.js new file mode 100644 index 0000000000..0d79171b59 --- /dev/null +++ b/lms/static/js/student_account/views/AccessView.js @@ -0,0 +1,283 @@ +var edx = edx || {}; + +(function($, _, _s, Backbone, gettext) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.AccessView = Backbone.View.extend({ + el: '#login-and-registration-container', + + tpl: '#access-tpl', + + events: { + 'change .form-toggle': 'toggleForm' + }, + + subview: { + login: {}, + register: {}, + passwordHelp: {} + }, + + // The form currently loaded + activeForm: '', + + initialize: function( obj ) { + /* Mix non-conflicting functions from underscore.string + * (all but include, contains, and reverse) into the + * Underscore namespace + */ + _.mixin( _s.exports() ); + + this.tpl = $(this.tpl).html(); + this.activeForm = obj.mode || 'login'; + this.thirdPartyAuth = obj.thirdPartyAuth || { + currentProvider: null, + providers: [] + }; + this.platformName = obj.platformName; + + this.render(); + }, + + render: function() { + $(this.el).html( _.template( this.tpl, { + mode: this.activeForm + })); + + this.postRender(); + + return this; + }, + + postRender: function() { + // Load the default form + this.loadForm( this.activeForm ); + this.$header = $(this.el).find('.js-login-register-header'); + }, + + loadForm: function( type ) { + this.getFormData( type, this ); + }, + + load: { + login: function( data, context ) { + var model = new edx.student.account.LoginModel({}, { + method: data.method, + url: data.submit_url + }); + + context.subview.login = new edx.student.account.LoginView({ + fields: data.fields, + model: model, + thirdPartyAuth: context.thirdPartyAuth, + platformName: context.platformName + }); + + // Listen for 'password-help' event to toggle sub-views + context.listenTo( context.subview.login, 'password-help', context.resetPassword ); + + // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. + context.listenTo( context.subview.login, 'auth-complete', context.authComplete ); + + }, + + reset: function( data, context ) { + var model = new edx.student.account.PasswordResetModel({}, { + method: data.method, + url: data.submit_url + }); + + context.subview.passwordHelp = new edx.student.account.PasswordResetView({ + fields: data.fields, + model: model + }); + }, + + register: function( data, context ) { + var model = new edx.student.account.RegisterModel({}, { + method: data.method, + url: data.submit_url + }); + + context.subview.register = new edx.student.account.RegisterView({ + fields: data.fields, + model: model, + thirdPartyAuth: context.thirdPartyAuth, + platformName: context.platformName + }); + + // Listen for 'auth-complete' event so we can enroll/redirect the user appropriately. + context.listenTo( context.subview.register, 'auth-complete', context.authComplete ); + } + }, + + getFormData: function( type, context ) { + var urls = { + login: 'login_session', + register: 'registration', + reset: 'password_reset' + }; + + $.ajax({ + url: '/user_api/v1/account/' + urls[type] + '/', + type: 'GET', + dataType: 'json', + context: this, + success: function( data ) { + this.load[type]( data, context ); + }, + error: this.showFormError + }); + }, + + resetPassword: function() { + window.analytics.track('edx.bi.password_reset_form.viewed', { + category: 'user-engagement' + }); + + this.element.hide( this.$header ); + this.element.hide( $(this.el).find('.form-type') ); + this.loadForm('reset'); + this.element.scrollTop( $('#password-reset-wrapper') ); + }, + + showFormError: function() { + this.element.show( $('#form-load-fail') ); + }, + + toggleForm: function( e ) { + var type = $(e.currentTarget).val(), + $form = $('#' + type + '-form'), + $anchor = $('#' + type + '-anchor'); + + window.analytics.track('edx.bi.' + type + '_form.toggled', { + category: 'user-engagement' + }); + + if ( !this.form.isLoaded( $form ) ) { + this.loadForm( type ); + } + + this.element.hide( $(this.el).find('.form-wrapper') ); + this.element.show( $form ); + this.element.scrollTop( $anchor ); + }, + + /** + * Once authentication has completed successfully, a user may need to: + * + * - Enroll in a course. + * - Add a course to the shopping cart. + * - Be redirected to the dashboard / track selection page / shopping cart. + * + * This handler is triggered upon successful authentication, + * either from the login or registration form. It checks + * query string params, performs enrollment/shopping cart actions, + * then redirects the user to the next page. + * + * The optional query string params are: + * + * ?next: If provided, redirect to this page upon successful auth. + * Django uses this when an unauthenticated user accesses a view + * decorated with @login_required. + * + * ?enrollment_action: Can be either "enroll" or "add_to_cart". + * If you provide this param, you must also provide a `course_id` param; + * otherwise, no action will be taken. + * + * ?course_id: The slash-separated course ID to enroll in or add to the cart. + * + */ + authComplete: function() { + var enrollment = edx.student.account.EnrollmentInterface, + shoppingcart = edx.student.account.ShoppingCartInterface, + redirectUrl = '/dashboard', + queryParams = this.queryParams(); + + if ( queryParams.enrollmentAction === 'enroll' && queryParams.courseId) { + /* + If we need to enroll in a course, mark as enrolled. + The enrollment interface will redirect the student once enrollment completes. + */ + enrollment.enroll( decodeURIComponent( queryParams.courseId ) ); + } else if ( queryParams.enrollmentAction === 'add_to_cart' && queryParams.courseId) { + /* + If this is a paid course, add it to the shopping cart and redirect + the user to the "view cart" page. + */ + shoppingcart.addCourseToCart( decodeURIComponent( queryParams.courseId ) ); + } else { + /* + Otherwise, redirect the user to the next page + Check for forwarding url and ensure that it isn't external. + If not, use the default forwarding URL. + */ + if ( !_.isNull( queryParams.next ) ) { + var next = decodeURIComponent( queryParams.next ); + + // Ensure that the URL is internal for security reasons + if ( !window.isExternal( next ) ) { + redirectUrl = next; + } + } + + this.redirect( redirectUrl ); + } + }, + + /** + * Redirect to a URL. Mainly useful for mocking out in tests. + * @param {string} url The URL to redirect to. + */ + redirect: function( url ) { + window.location.href = url; + }, + + /** + * Retrieve query params that we use post-authentication + * to decide whether to enroll a student in a course, add + * an item to the cart, or redirect. + * + * @return {object} The query params. If any param is not + * provided, it will default to null. + */ + queryParams: function() { + return { + next: $.url( '?next' ), + enrollmentAction: $.url( '?enrollment_action' ), + courseId: $.url( '?course_id' ) + }; + }, + + form: { + isLoaded: function( $form ) { + return $form.html().length > 0; + } + }, + + /* Helper method to toggle display + * including accessibility considerations + */ + element: { + hide: function( $el ) { + $el.addClass('hidden') + .attr('aria-hidden', true); + }, + + scrollTop: function( $el ) { + // Scroll to top of selected element + $('html,body').animate({ + scrollTop: $el.offset().top + },'slow'); + }, + + show: function( $el ) { + $el.removeClass('hidden') + .attr('aria-hidden', false); + } + } + }); +})(jQuery, _, _.str, Backbone, gettext); diff --git a/lms/static/js/student_account/views/FormView.js b/lms/static/js/student_account/views/FormView.js new file mode 100644 index 0000000000..32b3e87472 --- /dev/null +++ b/lms/static/js/student_account/views/FormView.js @@ -0,0 +1,262 @@ +var edx = edx || {}; + +(function($, _, Backbone, gettext) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.FormView = Backbone.View.extend({ + tagName: 'form', + + el: '', + + tpl: '', + + fieldTpl: '#form_field-tpl', + + events: {}, + + errors: [], + + formType: '', + + $form: {}, + + fields: [], + + // String to append to required label fields + requiredStr: '*', + + submitButton: '', + + initialize: function( data ) { + this.model = data.model; + this.preRender( data ); + + this.tpl = $(this.tpl).html(); + this.fieldTpl = $(this.fieldTpl).html(); + this.buildForm( data.fields ); + + this.listenTo( this.model, 'error', this.saveError ); + }, + + /* Allows extended views to add custom + * init steps without needing to repeat + * default init steps + */ + preRender: function( data ) { + /* Custom code goes here */ + return data; + }, + + render: function( html ) { + var fields = html || ''; + + $(this.el).html( _.template( this.tpl, { + fields: fields + })); + + this.postRender(); + + return this; + }, + + postRender: function() { + var $container = $(this.el); + + this.$form = $container.find('form'); + this.$errors = $container.find('.submission-error'); + this.$submitButton = $container.find(this.submitButton); + }, + + buildForm: function( data ) { + var html = [], + i, + len = data.length, + fieldTpl = this.fieldTpl; + + this.fields = data; + + for ( i=0; i' + error.responseText + '
  • ']; + this.setErrors(); + this.toggleDisableButton(false); + }, + + setErrors: function() { + var $msg = this.$errors.find('.message-copy'), + html = [], + errors = this.errors, + i, + len = errors.length; + + for ( i=0; i' + error.responseText + '']; + this.setErrors(); + + /* If we've gotten a 403 error, it means that we've successfully + * authenticated with a third-party provider, but we haven't + * linked the account to an EdX account. In this case, + * we need to prompt the user to enter a little more information + * to complete the registration process. + */ + if ( error.status === 403 && + error.responseText === 'third-party-auth' && + this.currentProvider ) { + this.element.show( this.$authError ); + this.element.hide( this.$errors ); + } else { + this.element.hide( this.$authError ); + this.element.show( this.$errors ); + } + this.toggleDisableButton(false); + } + }); +})(jQuery, _, gettext); diff --git a/lms/static/js/student_account/views/PasswordResetView.js b/lms/static/js/student_account/views/PasswordResetView.js new file mode 100644 index 0000000000..cddedfa66f --- /dev/null +++ b/lms/static/js/student_account/views/PasswordResetView.js @@ -0,0 +1,47 @@ +var edx = edx || {}; + +(function($, gettext) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.PasswordResetView = edx.student.account.FormView.extend({ + el: '#password-reset-wrapper', + + tpl: '#password_reset-tpl', + + events: { + 'click .js-reset': 'submitForm' + }, + + formType: 'password-reset', + + requiredStr: '', + + submitButton: '.js-reset', + + preRender: function() { + this.listenTo( this.model, 'sync', this.saveSuccess ); + }, + + toggleErrorMsg: function( show ) { + if ( show ) { + this.setErrors(); + this.toggleDisableButton(false); + } else { + this.element.hide( this.$errors ); + } + }, + + saveSuccess: function() { + var $el = $(this.el), + $msg = $el.find('.js-reset-success'); + + this.element.hide( $el.find('#password-reset-form') ); + this.element.show( $msg ); + this.element.scrollTop( $msg ); + } + }); + +})(jQuery, gettext); diff --git a/lms/static/js/student_account/views/RegisterView.js b/lms/static/js/student_account/views/RegisterView.js new file mode 100644 index 0000000000..ef7fc9854d --- /dev/null +++ b/lms/static/js/student_account/views/RegisterView.js @@ -0,0 +1,64 @@ +var edx = edx || {}; + +(function($, _, gettext) { + 'use strict'; + + edx.student = edx.student || {}; + edx.student.account = edx.student.account || {}; + + edx.student.account.RegisterView = edx.student.account.FormView.extend({ + el: '#register-form', + + tpl: '#register-tpl', + + events: { + 'click .js-register': 'submitForm', + 'click .login-provider': 'thirdPartyAuth' + }, + + formType: 'register', + + submitButton: '.js-register', + + preRender: function( data ) { + this.providers = data.thirdPartyAuth.providers || []; + this.currentProvider = data.thirdPartyAuth.currentProvider || ''; + this.platformName = data.platformName; + + this.listenTo( this.model, 'sync', this.saveSuccess ); + }, + + render: function( html ) { + var fields = html || ''; + + $(this.el).html( _.template( this.tpl, { + /* We pass the context object to the template so that + * we can perform variable interpolation using sprintf + */ + context: { + fields: fields, + currentProvider: this.currentProvider, + providers: this.providers, + platformName: this.platformName + } + })); + + this.postRender(); + + return this; + }, + + thirdPartyAuth: function( event ) { + var providerUrl = $(event.target).data('provider-url') || ''; + + if ( providerUrl ) { + window.location.href = providerUrl; + } + }, + + saveSuccess: function() { + this.trigger('auth-complete'); + } + + }); +})(jQuery, _, gettext); diff --git a/lms/static/js/student_profile/profile.js b/lms/static/js/student_profile/profile.js index 664b198d65..77bc8ae7a2 100644 --- a/lms/static/js/student_profile/profile.js +++ b/lms/static/js/student_profile/profile.js @@ -4,7 +4,7 @@ var edx = edx || {}; 'use strict'; edx.student = edx.student || {}; - edx.student.profile = {}; + edx.student.profile = edx.student.profile || {}; var syncErrorMessage = gettext("The data could not be saved."); diff --git a/lms/static/js_test.yml b/lms/static/js_test.yml index fa0bb93b48..10f140a9e3 100644 --- a/lms/static/js_test.yml +++ b/lms/static/js_test.yml @@ -41,6 +41,7 @@ lib_paths: - xmodule_js/common_static/js/vendor/flot/jquery.flot.js - xmodule_js/common_static/js/vendor/CodeMirror/codemirror.js - xmodule_js/common_static/js/vendor/URI.min.js + - xmodule_js/common_static/js/vendor/url.min.js - xmodule_js/common_static/coffee/src/jquery.immediateDescendents.js - xmodule_js/common_static/coffee/src/xblock - xmodule_js/common_static/js/vendor/sinon-1.7.1.js @@ -49,6 +50,7 @@ lib_paths: - xmodule_js/src/xmodule.js - xmodule_js/common_static/js/src/ - xmodule_js/common_static/js/vendor/underscore-min.js + - xmodule_js/common_static/js/vendor/underscore.string.min.js - xmodule_js/common_static/js/vendor/backbone-min.js # Paths to source JavaScript files diff --git a/lms/static/sass/application-extend2.scss.mako b/lms/static/sass/application-extend2.scss.mako index 0ea4e21259..83c7664286 100644 --- a/lms/static/sass/application-extend2.scss.mako +++ b/lms/static/sass/application-extend2.scss.mako @@ -7,6 +7,7 @@ @import 'bourbon/bourbon'; // lib - bourbon @import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages + // BASE *default edX offerings* // ==================== @@ -44,6 +45,7 @@ @import 'elements/system-feedback'; // base - specific views +@import 'views/login-register'; @import 'views/verification'; @import 'views/shoppingcart'; diff --git a/lms/static/sass/base/_grid-settings.scss b/lms/static/sass/base/_grid-settings.scss index 4e41d3ba82..582472519e 100644 --- a/lms/static/sass/base/_grid-settings.scss +++ b/lms/static/sass/base/_grid-settings.scss @@ -6,7 +6,6 @@ $max-width: 1200px; $border-box-sizing: false; - /* Breakpoints */ $mobile: new-breakpoint(max-width 320px 4); $tablet: new-breakpoint(min-width 321px max-width 768px, 8); diff --git a/lms/static/sass/base/_variables.scss b/lms/static/sass/base/_variables.scss index c32f5e9eab..861d59e048 100644 --- a/lms/static/sass/base/_variables.scss +++ b/lms/static/sass/base/_variables.scss @@ -22,6 +22,12 @@ $monospace: Monaco, 'Bitstream Vera Sans Mono', 'Lucida Console', monospace; $body-font-family: $sans-serif; $serif: $georgia; +// FONT-WEIGHTS +$font-light: 300; +$font-regular: 400; +$font-semibold: 600; +$font-bold: 700; + // ==================== // MISC: base fonts/colors diff --git a/lms/static/sass/multicourse/_account.scss b/lms/static/sass/multicourse/_account.scss index 0c4243dce7..77ba0b0301 100644 --- a/lms/static/sass/multicourse/_account.scss +++ b/lms/static/sass/multicourse/_account.scss @@ -585,7 +585,7 @@ list-style: none; li { - margin: 0 0 ($baseline/4) 0; + margin: 0; /*0 0 ($baseline/4) 0;*/ } } } @@ -598,10 +598,6 @@ .message-title { color: shade($red, 10%) !important; } - - .message-copy { - - } } // misc diff --git a/lms/static/sass/shared/_footer.scss b/lms/static/sass/shared/_footer.scss index 9afd513a8b..ac181096ad 100644 --- a/lms/static/sass/shared/_footer.scss +++ b/lms/static/sass/shared/_footer.scss @@ -371,6 +371,7 @@ $edx-footer-bg-color: rgb(252,252,252); @extend %edx-footer-reset; @extend %edx-footer-section; width: flex-grid(3,12); + } .footer-follow-title { diff --git a/lms/static/sass/shared/_header.scss b/lms/static/sass/shared/_header.scss index dd0bddeb3d..9747f952db 100644 --- a/lms/static/sass/shared/_header.scss +++ b/lms/static/sass/shared/_header.scss @@ -634,15 +634,15 @@ header.global-new { &.rwd { nav { max-width: 1180px; - width: 100%; + width: 320px; } .mobile-menu-button { - @extend %t-action1; display: inline; float: left; text-decoration: none; color: $m-gray; + font-size: 18px; margin-top: 9px; &:hover, @@ -666,7 +666,7 @@ header.global-new { .nav-global, .nav-courseware { a { - @extend %t-action3; + font-size: 14px; &.nav-courseware-button { width: 86px; @@ -720,12 +720,6 @@ header.global-new { } } - @include media( 320px ) { - nav { - width: 320px; - } - } - @include media( $desktop ) { nav { width: 100%; diff --git a/lms/static/sass/views/_login-register.scss b/lms/static/sass/views/_login-register.scss new file mode 100644 index 0000000000..f2f9ce0579 --- /dev/null +++ b/lms/static/sass/views/_login-register.scss @@ -0,0 +1,452 @@ +// lms - views - login/register view +// ==================== +@import '../base/grid-settings'; +@import "neat/neat"; // lib - Neat + +%heading-4 { + font-size: 14px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0 !important; + color: $m-gray-d2; +} + +%body-text { + font-size: 15px; + margin: 0 0 $baseline 0; + color: $base-font-color; + line-height: lh(1); +} + +.section-bkg-wrapper { + background: $m-gray-l4; +} + +.login-register { + @include box-sizing(border-box); + @include outer-container; + $grid-columns: 12; + background: white; + + width: 100%; + + h2 { + line-height: 16px; + margin: 0; + font-family: $sans-serif; + } + + /* Temp. fix until applied globally */ + > { + @include box-sizing(border-box); + } + + + /* Remove autocomplete yellow background */ + input:-webkit-autofill { + -webkit-box-shadow:0 0 0 50px white inset; + -webkit-text-fill-color: #333; + } + + input:-webkit-autofill:focus { + -webkit-box-shadow: white, 0 0 0 50px white inset; + -webkit-text-fill-color: #333; + } + + .header { + @include outer-container; + border-bottom: 1px solid $gray-l4; + width: 100%; + padding-top: 35px; + padding-bottom: 35px; + overflow: hidden; + + .headline { + @include box-sizing(border-box); + @include font-size(35); + padding: 0 10px; + font-family: $sans-serif; + font-weight: $font-semibold; + text-align: left; + margin-bottom: 0; + color: $m-blue-d5; + } + + .tagline { + @include box-sizing(border-box); + @include font-size(24); + padding: 0 10px; + font-family: $sans-serif; + font-weight: $font-regular; + } + } + + .form-toggle { + margin: 0; + } + + .form-type { + @include box-sizing(border-box); + @include span-columns(12); + padding: 25px 10px; + + &:nth-of-type(2) { + border-bottom: 1px solid $gray-l4; + } + } + + .note { + @extend %t-copy-sub2; + display: block; + font-weight: normal; + color: $gray; + margin-top: 15px; + } + + + /** The forms **/ + .form-wrapper { + padding-top: 25px; + + form { + @include clearfix; + clear: both; + } + } + + .login-form { + margin-bottom: 20px; + } + + %bold-label { + @include font-size(16); + font-family: $sans-serif; + font-weight: $font-semibold; + font-style: normal; + text-transform: none; + } + + .form-label { + @extend %bold-label; + padding: 0 0 0 5px; + letter-spacing: 1px; + } + + .action-label { + @extend %bold-label; + margin-bottom: 20px; + } + + .form-field { + @include clearfix; + clear: both; + position: relative; + width: 100%; + margin: 0 0 $baseline 0; + + /** FROM _accounts.scss - start **/ + label, + input, + textarea { + border-radius: 0; + height: auto; + font-family: $sans-serif; + font-style: normal; + font-weight: 500; + color: $base-font-color; + } + + label { + @include transition(color 0.15s ease-in-out 0s); + margin: 0 0 ($baseline/4) 0; + color: tint($black, 20%); + font-weight: $font-semibold; + + &.inline { + display: inline; + } + + &.error { + color: $red; + } + + a { + font-family: $sans-serif; + } + } + + .field-link { + position: relative; + color: $link-color-d1; + font-weight: $font-regular; + text-decoration: none !important; // needed but nasty + font-family: $sans-serif; + font-size: 0.8em; + padding-top: 7px; + } + + input, + textarea { + display: block; + width: 100%; + margin: 0; + padding: ($baseline/2) ($baseline*.75); + + &.long { + width: 100%; + } + + &.short { + width: 25%; + } + + &.checkbox { + display: inline; + width: auto; + margin-right: 5px; + } + + &.error { + border-color: tint($red,50%); + } + } + + textarea.long { + height: ($baseline*5); + } + + select { + width: 100%; + + &.error { + border-color: tint($red,50%); + } + } + /** FROM _accounts.scss - end **/ + } + + .input-block { + width: 100%; + } + + .input-inline { + display: inline; + } + + .desc { + @include transition(color 0.15s ease-in-out 0s); + display: block; + margin-top: ($baseline/4); + color: $lighter-base-font-color; + font-size: em(13); + } + + .action-primary { + @extend %m-btn-primary; + width: 100%; + text-transform: none; + color: white; + } + + .login-provider { + @extend %btn-secondary-blue-outline; + width: 100%; + margin-bottom: 20px; + text-shadow: none; + text-transform: none; + + .icon { + color: inherit; + margin-right: $baseline/2; + } + + &:last-child { + margin-bottom: 20px; + } + + &.button-Google:hover, + &.button-Google:focus { + background-color: #dd4b39; + border: 1px solid #A5382B; + } + + &.button-Google:hover { + box-shadow: 0 2px 1px 0 #8D3024; + } + + &.button-Facebook:hover, + &.button-Facebook:focus { + background-color: #3b5998; + border: 1px solid #263A62; + } + + &.button-Facebook:hover { + box-shadow: 0 2px 1px 0 #30487C; + } + + &.button-LinkedIn:hover, + &.button-LinkedIn:focus { + background-color: #0077b5; + border: 1px solid #06527D; + } + + &.button-LinkedIn:hover { + box-shadow: 0 2px 1px 0 #005D8E; + } + + } + + /** Error Container - from _account.scss **/ + .status { + @include box-sizing(border-box); + margin: 0 0 $baseline 0; + border-bottom: 3px solid shade($yellow, 10%); + padding: $baseline $baseline; + background: tint($yellow,20%); + + .message-title { + @extend %heading-4; + font-family: $sans-serif; + margin: 0 0 ($baseline/4) 0; + font-size: em(14); + font-weight: 600; + } + + .message-copy { + @extend %body-text; + font-family: $sans-serif; + margin: 0 !important; + padding: 0; + list-style: none; + + li { + margin: 0 0 ($baseline/4) 0; + } + } + } + + .submission-error, .system-error { + box-shadow: inset 0 -1px 2px 0 tint($red, 85%); + border-bottom: 3px solid shade($red, 10%); + background: tint($red,95%); + + .message-title { + color: shade($red, 10%) !important; + } + + .message-copy { + + } + } + + .submission-success { + box-shadow: inset 0 -1px 2px 0 tint($green, 85%); + border-bottom: 3px solid shade($green, 10%); + background: tint($green, 95%); + + .message-title { + color: shade($green, 10%) !important; + } + + .message-copy { + + } + } + + /** RWD **/ + @include media( $tablet ) { + $grid-columns: 8; + + %inline-form-field-tablet { + clear: none; + display: inline-block; + float: left; + } + + .header .headline, + .header .tagline, + .form-type { + @include span-columns(6); + @include shift(1); + } + + .form-toggle { + margin-right: 5px; + } + + .form-field { + &.select-gender { + @extend %inline-form-field-tablet; + width: calc( 50% - 10px ); + margin-right: 20px; + } + + &.select-year_of_birth { + @extend %inline-form-field-tablet; + width: calc( 50% - 10px ); + } + + .field-link { + position: absolute; + top: 0; + right: 0; + } + } + } + + @include media( $desktop ) { + $grid-columns: 12; + + %inline-form-field-desktop { + clear: none; + display: inline-block; + float: left; + } + + .header .headline, + .header .tagline, + .form-type { + width: 600px; + margin-left: calc( 50% - 300px ); + margin-right: calc( 50% - 300px ); + } + + .form-toggle { + margin-right: 10px; + } + + .form-field { + &.select-level_of_education { + @extend %inline-form-field-desktop; + width: 300px; + margin-right: 20px; + } + + &.select-gender { + @extend %inline-form-field-desktop; + width: 60px; + margin-right: 20px; + } + + &.select-year_of_birth { + @extend %inline-form-field-desktop; + width: 100px; + } + + .field-link { + position: absolute; + top: 0; + right: 0; + } + } + + .login-provider { + @include span-columns(6); + + /* Node uses last-child which is not specific enough */ + &:nth-of-type(2n) { + margin-right: 0; + } + } + } +} diff --git a/lms/templates/courseware/mktg_course_about.html b/lms/templates/courseware/mktg_course_about.html index ae57b614cd..3b63e61ebd 100644 --- a/lms/templates/courseware/mktg_course_about.html +++ b/lms/templates/courseware/mktg_course_about.html @@ -29,7 +29,7 @@ window.top.location.href = "${reverse('dashboard')}"; } } else if (xhr.status == 403) { - window.top.location.href = "${reverse('register_user')}?course_id=${course.id | u}&enrollment_action=enroll"; + window.top.location.href = $("a.register").attr("href") || "${reverse('register_user')}?course_id=${course.id | u}&enrollment_action=enroll"; } else { $('#register_error').html( (xhr.responseText ? xhr.responseText : "${_("An error occurred. Please try again later.")}") diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 253a56220f..dbea4d34f2 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -1,5 +1,6 @@ <%! from django.utils.translation import ugettext as _ %> <%! from django.template import RequestContext %> +<%! import third_party_auth %> <%! from third_party_auth import pipeline %> <%! from microsite_configuration import microsite %> @@ -95,7 +96,7 @@ <%include file='dashboard/_dashboard_info_language.html' /> %endif - % if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')): + % if third_party_auth.is_enabled():
  • ## Translators: this section lists all the third-party authentication providers (for example, Google and LinkedIn) the user can link with or unlink from their edX account. diff --git a/lms/templates/login.html b/lms/templates/login.html index 29db663f65..84e48505e1 100644 --- a/lms/templates/login.html +++ b/lms/templates/login.html @@ -4,6 +4,7 @@ <%! from django.core.urlresolvers import reverse %> <%! from django.utils.translation import ugettext as _ %> +<%! import third_party_auth %> <%! from third_party_auth import provider, pipeline %> <%block name="pagetitle">${_("Log into your {platform_name} Account").format(platform_name=platform_name)} @@ -48,8 +49,16 @@ $('#login-form').on('ajax:error', function(event, request, status_string) { toggleSubmitButton(true); - $('.third-party-signin.message').addClass('is-shown').focus(); - $('.third-party-signin.message .instructions').html(request.responseText); + + if (request.status === 403) { + $('.message.submission-error').removeClass('is-shown'); + $('.third-party-signin.message').addClass('is-shown').focus(); + $('.third-party-signin.message .instructions').html(request.responseText); + } else { + $('.third-party-signin.message').removeClass('is-shown'); + $('.message.submission-error').addClass('is-shown').focus(); + $('.message.submission-error').html(gettext("Your request could not be completed. Please try again.")); + } }); $('#login-form').on('ajax:success', function(event, json, xhr) { @@ -190,11 +199,11 @@ % endif
    - +
    - % if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')): + % if third_party_auth.is_enabled(): ## Developers: this is a sentence fragment, which is usually frowned upon. The design of the pags uses this fragment to provide an "else" clause underneath a number of choices. It's OK to leave it. @@ -206,7 +215,7 @@ % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication provider (like Google or LinkedIn). - + % endfor diff --git a/lms/templates/main.html b/lms/templates/main.html index 0026085f3c..24bd8d4870 100644 --- a/lms/templates/main.html +++ b/lms/templates/main.html @@ -112,6 +112,7 @@ <%include file="widgets/optimizely.html" /> + <%include file="widgets/segment-io.html" /> @@ -153,8 +154,6 @@ % endif <%block name="js_extra"/> - - <%include file="widgets/segment-io.html" /> diff --git a/lms/templates/register.html b/lms/templates/register.html index 0033c71839..9942f807c1 100644 --- a/lms/templates/register.html +++ b/lms/templates/register.html @@ -12,6 +12,7 @@ <%! from django.utils.translation import ugettext as _ %> <%! from student.models import UserProfile %> <%! from datetime import date %> +<%! import third_party_auth %> <%! from third_party_auth import pipeline, provider %> <%! import calendar %> @@ -116,7 +117,7 @@
    - % if microsite.get_value('ENABLE_THIRD_PARTY_AUTH', settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH')): + % if third_party_auth.is_enabled(): % if not running_pipeline: @@ -124,7 +125,7 @@ % for enabled in provider.Registry.enabled(): ## Translators: provider_name is the name of an external, third-party user authentication service (like Google or LinkedIn). - + % endfor @@ -182,7 +183,7 @@ ${_('Will be shown in any discussions or forums you participate in')} (${_('cannot be changed later')})
  • - % if settings.FEATURES.get('ENABLE_THIRD_PARTY_AUTH') and running_pipeline: + % if third_party_auth.is_enabled() and running_pipeline: