diff --git a/common/djangoapps/enrollment/api.py b/common/djangoapps/enrollment/api.py index 2345b025f5..07c3b2ec87 100644 --- a/common/djangoapps/enrollment/api.py +++ b/common/djangoapps/enrollment/api.py @@ -5,8 +5,8 @@ course level, such as available course modes. """ from django.utils import importlib import logging -from django.core.cache import cache from django.conf import settings +from django.core.cache import cache from enrollment import errors log = logging.getLogger(__name__) @@ -181,7 +181,7 @@ def add_enrollment(user_id, course_id, mode='honor', is_active=True): return _data_api().create_course_enrollment(user_id, course_id, mode, is_active) -def update_enrollment(user_id, course_id, mode=None, is_active=None): +def update_enrollment(user_id, course_id, mode=None, is_active=None, enrollment_attributes=None): """Updates the course mode for the enrolled user. Update a course enrollment for the given user and course. @@ -232,6 +232,10 @@ def update_enrollment(user_id, course_id, mode=None, is_active=None): msg = u"Course Enrollment not found for user {user} in course {course}".format(user=user_id, course=course_id) log.warn(msg) raise errors.EnrollmentNotFoundError(msg) + else: + if enrollment_attributes is not None: + set_enrollment_attributes(user_id, course_id, enrollment_attributes) + return enrollment @@ -302,6 +306,53 @@ def get_course_enrollment_details(course_id, include_expired=False): return course_enrollment_details +def set_enrollment_attributes(user_id, course_id, attributes): + """Set enrollment attributes for the enrollment of given user in the + course provided. + + Args: + course_id (str): The Course to set enrollment attributes for. + user_id (str): The User to set enrollment attributes for. + attributes (list): Attributes to be set. + + Example: + >>>set_enrollment_attributes( + "Bob", + "course-v1-edX-DemoX-1T2015", + [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }, + ] + ) + """ + _data_api().add_or_update_enrollment_attr(user_id, course_id, attributes) + + +def get_enrollment_attributes(user_id, course_id): + """Retrieve enrollment attributes for given user for provided course. + + Args: + user_id: The User to get enrollment attributes for + course_id (str): The Course to get enrollment attributes for. + + Example: + >>>get_enrollment_attributes("Bob", "course-v1-edX-DemoX-1T2015") + [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }, + ] + + Returns: list + """ + return _data_api().get_enrollment_attributes(user_id, course_id) + + def _validate_course_mode(course_id, mode): """Checks to see if the specified course mode is valid for the course. diff --git a/common/djangoapps/enrollment/data.py b/common/djangoapps/enrollment/data.py index ce05cea6c3..f58ac8c0e3 100644 --- a/common/djangoapps/enrollment/data.py +++ b/common/djangoapps/enrollment/data.py @@ -9,12 +9,12 @@ from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore from enrollment.errors import ( CourseNotFoundError, CourseEnrollmentClosedError, CourseEnrollmentFullError, - CourseEnrollmentExistsError, UserNotFoundError, + CourseEnrollmentExistsError, UserNotFoundError, InvalidEnrollmentAttribute ) from enrollment.serializers import CourseEnrollmentSerializer, CourseField from student.models import ( CourseEnrollment, NonExistentCourseError, EnrollmentClosedError, - CourseFullError, AlreadyEnrolledError, + CourseFullError, AlreadyEnrolledError, CourseEnrollmentAttribute ) log = logging.getLogger(__name__) @@ -136,12 +136,112 @@ def update_course_enrollment(username, course_id, mode=None, is_active=None): return None +def add_or_update_enrollment_attr(user_id, course_id, attributes): + """Set enrollment attributes for the enrollment of given user in the + course provided. + + Args: + course_id (str): The Course to set enrollment attributes for. + user_id (str): The User to set enrollment attributes for. + attributes (list): Attributes to be set. + + Example: + >>>add_or_update_enrollment_attr( + "Bob", + "course-v1-edX-DemoX-1T2015", + [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }, + ] + ) + """ + course_key = CourseKey.from_string(course_id) + user = _get_user(user_id) + enrollment = CourseEnrollment.get_enrollment(user, course_key) + if not _invalid_attribute(attributes) and enrollment is not None: + CourseEnrollmentAttribute.add_enrollment_attr(enrollment, attributes) + + +def get_enrollment_attributes(user_id, course_id): + """Retrieve enrollment attributes for given user for provided course. + + Args: + user_id: The User to get enrollment attributes for + course_id (str): The Course to get enrollment attributes for. + + Example: + >>>get_enrollment_attributes("Bob", "course-v1-edX-DemoX-1T2015") + [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }, + ] + + Returns: list + """ + course_key = CourseKey.from_string(course_id) + user = _get_user(user_id) + enrollment = CourseEnrollment.get_enrollment(user, course_key) + return CourseEnrollmentAttribute.get_enrollment_attributes(enrollment) + + +def _get_user(user_id): + """Retrieve user with provided user_id + + Args: + user_id(str): username of the user for which object is to retrieve + + Returns: obj + """ + try: + return User.objects.get(username=user_id) + except User.DoesNotExist: + msg = u"Not user with username '{username}' found.".format(username=user_id) + log.warn(msg) + raise UserNotFoundError(msg) + + def _update_enrollment(enrollment, is_active=None, mode=None): enrollment.update_enrollment(is_active=is_active, mode=mode) enrollment.save() return CourseEnrollmentSerializer(enrollment).data # pylint: disable=no-member +def _invalid_attribute(attributes): + """Validate enrollment attribute + + Args: + attributes(dict): dict of attribute + + Return: + list of invalid attributes + """ + invalid_attributes = [] + for attribute in attributes: + if "namespace" not in attribute: + msg = u"'namespace' not in enrollment attribute" + log.warn(msg) + invalid_attributes.append("namespace") + raise InvalidEnrollmentAttribute(msg) + if "name" not in attribute: + msg = u"'name' not in enrollment attribute" + log.warn(msg) + invalid_attributes.append("name") + raise InvalidEnrollmentAttribute(msg) + if "value" not in attribute: + msg = u"'value' not in enrollment attribute" + log.warn(msg) + invalid_attributes.append("value") + raise InvalidEnrollmentAttribute(msg) + + return invalid_attributes + + def get_course_enrollment_info(course_id, include_expired=False): """Returns all course enrollment information for the given course. diff --git a/common/djangoapps/enrollment/errors.py b/common/djangoapps/enrollment/errors.py index c35e02d280..cc66e86ed3 100644 --- a/common/djangoapps/enrollment/errors.py +++ b/common/djangoapps/enrollment/errors.py @@ -50,3 +50,8 @@ class EnrollmentNotFoundError(CourseEnrollmentError): class EnrollmentApiLoadError(CourseEnrollmentError): """The data API could not be loaded.""" pass + + +class InvalidEnrollmentAttribute(CourseEnrollmentError): + """Enrollment Attributes could not be validated""" + pass diff --git a/common/djangoapps/enrollment/tests/fake_data_api.py b/common/djangoapps/enrollment/tests/fake_data_api.py index 3115ce1cbb..ec9fa519b3 100644 --- a/common/djangoapps/enrollment/tests/fake_data_api.py +++ b/common/djangoapps/enrollment/tests/fake_data_api.py @@ -19,6 +19,8 @@ _ENROLLMENTS = [] _COURSES = [] +_ENROLLMENT_ATTRIBUTES = [] + # pylint: disable=unused-argument def get_course_enrollments(student_id): @@ -78,6 +80,23 @@ def add_enrollment(student_id, course_id, is_active=True, mode='honor'): return enrollment +# pylint: disable=unused-argument +def add_or_update_enrollment_attr(user_id, course_id, attributes): + """Add or update enrollment attribute array""" + for attribute in attributes: + _ENROLLMENT_ATTRIBUTES.append({ + 'namespace': attribute['namespace'], + 'name': attribute['name'], + 'value': attribute['value'] + }) + + +# pylint: disable=unused-argument +def get_enrollment_attributes(user_id, course_id): + """Retrieve enrollment attribute array""" + return _ENROLLMENT_ATTRIBUTES + + 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 = { diff --git a/common/djangoapps/enrollment/tests/test_api.py b/common/djangoapps/enrollment/tests/test_api.py index 57af3f1403..4c11ea0fb0 100644 --- a/common/djangoapps/enrollment/tests/test_api.py +++ b/common/djangoapps/enrollment/tests/test_api.py @@ -143,6 +143,29 @@ class EnrollmentTest(TestCase): result = api.update_enrollment(self.USERNAME, self.COURSE_ID, mode='verified') self.assertEquals('verified', result['mode']) + def test_update_enrollment_attributes(self): + # Add fake course enrollment information to the fake data API + fake_data_api.add_course(self.COURSE_ID, course_modes=['honor', 'verified', 'audit', 'credit']) + # 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) + + enrollment_attributes = [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + } + ] + + result = api.update_enrollment( + self.USERNAME, self.COURSE_ID, mode='credit', enrollment_attributes=enrollment_attributes + ) + self.assertEquals('credit', result['mode']) + attributes = api.get_enrollment_attributes(self.USERNAME, self.COURSE_ID) + self.assertEquals(enrollment_attributes[0], attributes[0]) + 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']) diff --git a/common/djangoapps/enrollment/tests/test_data.py b/common/djangoapps/enrollment/tests/test_data.py index aee45b93d3..6702ac7e2d 100644 --- a/common/djangoapps/enrollment/tests/test_data.py +++ b/common/djangoapps/enrollment/tests/test_data.py @@ -170,6 +170,45 @@ class EnrollmentDataTest(ModuleStoreTestCase): self.assertEqual(self.user.username, result['user']) self.assertEqual(enrollment, result) + @ddt.data( + # Default (no course modes in the database) + # Expect that users are automatically enrolled as "honor". + ([], 'credit'), + + # 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', 'credit'], 'credit'), + ) + @ddt.unpack + def test_add_or_update_enrollment_attr(self, course_modes, enrollment_mode): + # Create the course modes (if any) required for this test case + self._create_course_modes(course_modes) + data.create_course_enrollment(self.user.username, unicode(self.course.id), enrollment_mode, True) + enrollment_attributes = [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + } + ] + + data.add_or_update_enrollment_attr(self.user.username, unicode(self.course.id), enrollment_attributes) + enrollment_attr = data.get_enrollment_attributes(self.user.username, unicode(self.course.id)) + self.assertEqual(enrollment_attr[0], enrollment_attributes[0]) + + enrollment_attributes = [ + { + "namespace": "credit", + "name": "provider_id", + "value": "ASU", + } + ] + + data.add_or_update_enrollment_attr(self.user.username, unicode(self.course.id), enrollment_attributes) + enrollment_attr = data.get_enrollment_attributes(self.user.username, unicode(self.course.id)) + self.assertEqual(enrollment_attr[0], enrollment_attributes[0]) + @raises(CourseNotFoundError) def test_non_existent_course(self): data.get_course_enrollment_info("this/is/bananas") diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index 9baca37201..a648e1742c 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -46,6 +46,7 @@ class EnrollmentTestMixin(object): as_server=False, mode=CourseMode.HONOR, is_active=None, + enrollment_attributes=None, ): """ Enroll in the course and verify the response's status code. If the expected status is 200, also validates @@ -62,7 +63,8 @@ class EnrollmentTestMixin(object): 'course_details': { 'course_id': course_id }, - 'user': username + 'user': username, + 'enrollment_attributes': enrollment_attributes } if is_active is not None: @@ -547,6 +549,78 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase): self.assertTrue(is_active) self.assertEqual(course_mode, CourseMode.VERIFIED) + def test_enrollment_with_credit_mode(self): + """With the right API key, update an existing enrollment with credit + mode and set enrollment attributes. + """ + for mode in [CourseMode.HONOR, CourseMode.CREDIT_MODE]: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode, + mode_display_name=mode, + ) + + # Create an enrollment + self.assert_enrollment_status(as_server=True) + + # Check that the enrollment is honor. + 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, CourseMode.HONOR) + + # Check that the enrollment upgraded to credit. + enrollment_attributes = [{ + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }] + self.assert_enrollment_status( + as_server=True, + mode=CourseMode.CREDIT_MODE, + expected_status=status.HTTP_200_OK, + enrollment_attributes=enrollment_attributes + ) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, CourseMode.CREDIT_MODE) + + def test_enrollment_with_invalid_attr(self): + """Check response status is bad request when invalid enrollment + attributes are passed + """ + for mode in [CourseMode.HONOR, CourseMode.CREDIT_MODE]: + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode, + mode_display_name=mode, + ) + + # Create an enrollment + self.assert_enrollment_status(as_server=True) + + # Check that the enrollment is honor. + 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, CourseMode.HONOR) + + # Check that the enrollment upgraded to credit. + enrollment_attributes = [{ + "namespace": "credit", + "name": "invalid", + "value": "hogwarts", + }] + self.assert_enrollment_status( + as_server=True, + mode=CourseMode.CREDIT_MODE, + expected_status=status.HTTP_400_BAD_REQUEST, + enrollment_attributes=enrollment_attributes + ) + course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id) + self.assertTrue(is_active) + self.assertEqual(course_mode, CourseMode.HONOR) + def test_downgrade_enrollment_with_mode(self): """With the right API key, downgrade an existing enrollment with a new mode. """ # Create an honor and verified mode for a course. This allows an update. diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 8cfb06cc34..65bc4e6ab0 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -5,7 +5,6 @@ consist primarily of authentication, request validation, and serialization. """ import logging -from ipware.ip import get_ip from django.core.exceptions import ObjectDoesNotExist from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError @@ -33,7 +32,11 @@ from enrollment.errors import ( ) from student.models import User + log = logging.getLogger(__name__) +REQUIRED_ATTRIBUTES = { + "credit": ["credit:provider_id"], +} class EnrollmentCrossDomainSessionAuth(SessionAuthenticationAllowInactiveUser, SessionAuthenticationCrossDomainCsrf): @@ -264,9 +267,13 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): If honor mode is not supported for the course, the request fails and returns the available modes. - A server-to-server call can be used by this command to enroll a user in other modes, such as "verified" - or "professional". If the mode is not supported for the course, the request will fail and return the - available modes. + A server-to-server call can be used by this command to enroll a user in other modes, such as "verified", + "professional" or "credit". If the mode is not supported for the course, the request will fail and + return the available modes. + + You can include other parameters as enrollment attributes for specific course mode as needed. For + example, for credit mode, you can include parameters namespace:'credit', name:'provider_id', + value:'UniversityX' to specify credit provider attribute. **Example Requests**: @@ -274,6 +281,12 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): POST /api/enrollment/v1/enrollment{"mode": "honor", "course_details":{"course_id": "edX/DemoX/Demo_Course"}} + POST /api/enrollment/v1/enrollment{ + "mode": "credit", + "course_details":{"course_id": "edX/DemoX/Demo_Course"}, + "enrollment_attributes":[{"namespace": "credit","name": "provider_id","value": "hogwarts",},] + } + **Post Parameters** * user: The username of the currently logged in user. Optional. @@ -292,6 +305,12 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): * email_opt_in: A Boolean indicating whether the user wishes to opt into email from the organization running this course. Optional. + * enrollment_attributes: A list of dictionary that contains: + + * namespace: Namespace of the attribute + * name: Name of the attribute + * value: Value of the attribute + **Response Values** A collection of course enrollments for the user, or for the newly created enrollment. @@ -335,7 +354,6 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): * user: The username of the user. """ - authentication_classes = OAuth2AuthenticationAllowInactiveUser, EnrollmentCrossDomainSessionAuth permission_classes = ApiKeyHeaderPermissionIsAuthenticated, throttle_classes = EnrollmentUserThrottle, @@ -370,6 +388,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): go through `add_enrollment()`, which allows creation of new and reactivation of old enrollments. """ # Get the User, Course ID, and Mode from the request. + username = request.DATA.get('user', request.user.username) course_id = request.DATA.get('course_details', {}).get('course_id') @@ -438,9 +457,17 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): } ) + enrollment_attributes = request.DATA.get('enrollment_attributes') enrollment = api.get_enrollment(username, unicode(course_id)) mode_changed = enrollment and mode is not None and enrollment['mode'] != mode active_changed = enrollment and is_active is not None and enrollment['is_active'] != is_active + missing_attrs = [] + if enrollment_attributes: + actual_attrs = [ + u"{namespace}:{name}".format(**attr) + for attr in enrollment_attributes + ] + missing_attrs = set(REQUIRED_ATTRIBUTES.get(mode, [])) - set(actual_attrs) if has_api_key_permissions and (mode_changed or active_changed): if mode_changed and active_changed and not is_active: # if the requester wanted to deactivate but specified the wrong mode, fail @@ -451,7 +478,21 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): ) log.warning(msg) return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg}) - response = api.update_enrollment(username, unicode(course_id), mode=mode, is_active=is_active) + + if len(missing_attrs) > 0: + msg = u"Missing enrollment attributes: requested mode={} required attributes={}".format( + mode, REQUIRED_ATTRIBUTES.get(mode) + ) + log.warning(msg) + return Response(status=status.HTTP_400_BAD_REQUEST, data={"message": msg}) + + response = api.update_enrollment( + username, + unicode(course_id), + mode=mode, + is_active=is_active, + enrollment_attributes=enrollment_attributes + ) else: # Will reactivate inactive enrollments. response = api.add_enrollment(username, unicode(course_id), mode=mode, is_active=is_active) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 2c1cc1bb2c..0e9f5566fb 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -1854,3 +1854,47 @@ class CourseEnrollmentAttribute(models.Model): name=self.name, value=self.value, ) + + @classmethod + def add_enrollment_attr(cls, enrollment, data_list): + """Delete all the enrollment attributes for the given enrollment and + add new attributes. + + Args: + enrollment(CourseEnrollment): 'CourseEnrollment' for which attribute is to be added + data(list): list of dictionaries containing data to save + """ + cls.objects.filter(enrollment=enrollment).delete() + attributes = [ + cls(enrollment=enrollment, namespace=data['namespace'], name=data['name'], value=data['value']) + for data in data_list + ] + cls.objects.bulk_create(attributes) + + @classmethod + def get_enrollment_attributes(cls, enrollment): + """Retrieve list of all enrollment attributes. + + Args: + enrollment(CourseEnrollment): 'CourseEnrollment' for which list is to retrieve + + Returns: list + + Example: + >>> CourseEnrollmentAttribute.get_enrollment_attributes(CourseEnrollment) + [ + { + "namespace": "credit", + "name": "provider_id", + "value": "hogwarts", + }, + ] + """ + return [ + { + "namespace": attribute.namespace, + "name": attribute.name, + "value": attribute.value, + } + for attribute in cls.objects.filter(enrollment=enrollment) + ]