Add the read API for course gradebook data (single and multiple users).
This commit is contained in:
committed by
Alex Dusenbery
parent
5d2919644b
commit
e5473f5396
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user