diff --git a/common/djangoapps/enrollment/forms.py b/common/djangoapps/enrollment/forms.py new file mode 100644 index 0000000000..8ceac15e28 --- /dev/null +++ b/common/djangoapps/enrollment/forms.py @@ -0,0 +1,50 @@ +""" +Forms for validating user input to the Course Enrollment related views. +""" +from django.core.exceptions import ValidationError +from django.forms import CharField, Form + +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from student import forms as student_forms + + +class CourseEnrollmentsApiListForm(Form): + """ + A form that validates the query string parameters for the CourseEnrollmentsApiListView. + """ + MAX_USERNAME_COUNT = 100 + username = CharField(required=False) + course_id = CharField(required=False) + + def clean_course_id(self): + """ + Validate and return a course ID. + """ + course_id = self.cleaned_data.get('course_id') + if course_id: + try: + return CourseKey.from_string(course_id) + except InvalidKeyError: + raise ValidationError("'{}' is not a valid course id.".format(course_id)) + return course_id + + def clean_username(self): + """ + Validate a string of comma-separated usernames and return a list of usernames. + """ + usernames_csv_string = self.cleaned_data.get('username') + if usernames_csv_string: + usernames = usernames_csv_string.split(',') + if len(usernames) > self.MAX_USERNAME_COUNT: + raise ValidationError( + "Too many usernames in a single request - {}. A maximum of {} is allowed".format( + len(usernames), + self.MAX_USERNAME_COUNT, + ) + ) + for username in usernames: + student_forms.validate_username(username) + return usernames + return usernames_csv_string diff --git a/common/djangoapps/enrollment/paginators.py b/common/djangoapps/enrollment/paginators.py new file mode 100644 index 0000000000..45f54b0d3d --- /dev/null +++ b/common/djangoapps/enrollment/paginators.py @@ -0,0 +1,11 @@ +""" +Paginators for the course enrollment related views. +""" +from rest_framework.pagination import CursorPagination + + +class CourseEnrollmentsApiListPagination(CursorPagination): + """ + Paginator for the Course enrollments list API. + """ + page_size = 100 diff --git a/common/djangoapps/enrollment/serializers.py b/common/djangoapps/enrollment/serializers.py index e0e7ad3e87..07e4f46475 100644 --- a/common/djangoapps/enrollment/serializers.py +++ b/common/djangoapps/enrollment/serializers.py @@ -82,6 +82,21 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): lookup_field = 'username' +class CourseEnrollmentsApiListSerializer(CourseEnrollmentSerializer): + """ + Serializes CourseEnrollment model and returns a subset of fields returned + by the CourseEnrollmentSerializer. + """ + course_id = serializers.CharField(source='course_overview.id') + + def __init__(self, *args, **kwargs): + super(CourseEnrollmentsApiListSerializer, self).__init__(*args, **kwargs) + self.fields.pop('course_details') + + class Meta(CourseEnrollmentSerializer.Meta): + fields = CourseEnrollmentSerializer.Meta.fields + ('course_id', ) + + class ModeSerializer(serializers.Serializer): """Serializes a course's 'Mode' tuples diff --git a/common/djangoapps/enrollment/tests/fixtures/course-enrollments-api-list-valid-data.json b/common/djangoapps/enrollment/tests/fixtures/course-enrollments-api-list-valid-data.json new file mode 100644 index 0000000000..e9fd2f55ec --- /dev/null +++ b/common/djangoapps/enrollment/tests/fixtures/course-enrollments-api-list-valid-data.json @@ -0,0 +1,157 @@ +[ + [ + { + "course_id": "e/d/X" + }, + [ + { + "course_id": "e/d/X", + "is_active": true, + "mode": "honor", + "user": "student1", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "e/d/X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "course_id": "x/y/Z" + }, + [ + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "staff", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "student3", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "course_id": "x/y/Z", + "username": "student2,student3" + }, + [ + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "student3", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "course_id": "x/y/Z", + "username": "student1,student2" + }, + [ + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] + ], + [ + { + "username": "student2,staff" + }, + [ + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "staff", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "e/d/X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + } + ] + + ], + [ + null, + [ + { + "course_id": "e/d/X", + "is_active": true, + "mode": "honor", + "user": "student1", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "e/d/X", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "student3", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "honor", + "user": "student2", + "created": "2018-01-01T00:00:01Z" + }, + { + "course_id": "x/y/Z", + "is_active": true, + "mode": "verified", + "user": "staff", + "created": "2018-01-01T00:00:01Z" + } + ] + ] +] diff --git a/common/djangoapps/enrollment/tests/test_views.py b/common/djangoapps/enrollment/tests/test_views.py index 80e80d0876..af8b9b31fd 100644 --- a/common/djangoapps/enrollment/tests/test_views.py +++ b/common/djangoapps/enrollment/tests/test_views.py @@ -16,6 +16,7 @@ from django.core.handlers.wsgi import WSGIRequest from django.urls import reverse from django.test import Client from django.test.utils import override_settings +from freezegun import freeze_time from mock import patch from rest_framework import status from rest_framework.test import APITestCase @@ -24,7 +25,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, check_mongo_calls from course_modes.models import CourseMode from course_modes.tests.factories import CourseModeFactory -from enrollment import api +from enrollment import api, data from enrollment.errors import CourseEnrollmentError from enrollment.views import EnrollmentUserThrottle from openedx.core.djangoapps.content.course_overviews.models import CourseOverview @@ -1522,3 +1523,162 @@ class UserRoleTest(ModuleStoreTestCase): } response_data = json.loads(response.content) self.assertEqual(response_data, expected_response) + + +@ddt.ddt +@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class CourseEnrollmentsApiListTest(APITestCase, ModuleStoreTestCase): + """ + Test the course enrollments list API. + """ + shard = 3 + CREATED_DATA = datetime.datetime(2018, 1, 1, 0, 0, 1, tzinfo=pytz.UTC) + + def setUp(self): + super(CourseEnrollmentsApiListTest, self).setUp() + self.rate_limit_config = RateLimitConfiguration.current() + self.rate_limit_config.enabled = False + self.rate_limit_config.save() + + throttle = EnrollmentUserThrottle() + self.rate_limit, __ = throttle.parse_rate(throttle.rate) + + self.course = CourseFactory.create(org='e', number='d', run='X', emit_signals=True) + self.course2 = CourseFactory.create(org='x', number='y', run='Z', emit_signal=True) + + for mode_slug in ('honor', 'verified', 'audit'): + CourseModeFactory.create( + course_id=self.course.id, + mode_slug=mode_slug, + mode_display_name=mode_slug + ) + + self.staff_user = AdminFactory( + username='staff', + email='staff@example.com', + password='edx' + ) + + self.student1 = UserFactory( + username='student1', + email='student1@example.com', + password='edx' + ) + + self.student2 = UserFactory( + username='student2', + email='student2@example.com', + password='edx' + ) + + self.student3 = UserFactory( + username='student3', + email='student3@example.com', + password='edx' + ) + + with freeze_time(self.CREATED_DATA): + data.create_course_enrollment( + self.student1.username, + unicode(self.course.id), + 'honor', + True + ) + data.create_course_enrollment( + self.student2.username, + unicode(self.course.id), + 'honor', + True + ) + data.create_course_enrollment( + self.student3.username, + unicode(self.course2.id), + 'verified', + True + ) + data.create_course_enrollment( + self.student2.username, + unicode(self.course2.id), + 'honor', + True + ) + data.create_course_enrollment( + self.staff_user.username, + unicode(self.course2.id), + 'verified', + True + ) + self.url = reverse('courseenrollmentsapilist') + + def _login_as_staff(self): + self.client.login(username=self.staff_user.username, password='edx') + + def _make_request(self, query_params=None): + return self.client.get(self.url, query_params) + + def _assert_list_of_enrollments(self, query_params=None, expected_status=status.HTTP_200_OK, error_fields=None): + """ + Make a request to the CourseEnrolllmentApiList endpoint and run assertions on the response + using the optional parameters 'query_params', 'expected_status' and 'error_fields'. + """ + response = self._make_request(query_params) + self.assertEqual(response.status_code, expected_status) + content = json.loads(response.content) + if error_fields is not None: + self.assertIn('field_errors', content) + for error_field in error_fields: + self.assertIn(error_field, content['field_errors']) + return content + + def test_user_not_authenticated(self): + self.client.logout() + response = self.client.get(self.url, {'course_id': self.course.id}) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_user_not_authorized(self): + self.client.login(username=self.student1.username, password='edx') + response = self.client.get(self.url, {'course_id': self.course.id}) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + @ddt.data( + ({'course_id': '1'}, ['course_id', ]), + ({'course_id': '1', 'username': 'staff'}, ['course_id', ]), + ({'username': '1*2'}, ['username', ]), + ({'username': '1*2', 'course_id': 'org.0/course_0/Run_0'}, ['username', ]), + ({'username': '1*2', 'course_id': '1'}, ['username', 'course_id']), + ({'username': ','.join(str(x) for x in range(101))}, ['username', ]) + ) + @ddt.unpack + def test_query_string_parameters_invalid_errors(self, query_params, error_fields): + self._login_as_staff() + self._assert_list_of_enrollments(query_params, status.HTTP_400_BAD_REQUEST, error_fields) + + @ddt.data( + # Non-existent user + ({'username': 'nobody'}, ), + ({'username': 'nobody', 'course_id': 'e/d/X'}, ), + + # Non-existent course + ({'course_id': 'a/b/c'}, ), + ({'course_id': 'a/b/c', 'username': 'student1'}, ), + + # Non-existent course and user + ({'course_id': 'a/b/c', 'username': 'dummy'}, ) + ) + @ddt.unpack + def test_non_existent_course_user(self, query_params): + self._login_as_staff() + content = self._assert_list_of_enrollments(query_params, status.HTTP_200_OK) + self.assertEqual(len(content['results']), 0) + + @ddt.file_data('fixtures/course-enrollments-api-list-valid-data.json') + @ddt.unpack + def test_response_valid_queries(self, args): + query_params = args[0] + expected_results = args[1] + + self._login_as_staff() + content = self._assert_list_of_enrollments(query_params, status.HTTP_200_OK) + results = content['results'] + + self.assertItemsEqual(results, expected_results) diff --git a/common/djangoapps/enrollment/urls.py b/common/djangoapps/enrollment/urls.py index 4d465ae16d..06aec0b43d 100644 --- a/common/djangoapps/enrollment/urls.py +++ b/common/djangoapps/enrollment/urls.py @@ -5,7 +5,14 @@ URLs for the Enrollment API from django.conf import settings from django.conf.urls import url -from .views import EnrollmentCourseDetailView, EnrollmentListView, EnrollmentView, UnenrollmentView, EnrollmentUserRolesView +from .views import ( + CourseEnrollmentsApiListView, + EnrollmentCourseDetailView, + EnrollmentListView, + EnrollmentUserRolesView, + EnrollmentView, + UnenrollmentView, +) urlpatterns = [ url(r'^enrollment/{username},{course_key}$'.format( @@ -15,6 +22,7 @@ urlpatterns = [ url(r'^enrollment/{course_key}$'.format(course_key=settings.COURSE_ID_PATTERN), EnrollmentView.as_view(), name='courseenrollment'), url(r'^enrollment$', EnrollmentListView.as_view(), name='courseenrollments'), + url(r'^enrollments/?$', CourseEnrollmentsApiListView.as_view(), name='courseenrollmentsapilist'), url(r'^course/{course_key}$'.format(course_key=settings.COURSE_ID_PATTERN), EnrollmentCourseDetailView.as_view(), name='courseenrollmentdetails'), url(r'^unenroll/$', UnenrollmentView.as_view(), name='unenrollment'), diff --git a/common/djangoapps/enrollment/views.py b/common/djangoapps/enrollment/views.py index 646b42fb33..891d5a5874 100644 --- a/common/djangoapps/enrollment/views.py +++ b/common/djangoapps/enrollment/views.py @@ -6,12 +6,15 @@ consist primarily of authentication, request validation, and serialization. import logging from course_modes.models import CourseMode -from django.core.exceptions import ObjectDoesNotExist +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.utils.decorators import method_decorator from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from enrollment import api from enrollment.errors import CourseEnrollmentError, CourseEnrollmentExistsError, CourseModeNotFoundError +from enrollment.forms import CourseEnrollmentsApiListForm +from enrollment.paginators import CourseEnrollmentsApiListPagination +from enrollment.serializers import CourseEnrollmentsApiListSerializer from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -24,6 +27,7 @@ from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in from openedx.core.djangoapps.course_groups.cohorts import add_user_to_cohort, get_cohort_by_name, CourseUserGroup from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import ApiKeyHeaderPermission, ApiKeyHeaderPermissionIsAuthenticated +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.exceptions import CourseNotFoundError from openedx.core.lib.log_utils import audit_log from openedx.features.enterprise_support.api import ( @@ -33,12 +37,13 @@ from openedx.features.enterprise_support.api import ( enterprise_enabled ) from rest_framework import permissions, status +from rest_framework.generics import ListAPIView from rest_framework.response import Response from rest_framework.throttling import UserRateThrottle from rest_framework.views import APIView from six import text_type from student.auth import user_has_role -from student.models import User +from student.models import CourseEnrollment, User from student.roles import CourseStaffRole, GlobalStaff from util.disable_rate_limit import can_disable_rate_limit @@ -854,3 +859,98 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): actual_activation=current_enrollment['is_active'] if current_enrollment else None, user_id=user.id ) + + +@can_disable_rate_limit +class CourseEnrollmentsApiListView(DeveloperErrorViewMixin, ListAPIView): + """ + **Use Cases** + + Get a list of all course enrollments, optionally filtered by a course ID or list of usernames. + + **Example Requests** + + GET /api/enrollment/v1/enrollments + + GET /api/enrollment/v1/enrollments?course_id={course_id} + + GET /api/enrollment/v1/enrollments?username={username},{username},{username} + + GET /api/enrollment/v1/enrollments?course_id={course_id}&username={username} + + **Query Parameters for GET** + + * course_id: Filters the result to course enrollments for the course corresponding to the + given course ID. The value must be URL encoded. Optional. + + * username: List of comma-separated usernames. 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. + + **Response Values** + + If the request for information about the course enrollments is successful, an HTTP 200 "OK" response + is returned. + + The HTTP 200 response has the following values. + + * results: A list of the course enrollments matching the request. + + * created: Date and time when the course enrollment was created. + + * mode: Mode for the course enrollment. + + * is_active: Whether the course enrollment is active or not. + + * user: Username of the user in the course enrollment. + + * course_id: Course ID of the course in the course enrollment. + + * next: The URL to the next page of results, or null if this is the + last page. + + * previous: The URL to the next page of results, or null if this + is the first page. + + If the user is not logged in, a 401 error is returned. + + If the user is not global staff, a 403 error is returned. + + If the specified course_id is not valid or any of the specified usernames + are not valid, a 400 error is returned. + + If the specified course_id does not correspond to a valid course or if all the specified + usernames do not correspond to valid users, an HTTP 200 "OK" response is returned with an + empty 'results' field. + """ + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + permission_classes = (permissions.IsAdminUser, ) + throttle_classes = (EnrollmentUserThrottle, ) + serializer_class = CourseEnrollmentsApiListSerializer + pagination_class = CourseEnrollmentsApiListPagination + + def get_queryset(self): + """ + Get all the course enrollments for the given course_id and/or given list of usernames. + """ + form = CourseEnrollmentsApiListForm(self.request.query_params) + + if not form.is_valid(): + raise ValidationError(form.errors) + + queryset = CourseEnrollment.objects.all() + course_id = form.cleaned_data.get('course_id') + usernames = form.cleaned_data.get('username') + + if course_id: + queryset = queryset.filter(course_id=course_id) + if usernames: + queryset = queryset.filter(user__username__in=usernames) + return queryset