Add the read API for course gradebook data (single and multiple users).

This commit is contained in:
Alex Dusenbery
2018-10-03 16:03:00 -04:00
committed by Alex Dusenbery
parent 5d2919644b
commit e5473f5396
7 changed files with 923 additions and 79 deletions

View File

@@ -16,7 +16,7 @@ class GradingPolicySerializer(serializers.Serializer):
dropped = serializers.IntegerField(source='drop_count')
weight = serializers.FloatField()
def to_representation(self, obj):
def to_representation(self, instance):
"""
Return a representation of the grading policy.
"""
@@ -25,6 +25,58 @@ class GradingPolicySerializer(serializers.Serializer):
# DRF v3 unhelpfully raises an exception.
return dict(
super(GradingPolicySerializer, self).to_representation(
defaultdict(lambda: None, obj)
defaultdict(lambda: None, instance)
)
)
class SectionBreakdownSerializer(serializers.Serializer):
"""
Serializer for the `section_breakdown` portion of a gradebook entry.
"""
are_grades_published = serializers.BooleanField()
auto_grade = serializers.BooleanField()
category = serializers.CharField()
chapter_name = serializers.CharField()
comment = serializers.CharField()
detail = serializers.CharField()
displayed_value = serializers.CharField()
is_graded = serializers.BooleanField()
grade_description = serializers.CharField()
is_ag = serializers.BooleanField()
is_average = serializers.BooleanField()
is_manually_graded = serializers.BooleanField()
label = serializers.CharField()
letter_grade = serializers.CharField()
module_id = serializers.CharField()
percent = serializers.FloatField()
score_earned = serializers.FloatField()
score_possible = serializers.FloatField()
section_block_id = serializers.CharField()
subsection_name = serializers.CharField()
class SimpleSerializer(serializers.BaseSerializer):
"""
A Serializer intended to take a dictionary of data and simply spit
that same dictionary back out as the "serialization".
"""
def to_representation(self, instance):
return instance
class StudentGradebookEntrySerializer(serializers.Serializer):
"""
Serializer for student gradebook entry.
"""
course_id = serializers.CharField()
email = serializers.CharField()
user_id = serializers.IntegerField()
username = serializers.CharField()
full_name = serializers.CharField()
passed = serializers.BooleanField()
percent = serializers.FloatField()
letter_grade = serializers.CharField()
progress_page_url = serializers.CharField()
section_breakdown = SectionBreakdownSerializer(many=True)
aggregates = SimpleSerializer()

View File

