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:
committed by
Alex Dusenbery
parent
9753eae441
commit
fff69a9f58
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user