From e5473f5396c87291a2a74f441d93dfc9a307084d Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Wed, 3 Oct 2018 16:03:00 -0400 Subject: [PATCH] Add the read API for course gradebook data (single and multiple users). --- lms/djangoapps/grades/api/serializers.py | 56 ++- lms/djangoapps/grades/api/urls.py | 3 + .../grades/api/v1/tests/test_views.py | 440 +++++++++++++++- lms/djangoapps/grades/api/v1/urls.py | 25 +- lms/djangoapps/grades/api/v1/views.py | 468 +++++++++++++++--- lms/djangoapps/grades/config/waffle.py | 9 +- .../djangoapps/user_api/tests/test_views.py | 1 - 7 files changed, 923 insertions(+), 79 deletions(-) diff --git a/lms/djangoapps/grades/api/serializers.py b/lms/djangoapps/grades/api/serializers.py index 6ecc58f8ed..f282b4e289 100644 --- a/lms/djangoapps/grades/api/serializers.py +++ b/lms/djangoapps/grades/api/serializers.py @@ -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() diff --git a/lms/djangoapps/grades/api/urls.py b/lms/djangoapps/grades/api/urls.py index 37d00378cd..3d648b1985 100644 --- a/lms/djangoapps/grades/api/urls.py +++ b/lms/djangoapps/grades/api/urls.py @@ -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( diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py index 5fae2e94af..1812f90197 100644 --- a/lms/djangoapps/grades/api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/api/v1/tests/test_views.py @@ -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) diff --git a/lms/djangoapps/grades/api/v1/urls.py b/lms/djangoapps/grades/api/v1/urls.py index e695314d3b..280651dc04 100644 --- a/lms/djangoapps/grades/api/v1/urls.py +++ b/lms/djangoapps/grades/api/v1/urls.py @@ -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' ), ] diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index 9d05b2cfd3..c5b747e452 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -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) diff --git a/lms/djangoapps/grades/config/waffle.py b/lms/djangoapps/grades/config/waffle.py index da1f02be37..8a8537bbee 100644 --- a/lms/djangoapps/grades/config/waffle.py +++ b/lms/djangoapps/grades/config/waffle.py @@ -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, + ), } diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index 56a1d2a571..c389a3486c 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -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