Add a GET API endpoint for program enrollments.

This commit is contained in:
Alex Dusenbery
2019-04-29 13:00:26 -04:00
committed by Alex Dusenbery
parent c8f8e05616
commit 20f0bc03d4
18 changed files with 485 additions and 47 deletions

View File

@@ -20,7 +20,6 @@ from lms.djangoapps.grades.api.v1.utils import (
USER_MODEL,
CourseEnrollmentPagination,
GradeViewMixin,
PaginatedAPIView,
get_course_key,
verify_course_exists
)
@@ -43,7 +42,7 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey, UsageKey
from openedx.core.djangoapps.course_groups import cohorts
from openedx.core.djangoapps.util.forms import to_bool
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, view_auth_classes
from openedx.core.lib.cache_utils import request_cached
from student.auth import has_course_author_access
from student.models import CourseEnrollment

View File

@@ -82,39 +82,6 @@ class CourseEnrollmentPagination(CursorPagination):
return self.page_size
class PaginatedAPIView(APIView):
"""
An `APIView` class enhanced with the pagination methods of `GenericAPIView`.
"""
# pylint: disable=attribute-defined-outside-init
@property
def paginator(self):
"""
The paginator instance associated with the view, or `None`.
"""
if not hasattr(self, '_paginator'):
if self.pagination_class is None:
self._paginator = None
else:
self._paginator = self.pagination_class()
return self._paginator
def paginate_queryset(self, queryset):
"""
Return a single page of results, or `None` if pagination is disabled.
"""
if self.paginator is None:
return None
return self.paginator.paginate_queryset(queryset, self.request, view=self)
def get_paginated_response(self, data):
"""
Return a paginated style `Response` object for the given output data.
"""
assert self.paginator is not None
return self.paginator.get_paginated_response(data)
class GradeViewMixin(DeveloperErrorViewMixin):
"""
Mixin class for Grades related views.

View File

@@ -14,7 +14,6 @@ from lms.djangoapps.grades.api.serializers import GradingPolicySerializer
from lms.djangoapps.grades.api.v1.utils import (
CourseEnrollmentPagination,
GradeViewMixin,
PaginatedAPIView,
get_course_key,
verify_course_exists
)
@@ -22,6 +21,7 @@ from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from lms.djangoapps.grades.models import PersistentCourseGrade
from opaque_keys import InvalidKeyError
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import PaginatedAPIView
from xmodule.modulestore.django import modulestore
log = logging.getLogger(__name__)

View File

@@ -0,0 +1,12 @@
"""
Grades API URLs.
"""
from django.conf.urls import include, url
app_name = 'lms.djangoapps.program_enrollments'
urlpatterns = [
url(r'^v1/', include('program_enrollments.api.v1.urls', namespace='v1'))
]

View File

@@ -0,0 +1,42 @@
"""
API Serializers
"""
from rest_framework import serializers
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
# pylint: disable=abstract-method
class ProgramEnrollmentSerializer(serializers.ModelSerializer):
"""
Serializer for Program Enrollments
"""
class Meta(object):
model = ProgramEnrollment
fields = ('user', 'external_user_key', 'program_uuid', 'curriculum_uuid', 'status')
validators = []
def validate(self, attrs):
enrollment = ProgramEnrollment(**attrs)
enrollment.full_clean()
return attrs
def create(self, validated_data):
return ProgramEnrollment.objects.create(**validated_data)
class ProgramEnrollmentListSerializer(serializers.Serializer):
"""
Serializer for listing enrollments in a program.
"""
student_key = serializers.CharField(source='external_user_key')
status = serializers.CharField()
account_exists = serializers.SerializerMethodField()
curriculum_uuid = serializers.UUIDField()
class Meta(object):
model = ProgramEnrollment
def get_account_exists(self, obj):
return bool(obj.user)

View File

@@ -0,0 +1,22 @@
"""
Factories for Program Enrollment tests.
"""
from uuid import uuid4
import factory
from factory.django import DjangoModelFactory
from lms.djangoapps.program_enrollments import models
from student.tests.factories import UserFactory
class ProgramEnrollmentFactory(DjangoModelFactory):
""" A Factory for the ProgramEnrollment model. """
class Meta(object):
model = models.ProgramEnrollment
user = factory.SubFactory(UserFactory)
external_user_key = None
program_uuid = uuid4()
curriculum_uuid = uuid4()
status = 'enrolled'

View File

@@ -0,0 +1,40 @@
"""
Unit tests for ProgramEnrollment serializers.
"""
from __future__ import unicode_literals
from uuid import uuid4
from django.test import TestCase
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from lms.djangoapps.program_enrollments.api.v1.serializers import ProgramEnrollmentSerializer
from student.tests.factories import UserFactory
class ProgramEnrollmentSerializerTests(TestCase):
"""
Tests for the ProgramEnrollment serializer.
"""
def setUp(self):
"""
Set up the test data used in the specific tests
"""
super(ProgramEnrollmentSerializerTests, self).setUp()
self.user = UserFactory.create()
self.enrollment = ProgramEnrollment.objects.create(
user=self.user,
external_user_key='abc',
program_uuid=uuid4(),
curriculum_uuid=uuid4(),
status='enrolled'
)
self.serializer = ProgramEnrollmentSerializer(instance=self.enrollment)
def test_serializer_contains_expected_fields(self):
data = self.serializer.data
self.assertEqual(
set(data.keys()),
{'user', 'external_user_key', 'program_uuid', 'curriculum_uuid', 'status'}
)

View File

@@ -0,0 +1,173 @@
"""
Unit tests for ProgramEnrollment views.
"""
from __future__ import unicode_literals
import mock
from django.urls import reverse
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from student.tests.factories import UserFactory
from .factories import ProgramEnrollmentFactory
class ProgramEnrollmentListTest(APITestCase):
"""
Tests for GET calls to the Program Enrollments API.
"""
@classmethod
def setUpClass(cls):
super(ProgramEnrollmentListTest, cls).setUpClass()
cls.program_uuid = '00000000-1111-2222-3333-444444444444'
cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444'
cls.password = 'password'
cls.student = UserFactory.create(username='student', password=cls.password)
cls.global_staff = GlobalStaffFactory.create(username='global-staff', password=cls.password)
@classmethod
def tearDownClass(cls):
super(ProgramEnrollmentListTest, cls).tearDownClass()
def create_enrollments(self):
"""
Helper method for creating program enrollment records.
"""
for i in xrange(2):
user_key = 'user-{}'.format(i)
ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid,
curriculum_uuid=self.curriculum_uuid,
user=None,
status='pending',
external_user_key=user_key,
)
for i in xrange(2, 4):
user_key = 'user-{}'.format(i)
ProgramEnrollmentFactory.create(
program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key=user_key,
)
self.addCleanup(self.destroy_enrollments)
def destroy_enrollments(self):
"""
Deletes program enrollments associated with this test case's program_uuid.
"""
ProgramEnrollment.objects.filter(program_uuid=self.program_uuid).delete()
def get_url(self, program_key=None):
return reverse('programs_api:v1:program_enrollments', kwargs={'program_key': program_key})
@mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True, return_value=None)
def test_404_if_no_program_with_key(self, mock_get_programs):
self.client.login(username=self.global_staff.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_404_NOT_FOUND == response.status_code
mock_get_programs.assert_called_once_with(uuid=self.program_uuid)
def test_403_if_not_staff(self):
self.client.login(username=self.student.username, password=self.password)
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_403_FORBIDDEN == response.status_code
def test_401_if_anonymous(self):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_401_UNAUTHORIZED == response.status_code
def test_200_empty_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [],
}
assert expected == response.data
def test_200_many_results(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
response = self.client.get(self.get_url(self.program_uuid))
assert status.HTTP_200_OK == response.status_code
expected = {
'next': None,
'previous': None,
'results': [
{
'student_key': 'user-0', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-1', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
],
}
assert expected == response.data
def test_200_many_pages(self):
self.client.login(username=self.global_staff.username, password=self.password)
self.create_enrollments()
with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True):
url = self.get_url(self.program_uuid) + '?page_size=2'
response = self.client.get(url)
assert status.HTTP_200_OK == response.status_code
expected_results = [
{
'student_key': 'user-0', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-1', 'status': 'pending', 'account_exists': False,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
]
assert expected_results == response.data['results']
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert response.data['next'] is not None
assert self.get_url(self.program_uuid) in response.data['next']
assert '?cursor=' in response.data['next']
assert response.data['previous'] is None
next_response = self.client.get(response.data['next'])
assert status.HTTP_200_OK == next_response.status_code
next_expected_results = [
{
'student_key': 'user-2', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
{
'student_key': 'user-3', 'status': 'enrolled', 'account_exists': True,
'curriculum_uuid': text_type(self.curriculum_uuid),
},
]
assert next_expected_results == next_response.data['results']
assert next_response.data['next'] is None
# there's going to be a 'cursor' query param, but we have no way of knowing it's value
assert next_response.data['previous'] is not None
assert self.get_url(self.program_uuid) in next_response.data['previous']
assert '?cursor=' in next_response.data['previous']

View File

@@ -0,0 +1,15 @@
""" Program Enrollments API v1 URLs. """
from django.conf.urls import url
from lms.djangoapps.program_enrollments.api.v1.views import ProgramEnrollmentsView
app_name = 'lms.djangoapps.program_enrollments'
urlpatterns = [
url(
r'^programs/{program_key}/enrollments/$'.format(program_key=r'(?P<program_key>[0-9a-fA-F-]+)'),
ProgramEnrollmentsView.as_view(),
name='program_enrollments'
),
]

View File

@@ -0,0 +1,126 @@
# -*- coding: utf-8 -*-
"""
ProgramEnrollment Views
"""
from __future__ import unicode_literals
from functools import wraps
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
from rest_framework import status
from rest_framework.pagination import CursorPagination
from lms.djangoapps.program_enrollments.api.v1.serializers import ProgramEnrollmentListSerializer
from lms.djangoapps.program_enrollments.models import ProgramEnrollment
from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView
def verify_program_exists(view_func):
"""
Raises:
An API error if the `program_key` kwarg in the wrapped function
does not exist in the catalog programs cache.
"""
@wraps(view_func)
def wrapped_function(self, request, **kwargs):
"""
Wraps the given view_function.
"""
program_uuid = kwargs['program_key']
program = get_programs(uuid=program_uuid)
if not program:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='no program exists with given key',
error_code='program_does_not_exist'
)
return view_func(self, request, **kwargs)
return wrapped_function
class ProgramEnrollmentPagination(CursorPagination):
"""
Pagination class for Program Enrollments.
"""
ordering = 'id'
page_size = 100
page_size_query_param = 'page_size'
def get_page_size(self, request):
"""
Get the page size based on the defined page size parameter if defined.
"""
try:
page_size_string = request.query_params[self.page_size_query_param]
return int(page_size_string)
except (KeyError, ValueError):
pass
return self.page_size
class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView):
"""
A view for Create/Read/Update methods on Program Enrollment data.
Path: `/api/program_enrollments/v1/programs/{program_key}/enrollments/`
The path can contain an optional `page_size?=N` query parameter. The default page size is 100.
Returns:
* 200: OK - Contains a paginated set of program enrollment data.
* 401: The requesting user is not authenticated.
* 403: The requesting user lacks access for the given program.
* 404: The requested program does not exist.
Response:
In the case of a 200 response code, the response will include a paginated
data set. The `results` section of the response consists of a list of
program enrollment records, where each record contains the following keys:
* student_key: The identifier of the student enrolled in the program.
* status: The student's enrollment status.
* account_exists: A boolean indicating if the student has created an edx-platform user account.
* curriculum_uuid: The curriculum UUID of the enrollment record for the (student, program).
Example:
{
"next": null,
"previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_key}/enrollments/?curor=abcd",
"results": [
{
"student_key": "user-0", "status": "pending",
"account_exists": False, "curriculum_uuid": "00000000-1111-2222-3333-444444444444"
},
{
"student_key": "user-1", "status": "pending",
"account_exists": False, "curriculum_uuid": "00000001-1111-2222-3333-444444444444"
},
{
"student_key": "user-2", "status": "enrolled",
"account_exists": True, "curriculum_uuid": "00000002-1111-2222-3333-444444444444"
},
{
"student_key": "user-3", "status": "enrolled",
"account_exists": True, "curriculum_uuid": "00000003-1111-2222-3333-444444444444"
},
],
}
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
pagination_class = ProgramEnrollmentPagination
@verify_program_exists
def get(self, request, program_key=None):
enrollments = ProgramEnrollment.objects.filter(program_uuid=program_key)
paginated_enrollments = self.paginate_queryset(enrollments)
serializer = ProgramEnrollmentListSerializer(paginated_enrollments, many=True)
return self.get_paginated_response(serializer.data)