@@ -7,6 +7,9 @@ from django.conf.urls import include, url
from lms.djangoapps.grades.api import views
app_name = 'lms.djangoapps.grades'
urlpatterns = [
url(
r'^v0/course_grade/{course_id}/users/$'.format(

View File

@@ -1,18 +1,26 @@
"""
Tests for v1 views
"""
from __future__ import unicode_literals
from collections import OrderedDict
from datetime import datetime
import ddt
import ddt
from django.urls import reverse
from mock import MagicMock, patch
from opaque_keys import InvalidKeyError
from pytz import UTC
from rest_framework import status
from rest_framework.test import APITestCase
from six import text_type
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK
from lms.djangoapps.grades.course_data import CourseData
from lms.djangoapps.grades.course_grade import CourseGrade
from lms.djangoapps.grades.subsection_grade import ReadSubsectionGrade
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
@@ -69,7 +77,6 @@ class GradeViewTestMixin(SharedModuleStoreTestCase):
cls.empty_course = cls._create_test_course_with_default_grading_policy(
display_name='empty test course', run="Empty_testing_course"
)
cls.course_key = cls.course.id
cls.password = 'test'
@@ -78,18 +85,22 @@ class GradeViewTestMixin(SharedModuleStoreTestCase):
cls.other_user = UserFactory(username='bar', password=cls.password)
cls.staff = StaffFactory(course_key=cls.course_key, password=cls.password)
cls.global_staff = GlobalStaffFactory.create()
date = datetime(2013, 1, 22, tzinfo=UTC)
for user in (cls.student, cls.other_student,):
CourseEnrollmentFactory(
course_id=cls.course.id,
user=user,
created=date,
)
cls._create_user_enrollments(cls.course, cls.student, cls.other_student)
def setUp(self):
super(GradeViewTestMixin, self).setUp()
self.client.login(username=self.student.username, password=self.password)
@classmethod
def _create_user_enrollments(cls, course, *users):
date = datetime(2013, 1, 22, tzinfo=UTC)
for user in users:
CourseEnrollmentFactory(
course_id=course.id,
user=user,
created=date,
)
@classmethod
def _create_test_course_with_default_grading_policy(cls, display_name, run):
"""
@@ -383,3 +394,414 @@ class CourseGradesViewTest(GradeViewTestMixin, APITestCase):
]
self.assertEqual(resp.data, expected_data)
class GradebookViewTest(GradeViewTestMixin, APITestCase):
"""
Tests for the gradebook view.
"""
@classmethod
def setUpClass(cls):
super(GradebookViewTest, cls).setUpClass()
cls.namespaced_url = 'grades_api:v1:course_gradebook'
cls.waffle_flag = waffle_flags()[WRITABLE_GRADEBOOK]
cls.course = CourseFactory.create(display_name='test-course', run='run-1')
cls.course_overview = CourseOverviewFactory.create(id=cls.course.id)
# we re-assign cls.course from what's created in the parent class, so we have to
# re-create the enrollments, too.
cls._create_user_enrollments(cls.course, cls.student, cls.other_student)
cls.chapter_1 = ItemFactory.create(
category='chapter',
parent_location=cls.course.location,
display_name="Chapter 1",
)
cls.chapter_2 = ItemFactory.create(
category='chapter',
parent_location=cls.course.location,
display_name="Chapter 2",
)
cls.subsections = {
cls.chapter_1.location: [
ItemFactory.create(
category='sequential',
parent_location=cls.chapter_1.location,
due=datetime(2017, 12, 18, 11, 30, 00),
display_name='HW 1',
format='Homework',
graded=True,
),
ItemFactory.create(
category='sequential',
parent_location=cls.chapter_1.location,
due=datetime(2017, 12, 18, 11, 30, 00),
display_name='Lab 1',
format='Lab',
graded=True,
),
],
cls.chapter_2.location: [
ItemFactory.create(
category='sequential',
parent_location=cls.chapter_2.location,
due=datetime(2017, 12, 18, 11, 30, 00),
display_name='HW 2',
format='Homework',
graded=True,
),
ItemFactory.create(
category='sequential',
parent_location=cls.chapter_2.location,
due=datetime(2017, 12, 18, 11, 30, 00),
display_name='Lab 2',
format='Lab',
graded=True,
),
],
}
def get_url(self, course_key=None, username=None):
"""
Helper function to create the course gradebook API read url.
"""
base_url = reverse(
self.namespaced_url,
kwargs={
'course_id': course_key or self.course_key,
}
)
if username:
return "{0}?username={1}".format(base_url, username)
return base_url
def mock_subsection_grade(self, subsection, **kwargs):
"""
Helper function to mock a subsection grade.
"""
model = MagicMock(**kwargs)
factory = MagicMock()
return ReadSubsectionGrade(subsection, model, factory)
def mock_course_grade(self, user, **kwargs):
"""
Helper function to return a mock CourseGrade object.
"""
course_data = CourseData(user, course=self.course)
course_grade = CourseGrade(user=user, course_data=course_data, **kwargs)
course_grade.chapter_grades = OrderedDict([
(self.chapter_1.location, {
'sections': [
self.mock_subsection_grade(
self.subsections[self.chapter_1.location][0],
earned_all=1.0,
possible_all=2.0,
earned_graded=1.0,
possible_graded=2.0,
),
self.mock_subsection_grade(
self.subsections[self.chapter_1.location][1],
earned_all=1.0,
possible_all=2.0,
earned_graded=1.0,
possible_graded=2.0,
),
],
'display_name': 'Chapter 1',
}),
(self.chapter_2.location, {
'sections': [
self.mock_subsection_grade(
self.subsections[self.chapter_2.location][0],
earned_all=1.0,
possible_all=2.0,
earned_graded=1.0,
possible_graded=2.0,
),
self.mock_subsection_grade(
self.subsections[self.chapter_2.location][1],
earned_all=1.0,
possible_all=2.0,
earned_graded=1.0,
possible_graded=2.0,
),
],
'display_name': 'Chapter 2',
}),
])
return course_grade
def login_staff(self):
"""
Helper function to login the global staff user, who has permissions to read from the
Gradebook API.
"""
self.client.logout()
self.client.login(username=self.global_staff.username, password=self.password)
def expected_subsection_grades(self, letter_grade=None):
"""
Helper function to generate expected subsection detail results.
"""
return [
OrderedDict([
('are_grades_published', True),
('auto_grade', False),
('category', 'Homework'),
('chapter_name', 'Chapter 1'),
('comment', ''),
('detail', ''),
('displayed_value', '0.50'),
('is_graded', True),
('grade_description', '(1.00/2.00)'),
('is_ag', False),
('is_average', False),
('is_manually_graded', False),
('label', 'HW 01'),
('letter_grade', letter_grade),
('module_id', text_type(self.subsections[self.chapter_1.location][0].location)),
('percent', 0.5),
('score_earned', 1.0),
('score_possible', 2.0),
('section_block_id', text_type(self.chapter_1.location)),
('subsection_name', 'HW 1')
]),
OrderedDict([
('are_grades_published', True),
('auto_grade', False),
('category', 'Lab'),
('chapter_name', 'Chapter 1'),
('comment', ''),
('detail', ''),
('displayed_value', '0.50'),
('is_graded', True),
('grade_description', '(1.00/2.00)'),
('is_ag', False),
('is_average', False),
('is_manually_graded', False),
('label', 'Lab 01'),
('letter_grade', letter_grade),
('module_id', text_type(self.subsections[self.chapter_1.location][1].location)),
('percent', 0.5),
('score_earned', 1.0),
('score_possible', 2.0),
('section_block_id', text_type(self.chapter_1.location)),
('subsection_name', 'Lab 1')
]),
OrderedDict([
('are_grades_published', True),
('auto_grade', False),
('category', 'Homework'),
('chapter_name', 'Chapter 2'),
('comment', ''),
('detail', ''),
('displayed_value', '0.50'),
('is_graded', True),
('grade_description', '(1.00/2.00)'),
('is_ag', False),
('is_average', False),
('is_manually_graded', False),
('label', 'HW 02'),
('letter_grade', letter_grade),
('module_id', text_type(self.subsections[self.chapter_2.location][0].location)),
('percent', 0.5),
('score_earned', 1.0),
('score_possible', 2.0),
('section_block_id', text_type(self.chapter_2.location)),
('subsection_name', 'HW 2')
]),
OrderedDict([
('are_grades_published', True),
('auto_grade', False),
('category', 'Lab'),
('chapter_name', 'Chapter 2'),
('comment', ''),
('detail', ''),
('displayed_value', '0.50'),
('is_graded', True),
('grade_description', '(1.00/2.00)'),
('is_ag', False),
('is_average', False),
('is_manually_graded', False),
('label', 'Lab 02'),
('letter_grade', letter_grade),
('module_id', text_type(self.subsections[self.chapter_2.location][1].location)),
('percent', 0.5),
('score_earned', 1.0),
('score_possible', 2.0),
('section_block_id', text_type(self.chapter_2.location)),
('subsection_name', 'Lab 2')
]),
]
def test_feature_not_enabled(self):
self.client.logout()
self.client.login(username=self.global_staff.username, password=self.password)
with override_waffle_flag(self.waffle_flag, active=False):
resp = self.client.get(
self.get_url(course_key=self.empty_course.id)
)
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
def test_anonymous(self):
self.client.logout()
with override_waffle_flag(self.waffle_flag, active=True):
resp = self.client.get(self.get_url())
self.assertEqual(status.HTTP_401_UNAUTHORIZED, resp.status_code)
def test_student(self):
with override_waffle_flag(self.waffle_flag, active=True):
resp = self.client.get(self.get_url())
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
def test_course_does_not_exist(self):
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1')
)
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
def test_user_does_not_exist(self):
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key=self.course.id, username='not-a-real-user')
)
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
def test_user_not_enrolled(self):
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key=self.empty_course.id, username=self.student.username)
)
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
def test_course_no_enrollments(self):
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key=self.empty_course.id)
)
expected_data = {
'count': 0,
'next': None,
'previous': None,
'results': [],
}
self.assertEqual(status.HTTP_200_OK, resp.status_code)
self.assertEqual(expected_data, dict(resp.data))
def test_gradebook_data_for_course(self):
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
mock_grade.side_effect = [
self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85),
self.mock_course_grade(self.other_student, passed=False, letter_grade=None, percent=0.45),
]
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key=self.course.id)
)
expected_results = [
OrderedDict([
('course_id', text_type(self.course.id)),
('email', self.student.email),
('user_id', self.student.id),
('username', self.student.username),
('full_name', self.student.get_full_name()),
('passed', True),
('percent', 0.85),
('letter_grade', 'A'),
('progress_page_url', reverse(
'student_progress',
kwargs=dict(course_id=text_type(self.course.id), student_id=self.student.id)
)),
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
('aggregates', {
'Lab': {
'score_earned': 2.0,
'score_possible': 4.0,
},
'Homework': {
'score_earned': 2.0,
'score_possible': 4.0,
},
}),
]),
OrderedDict([
('course_id', text_type(self.course.id)),
('email', self.other_student.email),
('user_id', self.other_student.id),
('username', self.other_student.username),
('full_name', self.other_student.get_full_name()),
('passed', False),
('percent', 0.45),
('letter_grade', None),
('progress_page_url', reverse(
'student_progress',
kwargs=dict(course_id=text_type(self.course.id), student_id=self.other_student.id)
)),
('section_breakdown', self.expected_subsection_grades()),
('aggregates', {
'Lab': {
'score_earned': 2.0,
'score_possible': 4.0,
},
'Homework': {
'score_earned': 2.0,
'score_possible': 4.0,
},
}),
]),
]
self.assertEqual(status.HTTP_200_OK, resp.status_code)
actual_data = dict(resp.data)
self.assertEqual(2, actual_data['count'])
self.assertIsNone(actual_data['next'])
self.assertIsNone(actual_data['previous'])
self.assertEqual(expected_results, actual_data['results'])
def test_gradebook_data_for_single_learner(self):
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
mock_grade.return_value = self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85)
with override_waffle_flag(self.waffle_flag, active=True):
self.login_staff()
resp = self.client.get(
self.get_url(course_key=self.course.id, username=self.student.username)
)
expected_results = OrderedDict([
('course_id', text_type(self.course.id)),
('email', self.student.email),
('user_id', self.student.id),
('username', self.student.username),
('full_name', self.student.get_full_name()),
('passed', True),
('percent', 0.85),
('letter_grade', 'A'),
('progress_page_url', reverse(
'student_progress',
kwargs=dict(course_id=text_type(self.course.id), student_id=self.student.id)
)),
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
('aggregates', {
'Lab': {
'score_earned': 2.0,
'score_possible': 4.0,
},
'Homework': {
'score_earned': 2.0,
'score_possible': 4.0,
},
}),
])
self.assertEqual(status.HTTP_200_OK, resp.status_code)
actual_data = dict(resp.data)
self.assertEqual(expected_results, actual_data)

View File

@@ -5,21 +5,28 @@ from django.conf.urls import url
from lms.djangoapps.grades.api.v1 import views
from lms.djangoapps.grades.api.views import CourseGradingPolicy
app_name = 'lms.djangoapps.grades'
urlpatterns = [
url(
r'^courses/$',
views.CourseGradesView.as_view(), name='course_grades'
views.CourseGradesView.as_view(),
name='course_grades'
),
url(
r'^courses/{course_id}/$'.format(
course_id=settings.COURSE_ID_PATTERN,
),
views.CourseGradesView.as_view(), name='course_grades'
r'^courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN),
views.CourseGradesView.as_view(),
name='course_grades'
),
url(
r'^policy/courses/{course_id}/$'.format(
course_id=settings.COURSE_ID_PATTERN,
),
CourseGradingPolicy.as_view(), name='course_grading_policy'
r'^policy/courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN),
CourseGradingPolicy.as_view(),
name='course_grading_policy'
),
url(
r'^gradebook/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN),
views.GradebookView.as_view(),
name='course_gradebook'
),
]

View File

@@ -1,15 +1,24 @@
""" API v0 views. """
import logging
from collections import defaultdict
from contextlib import contextmanager
from functools import wraps
from django.contrib.auth import get_user_model
from django.urls import reverse
from rest_framework import status
from rest_framework.exceptions import AuthenticationFailed
from rest_framework.generics import GenericAPIView
from rest_framework.pagination import PageNumberPagination
from rest_framework.response import Response
from six import text_type
from courseware.courses import get_course_with_access
from edx_rest_framework_extensions import permissions
from edx_rest_framework_extensions.authentication import JwtAuthentication, SessionAuthenticationAllowInactiveUser
from enrollment import data as enrollment_data
from lms.djangoapps.grades.api.serializers import StudentGradebookEntrySerializer
from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
@@ -23,11 +32,126 @@ log = logging.getLogger(__name__)
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="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
def verify_writable_gradebook_enabled(view_func):
"""
A decorator to wrap a view function that takes `course_key` as a parameter.
Raises:
A 403 API error if the writable gradebook feature is not enabled for the given course.
"""
@wraps(view_func)
def wrapped_function(self, request, **kwargs):
"""
Wraps the given view function.
"""
course_key = get_course_key(request, kwargs.get('course_id'))
if not waffle_flags()[WRITABLE_GRADEBOOK].is_enabled(course_key):
raise self.api_error(
status_code=status.HTTP_403_FORBIDDEN,
developer_message='The writable gradebook feature is not enabled for this course.',
error_code='feature_not_enabled'
)
return view_func(self, request, **kwargs)
return wrapped_function
class GradeViewMixin(DeveloperErrorViewMixin):
"""
Mixin class for Grades related views.
"""
def _get_single_user_grade(self, request, course_key):
def _get_single_user(self, request, course_key):
"""
Returns a single USER_MODEL object corresponding to the request's `username` parameter,
or the current `request.user` if no `username` was provided.
Args:
request (Request): django request object to check for username or request.user object
course_key (CourseLocator): The course to retrieve user grades for.
Returns:
A USER_MODEL object.
Raises:
USER_MODEL.DoesNotExist if no such user exists.
CourseEnrollment.DoesNotExist if the user is not enrolled in the given course.
"""
if 'username' in request.GET:
username = request.GET.get('username')
else:
username = request.user.username
grade_user = USER_MODEL.objects.get(username=username)
if not enrollment_data.get_course_enrollment(username, text_type(course_key)):
raise CourseEnrollment.DoesNotExist
return grade_user
@contextmanager
def _get_user_or_raise(self, request, course_key):
"""
Raises an API error if the username specified by the request does not exist, or if the
user is not enrolled in the given course.
Args:
request (Request): django request object to check for username or request.user object
course_key (CourseLocator): The course to retrieve user grades for.
Yields:
A USER_MODEL object.
"""
try:
yield self._get_single_user(request, course_key)
except USER_MODEL.DoesNotExist:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='The user matching the requested username does not exist.',
error_code='user_does_not_exist'
)
except CourseEnrollment.DoesNotExist:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='The user matching the requested username is not enrolled in this course',
error_code='user_not_enrolled'
)
def _get_single_user_grade(self, grade_user, course_key):
"""
Returns a grade response for the user object corresponding to the request's 'username' parameter,
or the current request.user if no 'username' was provided.
@@ -38,18 +162,25 @@ class GradeViewMixin(DeveloperErrorViewMixin):
Returns:
A serializable list of grade responses
"""
if 'username' in request.GET:
username = request.GET.get('username')
else:
username = request.user.username
grade_user = USER_MODEL.objects.get(username=username)
if not enrollment_data.get_course_enrollment(username, str(course_key)):
raise CourseEnrollment.DoesNotExist
course_grade = CourseGradeFactory().read(grade_user, course_key=course_key)
return Response([self._make_grade_response(grade_user, course_key, course_grade)])
return Response([self._serialize_user_grade(grade_user, course_key, course_grade)])
def _iter_user_grades(self, course_key):
"""
Args:
course_key (CourseLocator): The course to retrieve grades for.
Returns:
An iterator of CourseGrade objects for users enrolled in the given course.
"""
enrollments_in_course = enrollment_data.get_user_enrollments(course_key)
paged_enrollments = self.paginate_queryset(enrollments_in_course)
users = (enrollment.user for enrollment in paged_enrollments)
grades = CourseGradeFactory().iter(users, course_key=course_key)
for user, course_grade, exc in grades:
yield user, course_grade, exc
def _get_user_grades(self, course_key):
"""
@@ -60,22 +191,14 @@ class GradeViewMixin(DeveloperErrorViewMixin):
Returns:
A serializable list of grade responses
"""
enrollments_in_course = enrollment_data.get_user_enrollments(course_key)
paged_enrollments = self.paginator.paginate_queryset(
enrollments_in_course, self.request, view=self
)
users = (enrollment.user for enrollment in paged_enrollments)
grades = CourseGradeFactory().iter(users, course_key=course_key)
grade_responses = []
for user, course_grade, exc in grades:
for user, course_grade, exc in self._iter_user_grades(course_key):
if not exc:
grade_responses.append(self._make_grade_response(user, course_key, course_grade))
grade_responses.append(self._serialize_user_grade(user, course_key, course_grade))
return Response(grade_responses)
def _make_grade_response(self, user, course_key, course_grade):
def _serialize_user_grade(self, user, course_key, course_grade):
"""
Serialize a single grade to dict to use in Responses
"""
@@ -93,10 +216,46 @@ class GradeViewMixin(DeveloperErrorViewMixin):
Ensures that the user is authenticated (e.g. not an AnonymousUser).
"""
super(GradeViewMixin, self).perform_authentication(request)
if request.user.is_anonymous():
if request.user.is_anonymous:
raise AuthenticationFailed
class SubsectionLabelFinder(object):
"""
Finds the grader label (a short string identifying the section) of a graded section.
"""
def __init__(self, course_grade):
"""
Args:
course_grade: A CourseGrade object.
"""
self.section_summaries = [section for section in course_grade.summary.get('section_breakdown', [])]
def _get_subsection_summary(self, display_name):
"""
Given a subsection's display_name and a breakdown of section grades from CourseGrade.summary,
return the summary data corresponding to the subsection with this display_name.
"""
for index, section in enumerate(self.section_summaries):
if display_name.lower() in section['detail'].lower():
return index, section
return -1, None
def get_label(self, display_name):
"""
Returns the grader short label corresponding to the display_name, or None
if no match was found.
"""
section_index, summary = self._get_subsection_summary(display_name)
if summary:
# It's possible that two subsections/assignments would have the same display name.
# since the grade summary and chapter_grades data are presumably in a sorted order,
# we'll take the first matching section summary and remove it from the pool of
# section_summaries.
self.section_summaries.pop(section_index)
return summary['label']
class CourseGradesView(GradeViewMixin, GenericAPIView):
"""
**Use Case**
@@ -159,6 +318,7 @@ class CourseGradesView(GradeViewMixin, GenericAPIView):
required_scopes = ['grades:read']
@verify_course_exists
def get(self, request, course_id=None):
"""
Gets a course progress status.
@@ -171,42 +331,236 @@ class CourseGradesView(GradeViewMixin, GenericAPIView):
"""
username = request.GET.get('username')
if not course_id:
course_id = request.GET.get('course_id')
# Validate course exists with provided course_id
try:
course_key = CourseKey.from_string(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="Requested grade for unknown course {course}".format(course=course_id),
error_code='course_does_not_exist'
)
course_key = get_course_key(request, course_id)
if username:
# If there is a username passed, get grade for a single user
try:
return self._get_single_user_grade(request, course_key)
except USER_MODEL.DoesNotExist:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='The user matching the requested username does not exist.',
error_code='user_does_not_exist'
)
except CourseEnrollment.DoesNotExist:
raise self.api_error(
status_code=status.HTTP_404_NOT_FOUND,
developer_message='The user matching the requested username is not enrolled in this course',
error_code='user_not_enrolled'
)
with self._get_user_or_raise(request, course_id) as grade_user:
return self._get_single_user_grade(grade_user, course_key)
else:
# If no username passed, get paginated list of grades for all users in course
return self._get_user_grades(course_key)
class GradebookPagination(PageNumberPagination):
page_size = 25
page_size_query_param = 'page_size'
class GradebookView(GradeViewMixin, GenericAPIView):
"""
**Use Case**
* Get course gradebook entries of a single user in a course,
or of all users who are enrolled in a course. The currently logged-in user may request
all enrolled user's grades information if they are allowed.
**Example Request**
GET /api/grades/v1/gradebook/{course_id}/ - Get gradebook entries for all users in course
GET /api/grades/v1/gradebook/{course_id}/?username={username} - Get grades for specific user in course
**GET Parameters**
A GET request may include the following query parameters.
* username: (optional) A string representation of a user's username.
**GET Response Values**
If the request for gradebook data is successful,
an HTTP 200 "OK" response is returned.
The HTTP 200 response for a single has the following values:
* course_id: A string representation of a Course ID.
* email: A string representation of a user's email.
* user_id: The user's integer id.
* username: A string representation of a user's username passed in the request.
* full_name: A string representation of the user's full name.
* 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
* letter_grade: A letter grade as defined in grading policy (e.g. 'A' 'B' 'C' for 6.002x) or None
* progress_page_url: A link to the user's progress page.
* section_breakdown: A list of subsection grade details, as specified below.
* aggregates: A dict containing earned and possible scores (floats), broken down by subsection type
(e.g. "Exam", "Homework", "Lab").
A response for all user's grades in the course is paginated, and contains "count", "next" and "previous"
keys, along with the actual data contained in a "results" list.
An HTTP 404 may be returned for the following reasons:
* The requested course_key is invalid.
* No course corresponding to the requested key exists.
* No user corresponding to the requested username exists.
* The requested user is not enrolled in the requested course.
An HTTP 403 may be returned if the `writable_gradebook` feature is not
enabled for this course.
**Example GET Response**
{
"course_id": "course-v1:edX+DemoX+Demo_Course",
"email": "staff@example.com",
"user_id": 9,
"username": "staff",
"full_name": "",
"passed": false,
"percent": 0.36,
"letter_grade": null,
"progress_page_url": "/courses/course-v1:edX+DemoX+Demo_Course/progress/9/",
"section_breakdown": [
{
"are_grades_published": true,
"auto_grade": false,
"category": null,
"chapter_name": "Introduction",
"comment": "",
"detail": "",
"displayed_value": "0.00",
"is_graded": false,
"grade_description": "(0.00/0.00)",
"is_ag": false,
"is_average": false,
"is_manually_graded": false,
"label": null,
"letter_grade": null,
"module_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@edx_introduction",
"percent": 0.0,
"score_earned": 0.0,
"score_possible": 0.0,
"section_block_id": "block-v1:edX+DemoX+Demo_Course+type@chapter+block@abcdefgh123",
"subsection_name": "Demo Course Overview"
},
],
"aggregates": {
"Exam": {
"score_possible": 6.0,
"score_earned": 0.0
},
"Homework": {
"score_possible": 16.0,
"score_earned": 10.0
}
}
}
**Paginated GET response**
When requesting gradebook entries for all users, the response is paginated and contains the following values:
* count: The total number of user gradebook entries for this course.
* next: The URL containing the next page of data.
* previous: The URL containing the previous page of data.
* results: A list of user gradebook entries, structured as above.
Note: It's important that `GradeViewMixin` is the first inherited class here, so that
self.api_error returns error responses as expected.
"""
authentication_classes = (
JwtAuthentication,
OAuth2AuthenticationAllowInactiveUser,
SessionAuthenticationAllowInactiveUser,
)
permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,)
required_scopes = ['grades:read']
pagination_class = GradebookPagination
def _section_breakdown(self, course_grade):
"""
Given a course_grade, returns a list of grade data broken down by subsection
and a dictionary containing aggregate grade data by subsection format for the course.
Args:
course_grade: A CourseGrade object.
"""
breakdown = []
aggregates = defaultdict(lambda: defaultdict(float))
# TODO: https://openedx.atlassian.net/browse/EDUCATOR-3559
# Fields we may not need:
# ['are_grades_published', 'auto_grade', 'comment', 'detail', 'is_ag', 'is_average', 'is_manually_graded']
# Some fields should be renamed:
# 'displayed_value' should maybe be 'description_percent'
# 'grade_description' should be 'description_ratio'
label_finder = SubsectionLabelFinder(course_grade)
for chapter_location, section_data in course_grade.chapter_grades.items():
for subsection_grade in section_data['sections']:
breakdown.append({
'are_grades_published': True,
'auto_grade': False,
'category': subsection_grade.format,
'chapter_name': section_data['display_name'],
'comment': '',
'detail': '',
'displayed_value': '{:.2f}'.format(subsection_grade.percent_graded),
'is_graded': subsection_grade.graded,
'grade_description': '({earned:.2f}/{possible:.2f})'.format(
earned=subsection_grade.graded_total.earned,
possible=subsection_grade.graded_total.possible,
),
'is_ag': False,
'is_average': False,
'is_manually_graded': False,
'label': label_finder.get_label(subsection_grade.display_name),
'letter_grade': course_grade.letter_grade,
'module_id': text_type(subsection_grade.location),
'percent': subsection_grade.percent_graded,
'score_earned': subsection_grade.graded_total.earned,
'score_possible': subsection_grade.graded_total.possible,
'section_block_id': text_type(chapter_location),
'subsection_name': subsection_grade.display_name,
})
if subsection_grade.graded and subsection_grade.graded_total.possible > 0:
aggregates[subsection_grade.format]['score_earned'] += subsection_grade.graded_total.earned
aggregates[subsection_grade.format]['score_possible'] += subsection_grade.graded_total.possible
return breakdown, aggregates
def _gradebook_entry(self, user, course, course_grade):
"""
Returns a dictionary of course- and subsection-level grade data for
a given user in a given course.
Args:
user: A User object.
course: A Course Descriptor object.
course_grade: A CourseGrade object.
"""
user_entry = self._serialize_user_grade(user, course.id, course_grade)
breakdown, aggregates = self._section_breakdown(course_grade)
user_entry['section_breakdown'] = breakdown
user_entry['aggregates'] = aggregates
user_entry['progress_page_url'] = reverse(
'student_progress',
kwargs=dict(course_id=text_type(course.id), student_id=user.id)
)
user_entry['user_id'] = user.id
user_entry['full_name'] = user.get_full_name()
return user_entry
@verify_course_exists
@verify_writable_gradebook_enabled
def get(self, request, course_id):
"""
Returns a gradebook entry/entries (i.e. both course and subsection-level grade data)
for all users enrolled in a course, or a single user enrolled in a course
if a `username` parameter is provided.
Args:
request: A Django request object.
course_id: A string representation of a CourseKey object.
"""
username = request.GET.get('username')
course_key = get_course_key(request, course_id)
course = get_course_with_access(request.user, 'staff', course_key, depth=None)
if username:
with self._get_user_or_raise(request, course_id) as grade_user:
course_grade = CourseGradeFactory().read(grade_user, course)
entry = self._gradebook_entry(grade_user, course, course_grade)
serializer = StudentGradebookEntrySerializer(entry)
return Response(serializer.data)
else:
# list gradebook data for all course enrollees
entries = []
for user, course_grade, exc in self._iter_user_grades(course_key):
if not exc:
entries.append(self._gradebook_entry(user, course, course_grade))
serializer = StudentGradebookEntrySerializer(entries, many=True)
return self.get_paginated_response(serializer.data)

View File

@@ -14,6 +14,7 @@ DISABLE_REGRADE_ON_POLICY_CHANGE = u'disable_regrade_on_policy_change'
# Course Flags
REJECTED_EXAM_OVERRIDES_GRADE = u'rejected_exam_overrides_grade'
ENFORCE_FREEZE_GRADE_AFTER_COURSE_END = u'enforce_freeze_grade_after_course_end'
WRITABLE_GRADEBOOK = u'writable_gradebook'
def waffle():
@@ -39,5 +40,11 @@ def waffle_flags():
namespace,
ENFORCE_FREEZE_GRADE_AFTER_COURSE_END,
flag_undefined_default=True,
)
),
# By default, do not enable a gradebook with writable grades. Can be enabled on per-course basis.
WRITABLE_GRADEBOOK: CourseWaffleFlag(
namespace,
WRITABLE_GRADEBOOK,
flag_undefined_default=False,
),
}

View File

@@ -27,7 +27,6 @@ from openedx.core.lib.time_zone_utils import get_display_time_zone
from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from student.tests.factories import UserFactory
from student.models import get_retired_email_by_email, get_retired_username_by_username
from third_party_auth.tests.testutil import simulate_running_pipeline, ThirdPartyAuthTestMixin
from third_party_auth.tests.utils import (
ThirdPartyOAuthTestMixin, ThirdPartyOAuthTestMixinFacebook, ThirdPartyOAuthTestMixinGoogle