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: '