Merge pull request #7377 from edx/sanchez/enrollment_api_course_modes
XCOM-45 Enrollment API supports creating modes other than honor.
This commit is contained in:
@@ -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):
|
||||
self._create_enrollment(as_server=True)
|
||||
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 _create_enrollment(self, course_id=None, username=None, expected_status=status.HTTP_200_OK, email_opt_in=None, as_server=False):
|
||||
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)
|
||||
|
||||
# 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,
|
||||
|
||||
Reference in New Issue
Block a user