View File

@@ -6,6 +6,8 @@ from __future__ import unicode_literals
from django.apps import AppConfig
from openedx.core.djangoapps.plugins.constants import ProjectType, PluginURLs
class ProgramEnrollmentsConfig(AppConfig):
"""
@@ -14,5 +16,11 @@ class ProgramEnrollmentsConfig(AppConfig):
name = 'lms.djangoapps.program_enrollments'
plugin_app = {
'url_config': {},
PluginURLs.CONFIG: {
ProjectType.LMS: {
PluginURLs.NAMESPACE: 'programs_api',
PluginURLs.REGEX: 'api/program_enrollments/',
PluginURLs.RELATIVE_PATH: 'api.urls',
}
},
}

View File

@@ -5,7 +5,9 @@ Django model specifications for the Program Enrollments API
from __future__ import unicode_literals
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.translation import ugettext_lazy as _
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField
from simple_history.models import HistoricalRecords
@@ -29,6 +31,7 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
class Meta(object):
app_label = "program_enrollments"
unique_together = ('external_user_key', 'program_uuid', 'curriculum_uuid')
# A student enrolled in a given (program, curriculum) should always
# have a non-null ``user`` or ``external_user_key`` field (or both).
@@ -51,6 +54,10 @@ class ProgramEnrollment(TimeStampedModel): # pylint: disable=model-missing-unic
status = models.CharField(max_length=9, choices=STATUSES)
historical_records = HistoricalRecords()
def clean(self):
if not (self.user or self.external_user_key):
raise ValidationError(_('One of user or external_user_key must not be null.'))
@classmethod
def retire_user(cls, user_id):
"""

