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
This commit is contained in:
Kyle McCormick
2019-08-02 17:06:35 -04:00
committed by Alex Dusenbery
parent 9753eae441
commit fff69a9f58
5 changed files with 481 additions and 107 deletions

View File

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

View File

@@ -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.

View File

@@ -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)

View File

@@ -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,

View File

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