Merge pull request #18838 from open-craft/guruprasad/api-list-course-enrollments
OC-5087 Create a staff-only API to list all course enrollments
This commit is contained in:
50
common/djangoapps/enrollment/forms.py
Normal file
50
common/djangoapps/enrollment/forms.py
Normal file
@@ -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
|
||||
11
common/djangoapps/enrollment/paginators.py
Normal file
11
common/djangoapps/enrollment/paginators.py
Normal file
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
157
common/djangoapps/enrollment/tests/fixtures/course-enrollments-api-list-valid-data.json
vendored
Normal file
157
common/djangoapps/enrollment/tests/fixtures/course-enrollments-api-list-valid-data.json
vendored
Normal file
@@ -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"
|
||||
}
|
||||
]
|
||||
]
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user