From fff69a9f5800c06040e0d7d87e30490b47aabdf4 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Fri, 2 Aug 2019 17:06:35 -0400 Subject: [PATCH] Add course grade listing API for program_enrollments app This new API endpoint differs from the existing Grades API in that (i) it only includes grades for users enrolled with a ProgramCourseEnrollment and (ii) it alters its return code depending on whether any student's grade failed to load. EDUCATOR-4529 --- lms/djangoapps/grades/rest_api/v1/utils.py | 6 +- .../program_enrollments/api/v1/serializers.py | 57 ++++ .../api/v1/tests/test_views.py | 300 +++++++++++++----- .../program_enrollments/api/v1/urls.py | 9 + .../program_enrollments/api/v1/views.py | 216 +++++++++++-- 5 files changed, 481 insertions(+), 107 deletions(-) diff --git a/lms/djangoapps/grades/rest_api/v1/utils.py b/lms/djangoapps/grades/rest_api/v1/utils.py index c162f4bf84..63836bef33 100644 --- a/lms/djangoapps/grades/rest_api/v1/utils.py +++ b/lms/djangoapps/grades/rest_api/v1/utils.py @@ -40,15 +40,17 @@ class CourseEnrollmentPagination(CursorPagination): return self.page_size - def get_paginated_response(self, data, **kwargs): # pylint: disable=arguments-differ + def get_paginated_response(self, data, status_code=200, **kwargs): # pylint: disable=arguments-differ """ - Return a response given serialized page data and kwargs. Each key-value pair is added to the response. + Return a response given serialized page data, optional status_code (defaults to 200), + and kwargs. Each key-value pair of kwargs is added to the response data. """ resp = super(CourseEnrollmentPagination, self).get_paginated_response(data) for (key, value) in kwargs.items(): resp.data[key] = value + resp.status_code = status_code return resp diff --git a/lms/djangoapps/program_enrollments/api/v1/serializers.py b/lms/djangoapps/program_enrollments/api/v1/serializers.py index fb766e79be..942daf6ece 100644 --- a/lms/djangoapps/program_enrollments/api/v1/serializers.py +++ b/lms/djangoapps/program_enrollments/api/v1/serializers.py @@ -131,6 +131,63 @@ class ProgramCourseEnrollmentListSerializer(serializers.Serializer): return text_type(obj.program_enrollment.curriculum_uuid) +class ProgramCourseGradeResult(object): + """ + Represents a courserun grade for a user enrolled through a program. + + Can be passed to ProgramCourseGradeResultSerializer. + """ + is_error = False + + def __init__(self, program_course_enrollment, course_grade): + """ + Creates a new grade result given a ProgramCourseEnrollment object + and a course grade object. + """ + self.student_key = program_course_enrollment.program_enrollment.external_user_key + self.passed = course_grade.passed + self.percent = course_grade.percent + self.letter_grade = course_grade.letter_grade + + +class ProgramCourseGradeErrorResult(object): + """ + Represents a failure to load a courserun grade for a user enrolled through + a program. + + Can be passed to ProgramCourseGradeResultSerializer. + """ + is_error = True + + def __init__(self, program_course_enrollment, exception=None): + """ + Creates a new course grade error object given a + ProgramCourseEnrollment and an exception. + """ + self.student_key = program_course_enrollment.program_enrollment.external_user_key + self.error = text_type(exception) if exception else u"Unknown error" + + +class ProgramCourseGradeResultSerializer(serializers.Serializer): + """ + Serializer for a user's grade in a program courserun. + + Meant to be used with ProgramCourseGradeResult + or ProgramCourseGradeErrorResult as input. + Absence of fields other than `student_key` will be ignored. + """ + # Required + student_key = serializers.CharField() + + # From ProgramCourseGradeResult only + passed = serializers.BooleanField(required=False) + percent = serializers.FloatField(required=False) + letter_grade = serializers.CharField(required=False) + + # From ProgramCourseGradeErrorResult only + error = serializers.CharField(required=False) + + class DueDateSerializer(serializers.Serializer): """ Serializer for a due date. 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 30201042ed..ffe0dd0035 100644 --- a/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py +++ b/lms/djangoapps/program_enrollments/api/v1/tests/test_views.py @@ -46,7 +46,31 @@ from xmodule.modulestore.tests.factories import CourseFactory as ModulestoreCour from xmodule.modulestore.tests.factories import ItemFactory -class ListViewTestMixin(object): +class ProgramCacheTestCaseMixin(CacheIsolationMixin): + """ + Mixin for using program cache in tests + """ + ENABLED_CACHES = ['default'] + + @staticmethod + def setup_catalog_cache(program_uuid, organization_key): + """ + helper function to initialize a cached program with an single authoring_organization + """ + catalog_org = CatalogOrganizationFactory.create(key=organization_key) + program = ProgramFactory.create( + uuid=program_uuid, + authoring_organizations=[catalog_org] + ) + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None) + return program + + @staticmethod + def set_program_in_catalog_cache(program_uuid, program): + cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None) + + +class ListViewTestMixin(ProgramCacheTestCaseMixin): """ Mixin to define some shared test data objects for program/course enrollment list view tests. @@ -56,10 +80,14 @@ class ListViewTestMixin(object): @classmethod def setUpClass(cls): super(ListViewTestMixin, cls).setUpClass() + cls.start_cache_isolation() cls.program_uuid = '00000000-1111-2222-3333-444444444444' cls.program_uuid_tmpl = '00000000-1111-2222-3333-4444444444{0:02d}' cls.curriculum_uuid = 'aaaaaaaa-1111-2222-3333-444444444444' cls.other_curriculum_uuid = 'bbbbbbbb-1111-2222-3333-444444444444' + cls.organization_key = "orgkey" + + cls.program = cls.setup_catalog_cache(cls.program_uuid, cls.organization_key) cls.course_id = CourseKey.from_string('course-v1:edX+ToyX+Toy_Course') _ = CourseOverviewFactory.create(id=cls.course_id) @@ -71,15 +99,37 @@ class ListViewTestMixin(object): @classmethod def tearDownClass(cls): super(ListViewTestMixin, cls).tearDownClass() + cls.end_cache_isolation() + + def setUp(self): + super(ListViewTestMixin, self).setUp() + + self.set_program_in_catalog_cache(self.program_uuid, self.program) + self.curriculum = next(c for c in self.program['curricula'] if c['is_active']) + self.course = self.curriculum['courses'][0] + self.course_run = self.course["course_runs"][0] + self.course_key = CourseKey.from_string(self.course_run["key"]) + CourseOverviewFactory(id=self.course_key) + self.course_not_in_program = CourseFactory() + self.course_not_in_program_key = CourseKey.from_string( + self.course_not_in_program["course_runs"][0]["key"] + ) + CourseOverviewFactory(id=self.course_not_in_program_key) 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 + kwargs['course_id'] = course_id return reverse(self.view_name, kwargs=kwargs) + def log_in_non_staff(self): + self.client.login(username=self.student.username, password=self.password) + + def log_in_staff(self): + self.client.login(username=self.global_staff.username, password=self.password) + @ddt.ddt class UserProgramReadOnlyAccessViewTest(ListViewTestMixin, APITestCase): @@ -340,86 +390,14 @@ class ProgramEnrollmentListTest(ListViewTestMixin, APITestCase): assert '?cursor=' in next_response.data['previous'] -class ProgramCacheTestCaseMixin(CacheIsolationMixin): - """ - Mixin for using program cache in tests - """ - ENABLED_CACHES = ['default'] - - def setup_catalog_cache(self, program_uuid, organization_key): - """ - helper function to initialize a cached program with an single authoring_organization - """ - catalog_org = CatalogOrganizationFactory.create(key=organization_key) - program = ProgramFactory.create( - uuid=program_uuid, - authoring_organizations=[catalog_org] - ) - self.set_program_in_catalog_cache(program_uuid, program) - return program - - def set_program_in_catalog_cache(self, program_uuid, program): - cache.set(PROGRAM_CACHE_KEY_TPL.format(uuid=program_uuid), program, None) - - -@ddt.ddt -class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin): - """ - A base for tests for course enrollment. - Children should override self.request() - """ - - @classmethod - def setUpClass(cls): - super(BaseCourseEnrollmentTestsMixin, cls).setUpClass() - cls.start_cache_isolation() - 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): - cls.end_cache_isolation() - super(BaseCourseEnrollmentTestsMixin, cls).tearDownClass() - - def setUp(self): - super(BaseCourseEnrollmentTestsMixin, self).setUp() - self.clear_caches() - self.addCleanup(self.clear_caches) - self.program_uuid = uuid4() - self.organization_key = "orgkey" - self.program = self.setup_catalog_cache(self.program_uuid, self.organization_key) - curriculum = next(c for c in self.program['curricula'] if c['is_active']) - self.course = curriculum['courses'][0] - self.course_run = self.course["course_runs"][0] - self.course_key = CourseKey.from_string(self.course_run["key"]) - CourseOverviewFactory(id=self.course_key) - self.course_not_in_program = CourseFactory() - self.course_not_in_program_key = CourseKey.from_string( - self.course_not_in_program["course_runs"][0]["key"] - ) - CourseOverviewFactory(id=self.course_not_in_program_key) - self.default_url = self.get_url(self.program_uuid, self.course_key) - self.client.login(username=self.global_staff, password=self.password) - +class ProgramEnrollmentDataMixin(object): + """ Provides methods for creating ProgramEnrollments and ProgramCourseEnrollments. """ def learner_enrollment(self, student_key, enrollment_status="active"): """ Convenience method to create a learner enrollment record """ return {"student_key": student_key, "status": enrollment_status} - def get_url(self, program_uuid, course_id): - """ - Convenience method to build a path for a program course enrollment request - """ - return reverse( - 'programs_api:v1:program_course_enrollments', - kwargs={ - 'program_uuid': str(program_uuid), - 'course_id': str(course_id) - } - ) - def request(self, path, data): pass @@ -454,7 +432,7 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin): ) course_enrollment.is_active = course_status == "active" course_enrollment.save() - return ProgramCourseEnrollmentFactory( + return ProgramCourseEnrollmentFactory.create( program_enrollment=program_enrollment, course_key=self.course_key, course_enrollment=course_enrollment, @@ -465,6 +443,30 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin): program_enrollment = self.create_program_enrollment(external_user_key, user) return self.create_program_course_enrollment(program_enrollment, course_status=course_status) + +@ddt.ddt +class BaseCourseEnrollmentTestsMixin(ProgramEnrollmentDataMixin, ListViewTestMixin, ProgramCacheTestCaseMixin): + """ + A base for tests for course enrollment. + Children should override self.request() + """ + view_name = 'programs_api:v1:program_course_enrollments' + + @classmethod + def setUpClass(cls): + super(BaseCourseEnrollmentTestsMixin, cls).setUpClass() + cls.start_cache_isolation() + + @classmethod + def tearDownClass(cls): + cls.end_cache_isolation() + super(BaseCourseEnrollmentTestsMixin, cls).tearDownClass() + + def setUp(self): + super(BaseCourseEnrollmentTestsMixin, self).setUp() + self.default_url = self.get_url(self.program_uuid, self.course_key) + self.log_in_staff() + def assert_program_course_enrollment(self, external_user_key, expected_status, has_user, mode=CourseMode.MASTERS): """ Convenience method to assert that a ProgramCourseEnrollment exists, @@ -493,7 +495,7 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin): def test_403_forbidden(self): self.client.logout() - self.client.login(username=self.student, password=self.password) + self.log_in_non_staff() request_data = [self.learner_enrollment("learner-1")] response = self.request(self.default_url, request_data) self.assertEqual(403, response.status_code) @@ -516,11 +518,11 @@ class BaseCourseEnrollmentTestsMixin(ProgramCacheTestCaseMixin): self.assertEqual(404, response.status_code) def test_404_no_curriculum(self): - self.program['curricula'] = [] - self.set_program_in_catalog_cache(self.program_uuid, self.program) - request_data = [self.learner_enrollment("learner-1")] - response = self.request(self.default_url, request_data) - self.assertEqual(404, response.status_code) + with mock.patch.dict(self.program, curricula=[]): + self.set_program_in_catalog_cache(self.program_uuid, self.program) + request_data = [self.learner_enrollment("learner-1")] + response = self.request(self.default_url, request_data) + self.assertEqual(404, response.status_code) def test_duplicate_learner(self): request_data = [ @@ -679,9 +681,8 @@ class CourseEnrollmentPostTests(BaseCourseEnrollmentTestsMixin, APITestCase): ) -# pylint: disable=no-member @ddt.ddt -class CourseEnrollmentModificationTestBase(BaseCourseEnrollmentTestsMixin): +class CourseEnrollmentModificationTestMixin(BaseCourseEnrollmentTestsMixin): """ Base class for both the PATCH and PUT endpoints for Course Enrollment API Children needs to implement assert_user_not_enrolled_test_result and @@ -744,7 +745,7 @@ class CourseEnrollmentModificationTestBase(BaseCourseEnrollmentTestsMixin): self.assert_program_course_enrollment('learner-4', 'active', False) -class CourseEnrollmentPatchTests(CourseEnrollmentModificationTestBase, APITestCase): +class CourseEnrollmentPatchTests(CourseEnrollmentModificationTestMixin, APITestCase): """ Tests for course enrollment PATCH """ def request(self, path, data): @@ -761,7 +762,7 @@ class CourseEnrollmentPatchTests(CourseEnrollmentModificationTestBase, APITestCa self.create_program_and_course_enrollments('learner-4', course_status=initial_statuses[3], user=None) -class CourseEnrollmentPutTests(CourseEnrollmentModificationTestBase, APITestCase): +class CourseEnrollmentPutTests(CourseEnrollmentModificationTestMixin, APITestCase): """ Tests for course enrollment PUT """ def request(self, path, data): @@ -1842,3 +1843,128 @@ class ProgramCourseEnrollmentOverviewViewTests(ProgramCacheTestCaseMixin, Shared response = self.client.get(self.get_url(self.program_uuid)) self.assertEqual(status.HTTP_200_OK, response.status_code) self.assertIn('micromasters_title', response.data['course_runs'][0]) + + +class ProgramCourseGradeListTest(ProgramEnrollmentDataMixin, ListViewTestMixin, APITestCase): + """ + Tests for GET calls to the Program Course Grades API. + """ + view_name = 'programs_api:v1:program_course_grades' + + @staticmethod + def mock_course_grade(percent=75.0, passed=True, letter_grade='B'): + return mock.MagicMock(percent=percent, passed=passed, letter_grade=letter_grade) + + @mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory') + def test_204_no_grades_to_return(self, mock_course_grade_factory): + mock_course_grade_factory.return_value.iter.return_value = [] + self.log_in_staff() + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertEqual(response.data['results'], []) + + def test_401_if_unauthenticated(self): + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_401_UNAUTHORIZED) + + def test_403_if_not_staff(self): + self.log_in_non_staff() + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_404_not_found(self): + fake_program_uuid = self.program_uuid_tmpl.format(99) + self.log_in_staff() + url = self.get_url(program_uuid=fake_program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + @mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory') + def test_200_grades_with_no_exceptions(self, mock_course_grade_factory): + other_student = UserFactory.create(username='other_student') + self.create_program_and_course_enrollments('student-key', user=self.student) + self.create_program_and_course_enrollments('other-student-key', user=other_student) + mock_course_grades = [ + (self.student, self.mock_course_grade(), None), + (other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None), + ] + mock_course_grade_factory.return_value.iter.return_value = mock_course_grades + + self.log_in_staff() + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_200_OK) + expected_results = [ + { + 'student_key': 'student-key', + 'passed': True, + 'percent': 75.0, + 'letter_grade': 'B', + }, + { + 'student_key': 'other-student-key', + 'passed': False, + 'percent': 40.0, + 'letter_grade': 'F', + }, + ] + self.assertEqual(response.data['results'], expected_results) + + @mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory') + def test_207_grades_with_some_exceptions(self, mock_course_grade_factory): + other_student = UserFactory.create(username='other_student') + self.create_program_and_course_enrollments('student-key', user=self.student) + self.create_program_and_course_enrollments('other-student-key', user=other_student) + mock_course_grades = [ + (self.student, None, Exception('Bad Data')), + (other_student, self.mock_course_grade(percent=40.0, passed=False, letter_grade='F'), None), + ] + mock_course_grade_factory.return_value.iter.return_value = mock_course_grades + + self.log_in_staff() + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_207_MULTI_STATUS) + expected_results = [ + { + 'student_key': 'student-key', + 'error': 'Bad Data', + }, + { + 'student_key': 'other-student-key', + 'passed': False, + 'percent': 40.0, + 'letter_grade': 'F', + }, + ] + self.assertEqual(response.data['results'], expected_results) + + @mock.patch('lms.djangoapps.program_enrollments.api.v1.views.CourseGradeFactory') + def test_422_grades_with_only_exceptions(self, mock_course_grade_factory): + other_student = UserFactory.create(username='other_student') + self.create_program_and_course_enrollments('student-key', user=self.student) + self.create_program_and_course_enrollments('other-student-key', user=other_student) + mock_course_grades = [ + (self.student, None, Exception('Bad Data')), + (other_student, None, Exception('Timeout')), + ] + mock_course_grade_factory.return_value.iter.return_value = mock_course_grades + + self.log_in_staff() + url = self.get_url(program_uuid=self.program_uuid, course_id=self.course_key) + response = self.client.get(url) + self.assertEqual(response.status_code, status.HTTP_422_UNPROCESSABLE_ENTITY) + expected_results = [ + { + 'student_key': 'student-key', + 'error': 'Bad Data', + }, + { + 'student_key': 'other-student-key', + 'error': 'Timeout', + }, + ] + self.assertEqual(response.data['results'], expected_results) diff --git a/lms/djangoapps/program_enrollments/api/v1/urls.py b/lms/djangoapps/program_enrollments/api/v1/urls.py index b50561cf41..d178227b29 100644 --- a/lms/djangoapps/program_enrollments/api/v1/urls.py +++ b/lms/djangoapps/program_enrollments/api/v1/urls.py @@ -7,6 +7,7 @@ from lms.djangoapps.program_enrollments.api.v1.constants import PROGRAM_UUID_PAT from lms.djangoapps.program_enrollments.api.v1.views import ( ProgramEnrollmentsView, ProgramCourseEnrollmentsView, + ProgramCourseGradesView, ProgramCourseEnrollmentOverviewView, UserProgramReadOnlyAccessView, ) @@ -38,6 +39,14 @@ urlpatterns = [ ProgramCourseEnrollmentsView.as_view(), name="program_course_enrollments" ), + url( + r'^programs/{program_uuid}/courses/{course_id}/grades/'.format( + program_uuid=PROGRAM_UUID_PATTERN, + course_id=COURSE_ID_PATTERN + ), + ProgramCourseGradesView.as_view(), + name="program_course_grades" + ), url( r'^programs/{program_uuid}/overview/'.format( program_uuid=PROGRAM_UUID_PATTERN, diff --git a/lms/djangoapps/program_enrollments/api/v1/views.py b/lms/djangoapps/program_enrollments/api/v1/views.py index 801a664fe7..0554c04c5f 100644 --- a/lms/djangoapps/program_enrollments/api/v1/views.py +++ b/lms/djangoapps/program_enrollments/api/v1/views.py @@ -20,17 +20,23 @@ from opaque_keys.edx.keys import CourseKey from rest_framework import status from rest_framework.views import APIView from rest_framework.exceptions import ValidationError -from rest_framework.pagination import CursorPagination from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response -from six import iteritems +from six import iteritems, text_type +from six.moves import zip from ccx_keys.locator import CCXLocator from bulk_email.api import is_bulk_email_feature_enabled, is_user_opted_out_for_course from course_modes.models import CourseMode from edx_when.api import get_dates_for_course from lms.djangoapps.certificates.api import get_certificate_for_user +from lms.djangoapps.grades.api import ( + CourseGradeFactory, + clear_prefetched_course_grades, + prefetch_course_grades, +) +from lms.djangoapps.grades.rest_api.v1.utils import CourseEnrollmentPagination from lms.djangoapps.program_enrollments.api.v1.constants import ( CourseEnrollmentResponseStatuses, CourseRunProgressStatuses, @@ -41,6 +47,9 @@ from lms.djangoapps.program_enrollments.api.v1.serializers import ( CourseRunOverviewListSerializer, ProgramCourseEnrollmentListSerializer, ProgramCourseEnrollmentRequestSerializer, + ProgramCourseGradeResult, + ProgramCourseGradeErrorResult, + ProgramCourseGradeResultSerializer, ProgramEnrollmentCreateRequestSerializer, ProgramEnrollmentListSerializer, ProgramEnrollmentModifyRequestSerializer, @@ -133,25 +142,11 @@ def verify_course_exists_and_in_program(view_func): return wrapped_function -class ProgramEnrollmentPagination(CursorPagination): +class ProgramEnrollmentPagination(CourseEnrollmentPagination): """ - Pagination class for Program Enrollments. + Pagination class for views in the Program Enrollments app. """ - 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): @@ -1209,3 +1204,188 @@ class ProgramCourseEnrollmentOverviewView(DeveloperErrorViewMixin, ProgramSpecif else: return CourseRunProgressStatuses.UPCOMING return None + + +class ProgramCourseGradesView( + DeveloperErrorViewMixin, + ProgramCourseRunSpecificViewMixin, + PaginatedAPIView, +): + """ + A view for retrieving a paginated list of grades for all students enrolled + in a given courserun through a given program. + + Path: ``/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/grades/`` + + Accepts: [GET] + + For GET requests, the path can contain an optional `page_size?=N` query parameter. + The default page size is 100. + + ------------------------------------------------------------------------------------ + GETs + ------------------------------------------------------------------------------------ + + **Returns** + + * 200: OK - Contains a paginated set of program courserun grades. + * 204: No Content - No grades to return + * 207: Mixed result - Contains mixed list of program courserun grades + and grade-fetching errors + * 422: All failed - Contains list of grade-fetching errors + * 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/207/422 response code, the response will include a + paginated data set. The `results` section of the response consists of a + list of grade records, where each successfully loaded record contains: + * student_key: The identifier of the student enrolled in the program and course. + * letter_grade: A letter grade as defined in grading policy + (e.g. 'A' 'B' 'C' for 6.002x) or None. + * passed: Boolean representing whether the course has been + passed according to the course's grading policy. + * percent: A float representing the overall grade for the course. + and failed-to-load records contain: + * student_key + * error: error message from grades Exception + + **Example** + + 207 Multi-Status + { + "next": null, + "previous": "http://example.com/api/program_enrollments/v1/programs/{program_uuid}/courses/{course_id}/grades/?cursor=abcd", + "results": [; + { + "student_key": "01709bffeae2807b6a7317", + "letter_grade": "Pass", + "percent": 0.95, + "passed": true + }, + { + "student_key": "2cfe15e3380a52e7198237", + "error": "Timeout while calculating grade" + }, + ... + ], + } + + """ + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + 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 ProgramCourseGrade objects. + """ + course_key = CourseKey.from_string(course_id) + grade_results = self._load_grade_results(program_uuid, course_key) + serializer = ProgramCourseGradeResultSerializer(grade_results, many=True) + response_code = self._calc_response_code(grade_results) + return self.get_paginated_response(serializer.data, status_code=response_code) + + def _load_grade_results(self, program_uuid, course_key): + """ + Load grades (or grading errors) for a given program courserun. + + Arguments: + program_uuid (str) + course_key (CourseKey) + + Returns: list[ProgramCourseGradeResult|ProgramCourseGradeErrorResult] + """ + enrollments_qs = use_read_replica_if_available( + ProgramCourseEnrollment.objects.filter( + program_enrollment__program_uuid=program_uuid, + program_enrollment__user__isnull=False, + course_key=course_key, + ).select_related( + 'program_enrollment', + 'program_enrollment__user', + ) + ) + paginated_enrollments = self.paginate_queryset(enrollments_qs) + if not paginated_enrollments: + return [] + + # Hint: `zip(*(list))` can be read as "unzip(list)" + enrollments, users = zip(*( + (enrollment, enrollment.program_enrollment.user) + for enrollment in paginated_enrollments + )) + enrollment_grade_pairs = zip( + enrollments, self._iter_grades(course_key, list(users)) + ) + grade_results = [ + ( + ProgramCourseGradeResult(enrollment, grade) + if grade + else ProgramCourseGradeErrorResult(enrollment, exception) + ) + for enrollment, (grade, exception) in enrollment_grade_pairs + ] + return grade_results + + @staticmethod + def _iter_grades(course_key, users): + """ + Load a user grades for a course, using bulk fetching for efficiency. + + Arguments: + course_key (CourseKey) + users (list[User]) + + Returns: iterable[( CourseGradeBase|NoneType, Exception|NoneType )] + Iterable of pairs, in same order as `users`. + The first item in the pair is the grade, or None if loading the + grade failed. + The second item in the pair is an exception or None. + """ + prefetch_course_grades(course_key, users) + try: + grades_iter = CourseGradeFactory().iter(users, course_key=course_key) + for user, course_grade, exception in grades_iter: + if not course_grade: + fmt = 'Failed to load course grade for user ID {} in {}: {}' + err_str = fmt.format( + user.id, + course_key, + text_type(exception) if exception else 'Unknown error' + ) + logger.error(err_str) + yield course_grade, exception + finally: + clear_prefetched_course_grades(course_key) + + @staticmethod + def _calc_response_code(grade_results): + """ + Returns HTTP status code appropriate for list of results, + which may be grades or errors. + + Arguments: + enrollment_grade_results: list[ProgramCourseGradeResult] + + Returns: int + * 200 for all success + * 207 for mixed result + * 422 for all failure + * 204 for empty + """ + if not grade_results: + return status.HTTP_204_NO_CONTENT + if all(result.is_error for result in grade_results): + return status.HTTP_422_UNPROCESSABLE_ENTITY + if any(result.is_error for result in grade_results): + return status.HTTP_207_MULTI_STATUS + return status.HTTP_200_OK