From 403fb52e9309c37d07f55def02e08445a2efe7ca Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Wed, 1 May 2019 16:23:55 -0400 Subject: [PATCH] Add a GET endpoint to list ProgramCourseEnrollment data. --- .../grades/api/v1/gradebook_views.py | 10 +- lms/djangoapps/grades/api/v1/utils.py | 44 +--- lms/djangoapps/grades/api/v1/views.py | 4 +- .../program_enrollments/api/v1/serializers.py | 25 ++- .../api/v1/tests/factories.py | 10 +- .../api/v1/tests/test_views.py | 190 ++++++++++++++++-- .../program_enrollments/api/v1/urls.py | 6 +- .../program_enrollments/api/v1/views.py | 121 ++++++++--- openedx/core/lib/api/view_utils.py | 43 ++++ 9 files changed, 352 insertions(+), 101 deletions(-) diff --git a/lms/djangoapps/grades/api/v1/gradebook_views.py b/lms/djangoapps/grades/api/v1/gradebook_views.py index c607da2cf0..c92ae67578 100644 --- a/lms/djangoapps/grades/api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/api/v1/gradebook_views.py @@ -20,8 +20,6 @@ from lms.djangoapps.grades.api.v1.utils import ( USER_MODEL, CourseEnrollmentPagination, GradeViewMixin, - get_course_key, - verify_course_exists ) from lms.djangoapps.grades.config.waffle import WRITABLE_GRADEBOOK, waffle_flags from lms.djangoapps.grades.constants import ScoreDatabaseTableEnum @@ -42,7 +40,13 @@ 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, PaginatedAPIView, view_auth_classes +from openedx.core.lib.api.view_utils import ( + DeveloperErrorViewMixin, + PaginatedAPIView, + get_course_key, + verify_course_exists, + 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 diff --git a/lms/djangoapps/grades/api/v1/utils.py b/lms/djangoapps/grades/api/v1/utils.py index 8c31583841..2ac9f10385 100644 --- a/lms/djangoapps/grades/api/v1/utils.py +++ b/lms/djangoapps/grades/api/v1/utils.py @@ -2,8 +2,8 @@ Define some view level utility functions here that multiple view modules will share """ from contextlib import contextmanager -from functools import wraps +from django.contrib.auth import get_user_model from rest_framework import status from rest_framework.exceptions import AuthenticationFailed from rest_framework.pagination import CursorPagination @@ -11,11 +11,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from six import text_type -from django.contrib.auth import get_user_model from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory -from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from student.models import CourseEnrollment from util.query import use_read_replica_if_available @@ -23,44 +19,6 @@ from util.query import use_read_replica_if_available USER_MODEL = get_user_model() -def get_course_key(request, course_id=None): - if not course_id: - return CourseKey.from_string(request.GET.get('course_id')) - return CourseKey.from_string(course_id) - - -def verify_course_exists(view_func): - """ - A decorator to wrap a view function that takes `course_key` as a parameter. - - Raises: - An API error if the `course_key` is invalid, or if no `CourseOverview` exists for the given key. - """ - @wraps(view_func) - def wrapped_function(self, request, **kwargs): - """ - Wraps the given view_function. - """ - try: - course_key = get_course_key(request, kwargs.get('course_id')) - except InvalidKeyError: - raise self.api_error( - status_code=status.HTTP_404_NOT_FOUND, - developer_message='The provided course key cannot be parsed.', - error_code='invalid_course_key' - ) - - if not CourseOverview.get_from_id_if_exists(course_key): - raise self.api_error( - status_code=status.HTTP_404_NOT_FOUND, - developer_message=u"Requested grade for unknown course {course}".format(course=text_type(course_key)), - error_code='course_does_not_exist' - ) - - return view_func(self, request, **kwargs) - return wrapped_function - - class CourseEnrollmentPagination(CursorPagination): """ Paginates over CourseEnrollment objects. diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index cd1b11faa8..b59eae9d31 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -14,14 +14,12 @@ from lms.djangoapps.grades.api.serializers import GradingPolicySerializer from lms.djangoapps.grades.api.v1.utils import ( CourseEnrollmentPagination, GradeViewMixin, - get_course_key, - verify_course_exists ) 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 openedx.core.lib.api.view_utils import PaginatedAPIView, get_course_key, verify_course_exists from xmodule.modulestore.django import modulestore log = logging.getLogger(__name__) diff --git a/lms/djangoapps/program_enrollments/api/v1/serializers.py b/lms/djangoapps/program_enrollments/api/v1/serializers.py index 331e556ddd..47512d69ec 100644 --- a/lms/djangoapps/program_enrollments/api/v1/serializers.py +++ b/lms/djangoapps/program_enrollments/api/v1/serializers.py @@ -2,8 +2,9 @@ API Serializers """ from rest_framework import serializers +from six import text_type -from lms.djangoapps.program_enrollments.models import ProgramEnrollment +from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment # pylint: disable=abstract-method @@ -68,3 +69,25 @@ class ProgramCourseEnrollmentRequestSerializer(serializers.Serializer, InvalidSt student_key = serializers.CharField(allow_blank=False) status = serializers.ChoiceField(allow_blank=False, choices=STATUS_CHOICES) + + +class ProgramCourseEnrollmentListSerializer(serializers.Serializer): + """ + Serializer for listing course enrollments in a program. + """ + student_key = serializers.SerializerMethodField() + status = serializers.CharField() + account_exists = serializers.SerializerMethodField() + curriculum_uuid = serializers.SerializerMethodField() + + class Meta(object): + model = ProgramCourseEnrollment + + def get_student_key(self, obj): + return obj.program_enrollment.external_user_key + + def get_account_exists(self, obj): + return bool(obj.program_enrollment.user) + + def get_curriculum_uuid(self, obj): + return text_type(obj.program_enrollment.curriculum_uuid) diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/factories.py b/lms/djangoapps/program_enrollments/api/v1/tests/factories.py index 0e77a17331..67540c347c 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/factories.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/factories.py @@ -8,7 +8,7 @@ from factory.django import DjangoModelFactory from opaque_keys.edx.keys import CourseKey from lms.djangoapps.program_enrollments import models -from student.tests.factories import UserFactory, CourseEnrollmentFactory +from student.tests.factories import CourseEnrollmentFactory, UserFactory class ProgramEnrollmentFactory(DjangoModelFactory): @@ -23,14 +23,12 @@ class ProgramEnrollmentFactory(DjangoModelFactory): status = 'enrolled' -class ProgramCourseEnrollmentFactory(factory.DjangoModelFactory): - """ - Factory for ProgramCourseEnrollment models - """ +class ProgramCourseEnrollmentFactory(DjangoModelFactory): + """ A factory for the ProgramCourseEnrollment model. """ class Meta(object): model = models.ProgramCourseEnrollment program_enrollment = factory.SubFactory(ProgramEnrollmentFactory) course_enrollment = factory.SubFactory(CourseEnrollmentFactory) course_key = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") - status = "active" + status = 'active' diff --git a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py index bd993aa5c0..d55e60b8e1 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -2,12 +2,13 @@ Unit tests for ProgramEnrollment views. """ from __future__ import unicode_literals + from uuid import uuid4 -import mock import ddt from django.core.cache import cache from django.urls import reverse +import mock from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.test import APITestCase @@ -15,37 +16,61 @@ from six import text_type from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory from lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses as CourseStatuses -from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment -from student.tests.factories import UserFactory +from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment +from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL from openedx.core.djangoapps.catalog.tests.factories import ( CourseFactory, OrganizationFactory as CatalogOrganizationFactory, ProgramFactory, ) -from openedx.core.djangolib.testing.utils import CacheIsolationMixin from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -from openedx.core.djangoapps.catalog.cache import PROGRAM_CACHE_KEY_TPL -from .factories import ProgramEnrollmentFactory, ProgramCourseEnrollmentFactory +from openedx.core.djangolib.testing.utils import CacheIsolationMixin +from student.tests.factories import UserFactory + +from .factories import ProgramCourseEnrollmentFactory, ProgramEnrollmentFactory -class ProgramEnrollmentListTest(APITestCase): +class ListViewTestMixin(object): """ - Tests for GET calls to the Program Enrollments API. + Mixin to define some shared test data objects for program/course enrollment + list view tests. """ + view_name = None + @classmethod def setUpClass(cls): - super(ProgramEnrollmentListTest, cls).setUpClass() + super(ListViewTestMixin, cls).setUpClass() cls.program_uuid = '00000000-1111-2222-3333-444444444444' cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444' + cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444' + + cls.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course') + _ = CourseOverviewFactory.create(id=cls.course_id) + 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() + super(ListViewTestMixin, cls).tearDownClass() - def create_enrollments(self): + def get_url(self, program_uuid=None, course_id=None): + """ Returns the primary URL requested by the test case. """ + kwargs = {'program_uuid': program_uuid or self.program_uuid} + if course_id: + kwargs['course_id'] = course_id or self.course_id + + return reverse(self.view_name, kwargs=kwargs) + + +class ProgramEnrollmentListTest(ListViewTestMixin, APITestCase): + """ + Tests for GET calls to the Program Enrollments API. + """ + view_name = 'programs_api:v1:program_enrollments' + + def create_program_enrollments(self): """ Helper method for creating program enrollment records. """ @@ -65,17 +90,14 @@ class ProgramEnrollmentListTest(APITestCase): program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key=user_key, ) - self.addCleanup(self.destroy_enrollments) + self.addCleanup(self.destroy_program_enrollments) - def destroy_enrollments(self): + def destroy_program_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) @@ -109,7 +131,7 @@ class ProgramEnrollmentListTest(APITestCase): def test_200_many_results(self): self.client.login(username=self.global_staff.username, password=self.password) - self.create_enrollments() + self.create_program_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)) @@ -141,7 +163,7 @@ class ProgramEnrollmentListTest(APITestCase): def test_200_many_pages(self): self.client.login(username=self.global_staff.username, password=self.password) - self.create_enrollments() + self.create_program_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) @@ -404,3 +426,135 @@ class CourseEnrollmentPostTests(APITestCase, ProgramCacheTestCaseMixin): response = self.client.post(self.default_url, post_data, format="json") self.assertEqual(response.status_code, 422) self.assertIn('invalid enrollment record', response.data) + + +class ProgramCourseEnrollmentListTest(ListViewTestMixin, APITestCase): + """ + Tests for GET calls to the Program Course Enrollments API. + """ + view_name = 'programs_api:v1:program_course_enrollments' + + def create_course_enrollments(self): + """ Helper method for creating ProgramCourseEnrollments. """ + program_enrollment_1 = ProgramEnrollmentFactory.create( + program_uuid=self.program_uuid, curriculum_uuid=self.curriculum_uuid, external_user_key='user-0', + ) + program_enrollment_2 = ProgramEnrollmentFactory.create( + program_uuid=self.program_uuid, curriculum_uuid=self.other_curriculum_uuid, external_user_key='user-0', + ) + ProgramCourseEnrollmentFactory.create( + program_enrollment=program_enrollment_1, + course_key=self.course_id, + status='active', + ) + ProgramCourseEnrollmentFactory.create( + program_enrollment=program_enrollment_2, + course_key=self.course_id, + status='inactive', + ) + + self.addCleanup(self.destroy_course_enrollments) + + def destroy_course_enrollments(self): + """ Helper method for tearing down ProgramCourseEnrollments. """ + ProgramCourseEnrollment.objects.filter( + program_enrollment__program_uuid=self.program_uuid, + course_key=self.course_id + ).delete() + + @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, self.course_id)) + assert status.HTTP_404_NOT_FOUND == response.status_code + mock_get_programs.assert_called_once_with(uuid=self.program_uuid) + + def test_404_if_course_does_not_exist(self): + other_course_key = CourseKey.from_string('course-v1:edX+ToyX+Other_Course') + self.client.login(username=self.global_staff.username, password=self.password) + response = self.client.get(self.get_url(self.program_uuid, other_course_key)) + assert status.HTTP_404_NOT_FOUND == response.status_code + + 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, self.course_id)) + assert status.HTTP_403_FORBIDDEN == response.status_code + + def test_401_if_anonymous(self): + response = self.client.get(self.get_url(self.program_uuid, self.course_id)) + 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, self.course_id)) + + 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_course_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, self.course_id)) + + assert status.HTTP_200_OK == response.status_code + expected = { + 'next': None, + 'previous': None, + 'results': [ + { + 'student_key': 'user-0', 'status': 'active', 'account_exists': True, + 'curriculum_uuid': text_type(self.curriculum_uuid), + }, + { + 'student_key': 'user-0', 'status': 'inactive', 'account_exists': True, + 'curriculum_uuid': text_type(self.other_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_course_enrollments() + with mock.patch('lms.djangoapps.program_enrollments.api.v1.views.get_programs', autospec=True): + url = self.get_url(self.program_uuid, self.course_id) + '?page_size=1' + response = self.client.get(url) + + assert status.HTTP_200_OK == response.status_code + expected_results = [ + { + 'student_key': 'user-0', 'status': 'active', 'account_exists': True, + '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, self.course_id) 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-0', 'status': 'inactive', 'account_exists': True, + 'curriculum_uuid': text_type(self.other_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, self.course_id) in next_response.data['previous'] + assert '?cursor=' in next_response.data['previous'] diff --git a/lms/djangoapps/program_enrollments/api/v1/urls.py b/lms/djangoapps/program_enrollments/api/v1/urls.py index a4ada97693..6a5532ed13 100644 --- a/lms/djangoapps/program_enrollments/api/v1/urls.py +++ b/lms/djangoapps/program_enrollments/api/v1/urls.py @@ -2,7 +2,7 @@ from django.conf.urls import url from lms.djangoapps.program_enrollments.api.v1.constants import PROGRAM_UUID_PATTERN -from lms.djangoapps.program_enrollments.api.v1.views import ProgramEnrollmentsView, ProgramCourseEnrollmentsView +from lms.djangoapps.program_enrollments.api.v1.views import ProgramCourseEnrollmentsView, ProgramEnrollmentsView from openedx.core.constants import COURSE_ID_PATTERN @@ -10,12 +10,12 @@ app_name = 'lms.djangoapps.program_enrollments' urlpatterns = [ url( - r'^programs/{program_key}/enrollments/$'.format(program_key=r'(?P[0-9a-fA-F-]+)'), + r'^programs/{program_uuid}/enrollments/$'.format(program_uuid=PROGRAM_UUID_PATTERN), ProgramEnrollmentsView.as_view(), name='program_enrollments' ), url( - r'^programs/{program_uuid}/course/{course_id}/enrollments/'.format( + r'^programs/{program_uuid}/courses/{course_id}/enrollments/'.format( program_uuid=PROGRAM_UUID_PATTERN, course_id=COURSE_ID_PATTERN ), diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index 0036e48e6e..e4ff306e65 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -3,35 +3,37 @@ ProgramEnrollment Views """ from __future__ import unicode_literals + from functools import wraps from django.http import Http404 +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 opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import ValidationError from rest_framework.pagination import CursorPagination from rest_framework.response import Response -from rest_framework.views import APIView -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 lms.djangoapps.program_enrollments.api.v1.constants import CourseEnrollmentResponseStatuses, MAX_ENROLLMENT_RECORDS from lms.djangoapps.program_enrollments.api.v1.serializers import ( - ProgramEnrollmentListSerializer, + ProgramCourseEnrollmentListSerializer, ProgramCourseEnrollmentRequestSerializer, + ProgramEnrollmentListSerializer, ) -from lms.djangoapps.program_enrollments.models import ProgramEnrollment, ProgramCourseEnrollment +from lms.djangoapps.program_enrollments.models import ProgramCourseEnrollment, ProgramEnrollment from openedx.core.djangoapps.catalog.utils import get_programs from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser -from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, PaginatedAPIView, verify_course_exists +from util.query import use_read_replica_if_available def verify_program_exists(view_func): """ Raises: - An API error if the `program_key` kwarg in the wrapped function + An API error if the `program_uuid` kwarg in the wrapped function does not exist in the catalog programs cache. """ @wraps(view_func) @@ -39,7 +41,7 @@ def verify_program_exists(view_func): """ Wraps the given view_function. """ - program_uuid = kwargs['program_key'] + program_uuid = kwargs['program_uuid'] program = get_programs(uuid=program_uuid) if not program: raise self.api_error( @@ -76,7 +78,7 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): """ A view for Create/Read/Update methods on Program Enrollment data. - Path: `/api/program_enrollments/v1/programs/{program_key}/enrollments/` + Path: `/api/program_enrollments/v1/programs/{program_uuid}/enrollments/` The path can contain an optional `page_size?=N` query parameter. The default page size is 100. Returns: @@ -97,7 +99,7 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): Example: { "next": null, - "previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_key}/enrollments/?curor=abcd", + "previous": "http://testserver.com/api/program_enrollments/v1/programs/{program_uuid}/enrollments/?curor=abcd", "results": [ { "student_key": "user-0", "status": "pending", @@ -128,8 +130,11 @@ class ProgramEnrollmentsView(DeveloperErrorViewMixin, PaginatedAPIView): pagination_class = ProgramEnrollmentPagination @verify_program_exists - def get(self, request, program_key=None): - enrollments = ProgramEnrollment.objects.filter(program_uuid=program_key) + def get(self, request, program_uuid=None): + """ Defines the GET list endpoint for ProgramEnrollment objects. """ + enrollments = use_read_replica_if_available( + ProgramEnrollment.objects.filter(program_uuid=program_uuid) + ) paginated_enrollments = self.paginate_queryset(enrollments) serializer = ProgramEnrollmentListSerializer(paginated_enrollments, many=True) return self.get_paginated_response(serializer.data) @@ -188,27 +193,79 @@ class ProgramCourseRunSpecificViewMixin(ProgramSpecificViewMixin): return CourseKey.from_string(self.kwargs['course_id']) -class ProgramCourseEnrollmentsView(ProgramCourseRunSpecificViewMixin, APIView): +# pylint: disable=line-too-long +class ProgramCourseEnrollmentsView(DeveloperErrorViewMixin, ProgramCourseRunSpecificViewMixin, PaginatedAPIView): """ A view for enrolling students in a course through a program, modifying program course enrollments, and listing program course - enrollments + enrollments. - Path: /api/v1/programs/{program_uuid}/courses/{course_id}/enrollments + Path: ``/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/enrollments/`` - Accepts: [POST] + Accepts: [GET, POST] + + For GET requests, the path can contain an optional `page_size?=N` query parameter. + The default page size is 100. ------------------------------------------------------------------------------------ POST ------------------------------------------------------------------------------------ - Returns: - * 200: Returns a map of students and their enrollment status. - * 207: Not all students enrolled. Returns resulting enrollment status. - * 401: User is not authenticated - * 403: User lacks read access organization of specified program. - * 404: Program does not exist, or course does not exist in program - * 422: Invalid request, unable to enroll students. + **Returns** + + * 200: Returns a map of students and their enrollment status. + * 207: Not all students enrolled. Returns resulting enrollment status. + * 401: User is not authenticated + * 403: User lacks read access organization of specified program. + * 404: Program does not exist, or course does not exist in program + * 422: Invalid request, unable to enroll students. + + ------------------------------------------------------------------------------------ + GET + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a paginated set of program course enrollment data. + * 401: The requesting user is not authenticated. + * 403: The requesting user lacks access for the given program/course. + * 404: The requested program or course 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 course enrollment records, where each record contains the following keys: + * student_key: The identifier of the student enrolled in the program and course. + * status: The student's course 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_uuid}/courses/{course_id}/enrollments/?curor=abcd", + "results": [ + { + "student_key": "user-0", "status": "inactive", + "account_exists": False, "curriculum_uuid": "00000000-1111-2222-3333-444444444444" + }, + { + "student_key": "user-1", "status": "inactive", + "account_exists": False, "curriculum_uuid": "00000001-1111-2222-3333-444444444444" + }, + { + "student_key": "user-2", "status": "active", + "account_exists": True, "curriculum_uuid": "00000002-1111-2222-3333-444444444444" + }, + { + "student_key": "user-3", "status": "active", + "account_exists": True, "curriculum_uuid": "00000003-1111-2222-3333-444444444444" + }, + ], + } + """ authentication_classes = ( JwtAuthentication, @@ -218,6 +275,22 @@ class ProgramCourseEnrollmentsView(ProgramCourseRunSpecificViewMixin, APIView): permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,) pagination_class = ProgramEnrollmentPagination + @verify_course_exists + @verify_program_exists + def get(self, request, program_uuid=None, course_id=None): + """ Defines the GET list endpoint for ProgramCourseEnrollment objects. """ + course_key = CourseKey.from_string(course_id) + enrollments = use_read_replica_if_available( + ProgramCourseEnrollment.objects.filter( + program_enrollment__program_uuid=program_uuid, course_key=course_key + ).select_related( + 'program_enrollment' + ) + ) + paginated_enrollments = self.paginate_queryset(enrollments) + serializer = ProgramCourseEnrollmentListSerializer(paginated_enrollments, many=True) + return self.get_paginated_response(serializer.data) + def post(self, request, program_uuid=None, course_id=None): """ Enroll a list of students in a course in a program diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 3ead968677..d3adad6417 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -3,11 +3,15 @@ Utilities related to API views """ from __future__ import absolute_import from collections import Sequence +from functools import wraps + from django.core.exceptions import NON_FIELD_ERRORS, ObjectDoesNotExist, ValidationError from django.http import Http404 from django.utils.translation import ugettext as _ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.exceptions import APIException from rest_framework.generics import GenericAPIView @@ -18,6 +22,7 @@ from rest_framework.response import Response from rest_framework.views import APIView from six import text_type, iteritems +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.permissions import IsUserInUrl @@ -338,3 +343,41 @@ class PaginatedAPIView(APIView): """ assert self.paginator is not None return self.paginator.get_paginated_response(data) + + +def get_course_key(request, course_id=None): + if not course_id: + return CourseKey.from_string(request.GET.get('course_id')) + return CourseKey.from_string(course_id) + + +def verify_course_exists(view_func): + """ + A decorator to wrap a view function that takes `course_key` as a parameter. + + Raises: + An API error if the `course_key` is invalid, or if no `CourseOverview` exists for the given key. + """ + @wraps(view_func) + def wrapped_function(self, request, **kwargs): + """ + Wraps the given view_function. + """ + try: + course_key = get_course_key(request, kwargs.get('course_id')) + except InvalidKeyError: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='The provided course key cannot be parsed.', + error_code='invalid_course_key' + ) + + if not CourseOverview.get_from_id_if_exists(course_key): + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message=u"Requested grade for unknown course {course}".format(course=text_type(course_key)), + error_code='course_does_not_exist' + ) + + return view_func(self, request, **kwargs) + return wrapped_function