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:
David Ormsbee
2019-01-09 10:52:36 -05:00
committed by GitHub
7 changed files with 505 additions and 4 deletions

View 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

View 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

View File

@@ -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

View 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"
}
]
]
]

View File

@@ -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)

View File

@@ -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'),

View File

@@ -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