Create a staff-only API to list all course enrollments

This data can be used by external frontends or other apps. The list
of course enrollments can optionally be filtered by course id or
usernames.
This commit is contained in:
Guruprasad Lakshmi Narayanan
2018-08-27 09:45:17 +05:30
parent a86d499592
commit df45fe8317
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