diff --git a/openedx/core/djangoapps/enrollments/forms.py b/openedx/core/djangoapps/enrollments/forms.py index 5285792890..4c691ae704 100644 --- a/openedx/core/djangoapps/enrollments/forms.py +++ b/openedx/core/djangoapps/enrollments/forms.py @@ -15,9 +15,10 @@ class CourseEnrollmentsApiListForm(Form): """ A form that validates the query string parameters for the CourseEnrollmentsApiListView. """ - MAX_USERNAME_COUNT = 100 + MAX_INPUT_COUNT = 100 username = CharField(required=False) course_id = CharField(required=False) + email = CharField(required=False) def clean_course_id(self): """ @@ -38,14 +39,31 @@ class CourseEnrollmentsApiListForm(Form): usernames_csv_string = self.cleaned_data.get('username') if usernames_csv_string: usernames = usernames_csv_string.split(',') - if len(usernames) > self.MAX_USERNAME_COUNT: + if len(usernames) > self.MAX_INPUT_COUNT: raise ValidationError( "Too many usernames in a single request - {}. A maximum of {} is allowed".format( len(usernames), - self.MAX_USERNAME_COUNT, + self.MAX_INPUT_COUNT, ) ) for username in usernames: validate_username(username) return usernames return usernames_csv_string + + def clean_email(self): + """ + Validate a string of comma-separated emails and return a list of emails. + """ + emails_csv_string = self.cleaned_data.get('email') + if emails_csv_string: + emails = emails_csv_string.split(',') + if len(emails) > self.MAX_INPUT_COUNT: + raise ValidationError( + "Too many emails in a single request - {}. A maximum of {} is allowed".format( + len(emails), + self.MAX_INPUT_COUNT, + ) + ) + return emails + return emails_csv_string diff --git a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json index 7822ad5626..6108d92efd 100644 --- a/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json +++ b/openedx/core/djangoapps/enrollments/tests/fixtures/course-enrollments-api-list-valid-data.json @@ -114,6 +114,63 @@ ] ], + [ + { + "email": "student1@example.com" + }, + [ + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student1", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "email": "student1@example.com,student2@example.com" + }, + [ + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student1", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "course-v1:x+y+Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "email": "student2@example.com", + "course_id": "course-v1:e+d+X" + }, + [ + { + "course_id": "course-v1:e+d+X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] + ], [ null, [ diff --git a/openedx/core/djangoapps/enrollments/tests/test_views.py b/openedx/core/djangoapps/enrollments/tests/test_views.py index 07a83adfa9..c6ded3c3b5 100644 --- a/openedx/core/djangoapps/enrollments/tests/test_views.py +++ b/openedx/core/djangoapps/enrollments/tests/test_views.py @@ -237,6 +237,112 @@ class EnrollmentTest(EnrollmentTestMixin, ModuleStoreTestCase, APITestCase, Ente assert is_active assert course_mode == enrollment_mode + def test_enroll_with_email_staff(self): + # Create enrollments with email are allowed if you are staff. + + self.client.logout() + AdminFactory.create(username='global_staff', email='global_staff@example.com', password=self.PASSWORD) + self.client.login(username="global_staff", password=self.PASSWORD) + + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'email': self.user.email + }, + format='json' + ) + assert resp.status_code == status.HTTP_200_OK + + @patch('openedx.core.djangoapps.enrollments.views.EnrollmentListView.has_api_key_permissions') + def test_enroll_with_email_server(self, has_api_key_permissions_mock): + # Create enrollments with email are allowed if it is a server-to-server request. + + has_api_key_permissions_mock.return_value = True + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'email': self.user.email + }, + format='json' + ) + assert resp.status_code == status.HTTP_200_OK + + def test_enroll_with_email_without_staff(self): + # If you are not staff or server request you can't create enrollments with email. + + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'email': self.other_user.email + }, + format='json' + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_enroll_with_user_and_email(self): + # Creating enrollments the user has priority over the email. + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'user': self.user.username, + 'email': self.other_user.email + }, + format='json' + ) + self.assertContains(resp, self.user.username, status_code=status.HTTP_200_OK) + + def test_enroll_with_user_without_permissions_and_email(self): + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'user': self.other_user.username, + 'email': self.user.email + }, + format='json' + ) + assert resp.status_code == status.HTTP_404_NOT_FOUND + + def test_enroll_with_user_as_self_user(self): + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + }, + 'user': self.user.username + }, + format='json' + ) + self.assertContains(resp, self.user.username, status_code=status.HTTP_200_OK) + + def test_enroll_without_user(self): + # To check if it takes the request.user. + resp = self.client.post( + reverse('courseenrollments'), + { + 'course_details': { + 'course_id': str(self.course.id) + } + }, + format='json' + ) + self.assertContains(resp, self.user.username, status_code=status.HTTP_200_OK) + @ddt.data( # Default (no course modes in the database) # Expect that users are automatically enrolled as the default diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index 8124db23ee..3923c23d78 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -676,7 +676,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): """ # Get the User, Course ID, and Mode from the request. - username = request.data.get('user', request.user.username) + username = request.data.get('user') course_id = request.data.get('course_details', {}).get('course_id') if not course_id: @@ -700,14 +700,32 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): 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 username: - username = request.user.username - if username != request.user.username and not has_api_key_permissions \ + if username and username != request.user.username and not has_api_key_permissions \ and not GlobalStaff().has_user(request.user): # 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) + # A provided user has priority over a provided email. + # Fallback on request user if neither is provided. + if not username: + email = request.data.get('email') + if email: + # Only server-to-server or staff users can use the email for the request. + if not has_api_key_permissions and not GlobalStaff().has_user(request.user): + return Response(status=status.HTTP_404_NOT_FOUND) + try: + username = User.objects.get(email=email).username + except ObjectDoesNotExist: + return Response( + status=status.HTTP_406_NOT_ACCEPTABLE, + data={ + 'message': f'The user with the email address {email} does not exist.' + } + ) + else: + username = request.user.username + if mode not in (CourseMode.AUDIT, CourseMode.HONOR, None) and not has_api_key_permissions \ and not GlobalStaff().has_user(request.user): return Response( @@ -911,6 +929,8 @@ class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username} + GET /api/enrollment/v1/enrollments?email={email},{email} + **Query Parameters for GET** * course_id: Filters the result to course enrollments for the course corresponding to the @@ -919,6 +939,9 @@ class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): * username: List of comma-separated usernames. Filters the result to the course enrollments of the given users. Optional. + * email: List of comma-separated emails. Filters the result to the course enrollments + of the given users. Optional. + * page_size: Number of results to return per page. Optional. * page: Page number to retrieve. Optional. @@ -981,9 +1004,12 @@ class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): queryset = CourseEnrollment.objects.all() course_id = form.cleaned_data.get('course_id') usernames = form.cleaned_data.get('username') + emails = form.cleaned_data.get('email') if course_id: queryset = queryset.filter(course_id=course_id) if usernames: queryset = queryset.filter(user__username__in=usernames) + if emails: + queryset = queryset.filter(user__email__in=emails) return queryset