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