View File

@@ -21,10 +21,12 @@ class ProgramEnrollmentModelTests(TestCase):
"""
super(ProgramEnrollmentModelTests, self).setUp()
self.user = UserFactory.create()
self.program_uuid = uuid4()
self.other_program_uuid = uuid4()
self.enrollment = ProgramEnrollment.objects.create(
user=self.user,
external_user_key='abc',
program_uuid=uuid4(),
program_uuid=self.program_uuid,
curriculum_uuid=uuid4(),
status='enrolled'
)

View File

@@ -1,9 +0,0 @@
# -*- coding: utf-8 -*-
"""
ProgramEnrollment Views
"""
from __future__ import unicode_literals
# from django.shortcuts import render
# Create your views here.

View File

@@ -15,6 +15,7 @@ from rest_framework.mixins import RetrieveModelMixin, UpdateModelMixin
from rest_framework.permissions import IsAuthenticated
from rest_framework.request import clone_request
from rest_framework.response import Response
from rest_framework.views import APIView
from six import text_type, iteritems
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
@@ -304,3 +305,36 @@ class LazySequence(Sequence):
self.iterable,
self.est_len,
)
class PaginatedAPIView(APIView):
"""
An `APIView` class enhanced with the pagination methods of `GenericAPIView`.
"""
# pylint: disable=attribute-defined-outside-init
@property
def paginator(self):
"""
The paginator instance associated with the view, or `None`.
"""
if not hasattr(self, '_paginator'):
if self.pagination_class is None:
self._paginator = None
else:
self._paginator = self.pagination_class()
return self._paginator
def paginate_queryset(self, queryset):
"""
Return a single page of results, or `None` if pagination is disabled.
"""
if self.paginator is None:
return None
return self.paginator.paginate_queryset(queryset, self.request, view=self)
def get_paginated_response(self, data):
"""
Return a paginated style `Response` object for the given output data.
"""
assert self.paginator is not None
return self.paginator.get_paginated_response(data)