Commit af4d288f by Stephen Sanchez

Merge pull request #7377 from edx/sanchez/enrollment_api_course_modes

XCOM-45 Enrollment API supports creating modes other than honor.
parents f0b44920 8c9f6370
......@@ -13,6 +13,7 @@ from django.conf import settings
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory
from course_modes.models import CourseMode
from enrollment.views import EnrollmentUserThrottle
from util.models import RateLimitConfiguration
from util.testing import UrlResetMixin
from enrollment import api
......@@ -40,9 +41,12 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
""" Create a course and user, then log in. """
super(EnrollmentTest, self).setUp()
rate_limit_config = RateLimitConfiguration.current()
rate_limit_config.enabled = False
rate_limit_config.save()
self.rate_limit_config = RateLimitConfiguration.current()
self.rate_limit_config.enabled = False
self.rate_limit_config.save()
throttle = EnrollmentUserThrottle()
self.rate_limit, rate_duration = throttle.parse_rate(throttle.rate)
self.course = CourseFactory.create()
self.user = UserFactory.create(username=self.USERNAME, email=self.EMAIL, password=self.PASSWORD)
......@@ -52,12 +56,12 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
@ddt.data(
# Default (no course modes in the database)
# Expect that users are automatically enrolled as "honor".
([], 'honor'),
([], CourseMode.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'),
([CourseMode.HONOR, CourseMode.VERIFIED, CourseMode.AUDIT], CourseMode.HONOR),
)
@ddt.unpack
def test_enroll(self, course_modes, enrollment_mode):
......@@ -80,8 +84,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_check_enrollment(self):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
# Create an enrollment
self._create_enrollment()
......@@ -91,7 +95,7 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course_details']['course_id'])
self.assertEqual('honor', data['mode'])
self.assertEqual(CourseMode.HONOR, data['mode'])
self.assertTrue(data['is_active'])
@ddt.data(
......@@ -139,8 +143,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_user_not_specified(self):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
# Create an enrollment
self._create_enrollment()
......@@ -150,7 +154,7 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
self.assertEqual(resp.status_code, status.HTTP_200_OK)
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course_details']['course_id'])
self.assertEqual('honor', data['mode'])
self.assertEqual(CourseMode.HONOR, data['mode'])
self.assertTrue(data['is_active'])
def test_user_not_authenticated(self):
......@@ -187,8 +191,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
# Try to enroll a user that is not the authenticated user.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
self._create_enrollment(username=self.other_user.username, expected_status=status.HTTP_404_NOT_FOUND)
# Verify that the server still has access to this endpoint.
......@@ -198,8 +202,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_user_does_not_match_param_for_list(self):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
resp = self.client.get(reverse('courseenrollments'), {"user": self.other_user.username})
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
......@@ -213,8 +217,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_user_does_not_match_param(self):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
resp = self.client.get(
reverse('courseenrollment', kwargs={"user": self.other_user.username, "course_id": unicode(self.course.id)})
......@@ -231,8 +235,8 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_get_course_details(self):
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='honor',
mode_display_name='Honor',
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
sku='123',
)
resp = self.client.get(
......@@ -243,9 +247,9 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
data = json.loads(resp.content)
self.assertEqual(unicode(self.course.id), data['course_id'])
mode = data['course_modes'][0]
self.assertEqual(mode['slug'], 'honor')
self.assertEqual(mode['slug'], CourseMode.HONOR)
self.assertEqual(mode['sku'], '123')
self.assertEqual(mode['name'], 'Honor')
self.assertEqual(mode['name'], CourseMode.HONOR)
def test_with_invalid_course_id(self):
self._create_enrollment(course_id='entirely/fake/course', expected_status=status.HTTP_400_BAD_REQUEST)
......@@ -266,7 +270,7 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_enrollment_already_enrolled(self):
response = self._create_enrollment()
repeat_response = self._create_enrollment()
repeat_response = self._create_enrollment(expected_status=status.HTTP_200_OK)
self.assertEqual(json.loads(response.content), json.loads(repeat_response.content))
def test_get_enrollment_with_invalid_key(self):
......@@ -285,39 +289,143 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
def test_enrollment_throttle_for_user(self):
"""Make sure a user requests do not exceed the maximum number of requests"""
rate_limit_config = RateLimitConfiguration.current()
rate_limit_config.enabled = True
rate_limit_config.save()
self.rate_limit_config.enabled = True
self.rate_limit_config.save()
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
for attempt in range(0, 50):
expected_status = status.HTTP_429_TOO_MANY_REQUESTS if attempt >= 40 else status.HTTP_200_OK
for attempt in xrange(self.rate_limit + 10):
expected_status = status.HTTP_200_OK
if attempt == 0:
expected_status = status.HTTP_201_CREATED
elif attempt >= self.rate_limit:
expected_status = status.HTTP_429_TOO_MANY_REQUESTS
self._create_enrollment(expected_status=expected_status)
def test_enrollment_throttle_for_service(self):
"""Make sure a service can call the enrollment API as many times as needed. """
rate_limit_config = RateLimitConfiguration.current()
rate_limit_config.enabled = True
rate_limit_config.save()
self.rate_limit_config.enabled = True
self.rate_limit_config.save()
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=CourseMode.HONOR,
mode_display_name=CourseMode.HONOR,
)
for attempt in range(0, 50):
for attempt in xrange(self.rate_limit + 10):
expected_status = status.HTTP_201_CREATED if attempt == 0 else status.HTTP_200_OK
self._create_enrollment(as_server=True, expected_status=expected_status)
def test_create_enrollment_with_mode(self):
"""With the right API key, create a new enrollment with a mode set other than the default."""
# Create a professional ed course mode.
CourseModeFactory.create(
course_id=self.course.id,
mode_slug='professional',
mode_display_name='professional',
)
# Create an enrollment
self._create_enrollment(as_server=True, mode='professional')
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, 'professional')
def test_update_enrollment_with_mode(self):
"""With the right API key, update an existing enrollment with a new mode. """
# Create an honor and verified mode for a course. This allows an update.
for mode in [CourseMode.HONOR, CourseMode.VERIFIED]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create an enrollment
self._create_enrollment(as_server=True)
def _create_enrollment(self, course_id=None, username=None, expected_status=status.HTTP_200_OK, email_opt_in=None, as_server=False):
# 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 verified.
self._create_enrollment(as_server=True, mode=CourseMode.VERIFIED, expected_status=status.HTTP_200_OK)
course_mode, is_active = CourseEnrollment.enrollment_mode_for_user(self.user, self.course.id)
self.assertTrue(is_active)
self.assertEqual(course_mode, CourseMode.VERIFIED)
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.
for mode in [CourseMode.HONOR, CourseMode.VERIFIED]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create a 'verified' enrollment
self._create_enrollment(as_server=True, mode=CourseMode.VERIFIED)
# Check that the enrollment is verified.
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.VERIFIED)
# Check that the enrollment downgraded to honor.
self._create_enrollment(as_server=True, mode=CourseMode.HONOR, expected_status=status.HTTP_200_OK)
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_change_mode_from_user(self):
"""Users should not be able to alter the enrollment mode on an enrollment. """
# Create an honor and verified mode for a course. This allows an update.
for mode in [CourseMode.HONOR, CourseMode.VERIFIED]:
CourseModeFactory.create(
course_id=self.course.id,
mode_slug=mode,
mode_display_name=mode,
)
# Create an enrollment
self._create_enrollment()
# 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)
# Get a 403 response when trying to upgrade yourself.
self._create_enrollment(mode=CourseMode.VERIFIED, expected_status=status.HTTP_403_FORBIDDEN)
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 _create_enrollment(
self,
course_id=None,
username=None,
expected_status=status.HTTP_201_CREATED,
email_opt_in=None,
as_server=False,
mode=CourseMode.HONOR,
):
"""Enroll in the course and verify the URL we are sent to. """
course_id = unicode(self.course.id) if course_id is None else course_id
username = self.user.username if username is None else username
params = {
'mode': mode,
'course_details': {
'course_id': course_id
},
......@@ -332,10 +440,10 @@ class EnrollmentTest(ModuleStoreTestCase, APITestCase):
self.assertEqual(resp.status_code, expected_status)
if expected_status == status.HTTP_200_OK:
if expected_status in [status.HTTP_201_CREATED, status.HTTP_200_OK]:
data = json.loads(resp.content)
self.assertEqual(course_id, data['course_details']['course_id'])
self.assertEqual('honor', data['mode'])
self.assertEqual(mode, data['mode'])
self.assertTrue(data['is_active'])
return resp
......@@ -391,7 +499,7 @@ class EnrollmentEmbargoTest(UrlResetMixin, ModuleStoreTestCase):
})
response = self.client.post(url, data, content_type='application/json')
self.assertEqual(response.status_code, 200)
self.assertEqual(response.status_code, status.HTTP_201_CREATED)
# Verify that we were enrolled
self.assertEqual(len(self._get_enrollments()), 1)
......
......@@ -6,6 +6,7 @@ consist primarily of authentication, request validation, and serialization.
from ipware.ip import get_ip
from django.utils.decorators import method_decorator
from opaque_keys import InvalidKeyError
from course_modes.models import CourseMode
from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in
from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated
from rest_framework import status
......@@ -215,20 +216,27 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
2. Enroll the currently logged in user in a course.
Currently you can use this command only to enroll the user in "honor" mode.
Currently a user can use this command only to enroll the user in "honor" mode.
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 supposed for the course, the request will fail and return the
available modes.
**Example Requests**:
GET /api/enrollment/v1/enrollment
POST /api/enrollment/v1/enrollment{"course_details":{"course_id": "edX/DemoX/Demo_Course"}}
POST /api/enrollment/v1/enrollment{"mode": "honor", "course_details":{"course_id": "edX/DemoX/Demo_Course"}}
**Post Parameters**
* user: The user ID of the currently logged in user. Optional. You cannot use the command to enroll a different user.
* mode: The Course Mode for the enrollment. Individual users cannot upgrade their enrollment mode from
'honor'. Only server to server requests can enroll with other modes. Optional.
* course details: A collection that contains:
* course_id: The unique identifier for the course.
......@@ -302,13 +310,8 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
"""
Enrolls the currently logged in user in a course.
"""
# Get the User, Course ID, and Mode from the request.
user = request.DATA.get('user', request.user.username)
if not user:
user = request.user.username
if user != request.user.username and not self.has_api_key_permissions(request):
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
if 'course_details' not in request.DATA or 'course_id' not in request.DATA['course_details']:
return Response(
......@@ -316,7 +319,6 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
data={"message": u"Course ID must be specified to create a new enrollment."}
)
course_id = request.DATA['course_details']['course_id']
try:
course_id = CourseKey.from_string(course_id)
except InvalidKeyError:
......@@ -327,11 +329,33 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
}
)
mode = request.DATA.get('mode', CourseMode.HONOR)
has_api_key_permissions = self.has_api_key_permissions(request)
# Check that the user specified is either the same user, or this is a server-to-server request.
if not user:
user = request.user.username
if user != request.user.username and not has_api_key_permissions:
# Return a 404 instead of a 403 (Unauthorized). If one user is looking up
# other users, do not let them deduce the existence of an enrollment.
return Response(status=status.HTTP_404_NOT_FOUND)
if mode != CourseMode.HONOR and not has_api_key_permissions:
return Response(
status=status.HTTP_403_FORBIDDEN,
data={
"message": u"User does not have permission to create enrollment with mode [{mode}].".format(
mode=mode
)
}
)
# Check whether any country access rules block the user from enrollment
# We do this at the view level (rather than the Python API level)
# because this check requires information about the HTTP request.
redirect_url = embargo_api.redirect_if_blocked(
course_id, user=request.user,
course_id, user=user,
ip_address=get_ip(request),
url=request.path
)
......@@ -347,12 +371,25 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn):
)
try:
response = api.add_enrollment(user, unicode(course_id))
# Check if the user is currently enrolled, and if it is the same as the current enrolled mode. We do not
# have to check if it is inactive or not, because if it is, we are still upgrading if the mode is different,
# and either path will re-activate the enrollment.
#
# Only server-to-server calls will currently be allowed to modify the mode for existing enrollments. All
# other requests will go through add_enrollment(), which will allow creating of new enrollments, and
# re-activating enrollments
enrollment = api.get_enrollment(user, unicode(course_id))
if has_api_key_permissions and enrollment and enrollment['mode'] != mode:
response = api.update_enrollment(user, unicode(course_id), mode=mode)
http_success_status = status.HTTP_200_OK
else:
response = api.add_enrollment(user, unicode(course_id), mode=mode)
http_success_status = status.HTTP_201_CREATED
email_opt_in = request.DATA.get('email_opt_in', None)
if email_opt_in is not None:
org = course_id.org
update_email_opt_in(request.user, org, email_opt_in)
return Response(response)
return Response(response, status=http_success_status)
except CourseModeNotFoundError as error:
return Response(
status=status.HTTP_400_BAD_REQUEST,
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment