From c11a2c0a6ed5bd1c4cf633ec984f9a57f224303a Mon Sep 17 00:00:00 2001 From: Alex Dusenbery <adusenbery@edx.org> Date: Mon, 15 Oct 2018 16:45:33 -0400 Subject: [PATCH] EDUCATOR-3471 | Add a grade override bulk update view. --- .../grades/api/v1/tests/test_views.py | 289 +++++++++++++++++- lms/djangoapps/grades/api/v1/urls.py | 5 + lms/djangoapps/grades/api/v1/views.py | 239 ++++++++++++++- .../docs/decisions/0001-gradebook-api.rst | 79 +++++ .../decisions/0002-gradebook-front-end.rst | 26 ++ lms/djangoapps/grades/signals/handlers.py | 1 + lms/djangoapps/grades/tasks.py | 8 +- lms/djangoapps/grades/tests/test_tasks.py | 1 + .../core/djangoapps/user_authn/tests/utils.py | 12 +- 9 files changed, 626 insertions(+), 34 deletions(-) create mode 100644 lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst create mode 100644 lms/djangoapps/grades/docs/decisions/0002-gradebook-front-end.rst diff --git a/lms/djangoapps/grades/api/v1/tests/test_views.py b/lms/djangoapps/grades/api/v1/tests/test_views.py index 32286a9949d..46993c5f409 100644 --- a/lms/djangoapps/grades/api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/api/v1/tests/test_views.py @@ -2,7 +2,8 @@ Tests for v1 views """ from __future__ import unicode_literals -from collections import OrderedDict +import json +from collections import OrderedDict, namedtuple from datetime import datetime import ddt @@ -19,6 +20,7 @@ from lms.djangoapps.grades.api.v1.views import CourseGradesView 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.models import PersistentSubsectionGrade from lms.djangoapps.grades.subsection_grade import ReadSubsectionGrade from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin @@ -371,18 +373,18 @@ class CourseGradesViewTest(GradeViewTestMixin, APITestCase): self.assertEqual(expected_data, resp.data) -class GradebookViewTest(GradeViewTestMixin, APITestCase): +class GradebookViewTestBase(GradeViewTestMixin, APITestCase): """ - Tests for the gradebook view. + Base class for the gradebook GET and POST view tests. """ - @classmethod def setUpClass(cls): - super(GradebookViewTest, cls).setUpClass() + super(GradebookViewTestBase, 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_key = cls.course.id cls.course_overview = CourseOverviewFactory.create(id=cls.course.id) cls.chapter_1 = ItemFactory.create( @@ -434,16 +436,34 @@ class GradebookViewTest(GradeViewTestMixin, APITestCase): ], } - def get_url(self, course_key=None, username=None): + def get_url(self, course_key=None): """ - Helper function to create the course gradebook API read url. + Helper function to create the course gradebook API url. """ - base_url = reverse( + return reverse( self.namespaced_url, kwargs={ 'course_id': course_key or self.course_key, } ) + + def login_staff(self): + """ + Helper function to login the global staff user, who has permissions to read from the + Gradebook API. + """ + self.client.login(username=self.global_staff.username, password=self.password) + + +class GradebookViewTest(GradebookViewTestBase): + """ + Tests for the gradebook view. + """ + def get_url(self, course_key=None, username=None): # pylint: disable=arguments-differ + """ + Helper function to create the course gradebook API read url. + """ + base_url = super(GradebookViewTest, self).get_url(course_key) if username: return "{0}?username={1}".format(base_url, username) return base_url @@ -504,13 +524,6 @@ class GradebookViewTest(GradeViewTestMixin, APITestCase): ]) 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.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. @@ -772,3 +785,249 @@ class GradebookViewTest(GradeViewTestMixin, APITestCase): self.assertEqual(status.HTTP_200_OK, resp.status_code) actual_data = dict(resp.data) self.assertEqual(expected_results, actual_data) + + +class GradebookBulkUpdateViewTest(GradebookViewTestBase): + """ + Tests for the gradebook bulk-update view. + """ + @classmethod + def setUpClass(cls): + super(GradebookBulkUpdateViewTest, cls).setUpClass() + cls.namespaced_url = 'grades_api:v1:course_gradebook_bulk_update' + + def test_feature_not_enabled(self): + self.client.login(username=self.global_staff.username, password=self.password) + with override_waffle_flag(self.waffle_flag, active=False): + resp = self.client.post( + self.get_url(course_key=self.empty_course.id) + ) + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) + + def test_anonymous(self): + with override_waffle_flag(self.waffle_flag, active=True): + resp = self.client.post(self.get_url()) + self.assertEqual(status.HTTP_401_UNAUTHORIZED, resp.status_code) + + def test_student(self): + self.client.login(username=self.student.username, password=self.password) + with override_waffle_flag(self.waffle_flag, active=True): + resp = self.client.post(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.post( + 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_not_enrolled(self): + with override_waffle_flag(self.waffle_flag, active=True): + self.login_staff() + unenrolled_student = UserFactory() + post_data = [ + { + 'user_id': unenrolled_student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'grade': {}, # doesn't matter what we put here. + } + ] + + resp = self.client.post( + self.get_url(), + data=json.dumps(post_data), + content_type='application/json', + ) + + expected_data = [ + { + 'user_id': unenrolled_student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'success': False, + 'reason': 'CourseEnrollment matching query does not exist.', + }, + ] + self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code) + self.assertEqual(expected_data, json.loads(resp.data)) + + def test_user_does_not_exist(self): + with override_waffle_flag(self.waffle_flag, active=True): + self.login_staff() + post_data = [ + { + 'user_id': -123, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'grade': {}, # doesn't matter what we put here. + } + ] + + resp = self.client.post( + self.get_url(), + data=json.dumps(post_data), + content_type='application/json', + ) + + expected_data = [ + { + 'user_id': -123, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'success': False, + 'reason': 'User matching query does not exist.', + }, + ] + self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code) + self.assertEqual(expected_data, json.loads(resp.data)) + + def test_invalid_usage_key(self): + with override_waffle_flag(self.waffle_flag, active=True): + self.login_staff() + post_data = [ + { + 'user_id': self.student.id, + 'usage_id': 'not-a-valid-usage-key', + 'grade': {}, # doesn't matter what we put here. + } + ] + + resp = self.client.post( + self.get_url(), + data=json.dumps(post_data), + content_type='application/json', + ) + + expected_data = [ + { + 'user_id': self.student.id, + 'usage_id': 'not-a-valid-usage-key', + 'success': False, + 'reason': "<class 'opaque_keys.edx.locator.BlockUsageLocator'>: not-a-valid-usage-key", + }, + ] + self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code) + self.assertEqual(expected_data, json.loads(resp.data)) + + def test_subsection_does_not_exist(self): + """ + When trying to override a grade for a valid usage key that does not exist in the requested course, + we should get an error reason specifying that the key does not exist in the course. + """ + with override_waffle_flag(self.waffle_flag, active=True): + self.login_staff() + usage_id = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow' + post_data = [ + { + 'user_id': self.student.id, + 'usage_id': usage_id, + 'grade': {}, # doesn't matter what we put here. + } + ] + + resp = self.client.post( + self.get_url(), + data=json.dumps(post_data), + content_type='application/json', + ) + + expected_data = [ + { + 'user_id': self.student.id, + 'usage_id': usage_id, + 'success': False, + 'reason': 'usage_key {} does not exist in this course.'.format(usage_id), + }, + ] + self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code) + self.assertEqual(expected_data, json.loads(resp.data)) + + def test_override_is_created(self): + """ + Test that when we make multiple requests to update grades for the same user/subsection, + the score from the most recent request is recorded. + """ + with override_waffle_flag(self.waffle_flag, active=True): + self.login_staff() + post_data = [ + { + 'user_id': self.student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'grade': { + 'earned_all_override': 3, + 'possible_all_override': 3, + 'earned_graded_override': 2, + 'possible_graded_override': 2, + }, + }, + { + 'user_id': self.student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][1].location), + 'grade': { + 'earned_all_override': 1, + 'possible_all_override': 4, + 'earned_graded_override': 1, + 'possible_graded_override': 4, + }, + } + ] + + resp = self.client.post( + self.get_url(), + data=json.dumps(post_data), + content_type='application/json', + ) + + expected_data = [ + { + 'user_id': self.student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][0].location), + 'success': True, + 'reason': None, + }, + { + 'user_id': self.student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][1].location), + 'success': True, + 'reason': None, + }, + ] + self.assertEqual(status.HTTP_202_ACCEPTED, resp.status_code) + self.assertEqual(expected_data, json.loads(resp.data)) + + second_post_data = [ + { + 'user_id': self.student.id, + 'usage_id': text_type(self.subsections[self.chapter_1.location][1].location), + 'grade': { + 'earned_all_override': 3, + 'possible_all_override': 4, + 'earned_graded_override': 3, + 'possible_graded_override': 4, + }, + }, + ] + + self.client.post( + self.get_url(), + data=json.dumps(second_post_data), + content_type='application/json', + ) + + GradeFields = namedtuple('GradeFields', ['earned_all', 'possible_all', 'earned_graded', 'possible_graded']) + + # We should now have PersistentSubsectionGradeOverride records corresponding to + # our bulk-update request, and PersistentSubsectionGrade records with grade values + # equal to those of the override. + for usage_key, expected_grades in ( + (self.subsections[self.chapter_1.location][0].location, GradeFields(3, 3, 2, 2)), + (self.subsections[self.chapter_1.location][1].location, GradeFields(3, 4, 3, 4)), + ): + # this selects related PersistentSubsectionGradeOverride objects. + grade = PersistentSubsectionGrade.read_grade( + user_id=self.student.id, + usage_key=usage_key, + ) + for field_name in expected_grades._fields: + expected_value = getattr(expected_grades, field_name) + self.assertEqual(expected_value, getattr(grade, field_name)) + self.assertEqual(expected_value, getattr(grade.override, field_name + '_override')) diff --git a/lms/djangoapps/grades/api/v1/urls.py b/lms/djangoapps/grades/api/v1/urls.py index 280651dc04e..5142c8f5107 100644 --- a/lms/djangoapps/grades/api/v1/urls.py +++ b/lms/djangoapps/grades/api/v1/urls.py @@ -29,4 +29,9 @@ urlpatterns = [ views.GradebookView.as_view(), name='course_gradebook' ), + url( + r'^gradebook/{course_id}/bulk-update$'.format(course_id=settings.COURSE_ID_PATTERN), + views.GradebookBulkUpdateView.as_view(), + name='course_gradebook_bulk_update' + ), ] diff --git a/lms/djangoapps/grades/api/v1/views.py b/lms/djangoapps/grades/api/v1/views.py index 8d5aca13451..4744223287a 100644 --- a/lms/djangoapps/grades/api/v1/views.py +++ b/lms/djangoapps/grades/api/v1/views.py @@ -1,6 +1,7 @@ """ API v0 views. """ +import json import logging -from collections import defaultdict +from collections import defaultdict, namedtuple from contextlib import contextmanager from functools import wraps @@ -17,12 +18,16 @@ from courseware.courses import get_course_with_access from edx_rest_framework_extensions import permissions from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import 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.constants import ScoreDatabaseTableEnum +from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory +from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverride +from lms.djangoapps.grades.signals import signals +from lms.djangoapps.grades.subsection_grade import CreateSubsectionGrade from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.keys import CourseKey, UsageKey from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin @@ -106,13 +111,15 @@ class GradeViewMixin(DeveloperErrorViewMixin): """ Mixin class for Grades related views. """ - def _get_single_user(self, request, course_key): + def _get_single_user(self, request, course_key, user_id=None): """ - Returns a single USER_MODEL object corresponding to the request's `username` parameter, - or the current `request.user` if no `username` was provided. + Returns a single USER_MODEL object corresponding to either the user_id provided, or if no id is provided, + then 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. + user_id (int): Optional user id to fetch the user object for. Returns: A USER_MODEL object. @@ -121,15 +128,15 @@ class GradeViewMixin(DeveloperErrorViewMixin): 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') + if user_id: + # May raise USER_MODEL.DoesNotExist if no user with this id exists + grade_user = USER_MODEL.objects.get(id=user_id) else: - username = request.user.username - - grade_user = USER_MODEL.objects.get(username=username) + username = request.GET.get('username') or 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 + # May raise CourseEnrollment.DoesNotExist if no enrollment exists for this user/course. + _ = CourseEnrollment.objects.get(user=grade_user, course_id=course_key) return grade_user @@ -333,7 +340,7 @@ class CourseGradesView(GradeViewMixin, GenericAPIView): if username: # If there is a username passed, get grade for a single user - with self._get_user_or_raise(request, course_id) as grade_user: + with self._get_user_or_raise(request, course_key) 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 @@ -559,7 +566,7 @@ class GradebookView(GradeViewMixin, GenericAPIView): 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: + with self._get_user_or_raise(request, course_key) as grade_user: course_grade = CourseGradeFactory().read(grade_user, course) entry = self._gradebook_entry(grade_user, course, course_grade) @@ -573,3 +580,205 @@ class GradebookView(GradeViewMixin, GenericAPIView): entries.append(self._gradebook_entry(user, course, course_grade)) serializer = StudentGradebookEntrySerializer(entries, many=True) return self.get_paginated_response(serializer.data) + + +GradebookUpdateResponseItem = namedtuple('GradebookUpdateResponseItem', ['user_id', 'usage_id', 'success', 'reason']) + + +class GradebookBulkUpdateView(GradeViewMixin, GenericAPIView): + """ + **Use Case** + Creates `PersistentSubsectionGradeOverride` objects for multiple (user_id, usage_id) + pairs in a given course, and invokes a Django signal to update subsection grades in + an asynchronous celery task. + + **Example Request** + POST /api/grades/v1/gradebook/{course_id}/bulk-update + + **POST Parameters** + This endpoint does not accept any URL parameters. + + **Example POST Data** + [ + { + "user_id": 9, + "usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", + "grade": { + "earned_all_override": 11, + "possible_all_override": 11, + "earned_graded_override": 11, + "possible_graded_override": 11 + } + }, + { + "user_id": 9, + "usage_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@advanced_questions", + "grade": { + "earned_all_override": 10, + "possible_all_override": 15, + "earned_graded_override": 9, + "possible_graded_override": 12 + } + } + ] + + **POST Response Values** + An HTTP 202 may be returned if a grade override was created for each of the requested (user_id, usage_id) + pairs in the request data. + An HTTP 403 may be returned if the `writable_gradebook` feature is not + enabled for this course. + An HTTP 404 may be returned for the following reasons: + * The requested course_key is invalid. + * No course corresponding to the requested key exists. + * The requesting user is not enrolled in the requested course. + An HTTP 422 may be returned if any of the requested (user_id, usage_id) pairs + did not have a grade override created due to some exception. A `reason` detailing the exception + is provided with each response item. + + **Example successful POST Response** + [ + { + "user_id": 9, + "usage_id": "some-requested-usage-id", + "success": true, + "reason": null + }, + { + "user_id": 9, + "usage_id": "an-invalid-usage-id", + "success": false, + "reason": "<class 'opaque_keys.edx.locator.BlockUsageLocator'>: not-a-valid-usage-key" + }, + { + "user_id": 9, + "usage_id": "a-valid-usage-key-that-doesn't-exist", + "success": false, + "reason": "a-valid-usage-key-that-doesn't-exist does not exist in this course" + }, + { + "user_id": 1234-I-DO-NOT-EXIST, + "usage_id": "a-valid-usage-key", + "success": false, + "reason": "User matching query does not exist." + } + ] + """ + authentication_classes = ( + JwtAuthentication, + OAuth2AuthenticationAllowInactiveUser, + SessionAuthenticationAllowInactiveUser, + ) + + permission_classes = (permissions.JWT_RESTRICTED_APPLICATION_OR_USER_ACCESS,) + + required_scopes = ['grades:write'] + + @verify_course_exists + @verify_writable_gradebook_enabled + def post(self, request, course_id): + """ + Creates or updates `PersistentSubsectionGradeOverrides` for the (user_id, usage_key) + specified in the request data. The `SUBSECTION_OVERRIDE_CHANGED` signal is invoked + after the grade override is created, which triggers a celery task to update the + course and subsection grades for the specified user. + """ + course_key = get_course_key(request, course_id) + course = get_course_with_access(request.user, 'staff', course_key, depth=None) + + result = [] + + for user_data in request.data: + requested_user_id = user_data['user_id'] + requested_usage_id = user_data['usage_id'] + try: + user = self._get_single_user(request, course_key, requested_user_id) + usage_key = UsageKey.from_string(requested_usage_id) + except (USER_MODEL.DoesNotExist, InvalidKeyError, CourseEnrollment.DoesNotExist) as exc: + result.append(GradebookUpdateResponseItem( + user_id=requested_user_id, + usage_id=requested_usage_id, + success=False, + reason=text_type(exc) + )) + continue + + try: + subsection_grade_model = PersistentSubsectionGrade.objects.get( + user_id=user.id, + course_id=course_key, + usage_key=usage_key + ) + except PersistentSubsectionGrade.DoesNotExist: + subsection = course.get_child(usage_key) + if subsection: + subsection_grade_model = self._create_subsection_grade(user, course, subsection) + else: + result.append(GradebookUpdateResponseItem( + user_id=requested_user_id, + usage_id=requested_usage_id, + success=False, + reason='usage_key {} does not exist in this course.'.format(usage_key) + )) + continue + + if subsection_grade_model: + self._create_override(subsection_grade_model, **user_data['grade']) + result.append(GradebookUpdateResponseItem( + user_id=user.id, + usage_id=text_type(usage_key), + success=True, + reason=None + )) + + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + if all((item.success for item in result)): + status_code = status.HTTP_202_ACCEPTED + + return Response( + json.dumps([item._asdict() for item in result]), + status=status_code, + content_type='application/json' + ) + + def _create_subsection_grade(self, user, course, subsection): + course_data = CourseData(user, course=course) + subsection_grade = CreateSubsectionGrade(subsection, course_data.structure, {}, {}) + return subsection_grade.update_or_create_model(user, force_update_subsections=True) + + def _create_override(self, subsection_grade_model, **override_data): + """ + Helper method to create a `PersistentSubsectionGradeOverride` object + and send a `SUBSECTION_OVERRIDE_CHANGED` signal. + """ + override, _ = PersistentSubsectionGradeOverride.objects.update_or_create( + grade=subsection_grade_model, + defaults=self._clean_override_data(override_data), + ) + signals.SUBSECTION_OVERRIDE_CHANGED.send( + sender=None, + user_id=subsection_grade_model.user_id, + course_id=text_type(subsection_grade_model.course_id), + usage_id=text_type(subsection_grade_model.usage_key), + only_if_higher=False, + modified=override.modified, + score_deleted=False, + score_db_table=ScoreDatabaseTableEnum.overrides, + force_update_subsections=True, + ) + + def _clean_override_data(self, override_data): + """ + Helper method to strip any grade override field names that won't work + as defaults when calling PersistentSubsectionGradeOverride.update_or_create(). + """ + allowed_fields = { + 'earned_all_override', + 'possible_all_override', + 'earned_graded_override', + 'possible_graded_override', + } + stripped_data = {} + for field in override_data.keys(): + if field in allowed_fields: + stripped_data[field] = override_data[field] + return stripped_data diff --git a/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst b/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst new file mode 100644 index 00000000000..7f7f7a5fe55 --- /dev/null +++ b/lms/djangoapps/grades/docs/decisions/0001-gradebook-api.rst @@ -0,0 +1,79 @@ +1. Gradebook read and write APIs +-------------------------------- + +Status +====== + +Accepted + +Context +======= + +We are implementing a "Writable Gradebook" feature from the instructor dashboard. +This feature supports both the reading of subsection grades (e.g. for HW assignments, Labs, Exams) +and the creation/modification of subsection grades from a user interface. This document captures +decisions related to the design of the Django APIs that support this feature. This feature is heavily +inspired by an implementation provided by Extension Engine (EE). + +Decisions +========= + +#. **Feature-gating** + + a. This feature will be gated behind a `CourseWaffleFlag`. This will allow us to roll out to a few courses + as a time and to defer decisions that may need to be made about the scalability of this feature when + applied to courses with a massive number (i.e. hundreds of thousands) of enrollments. We can eventually + remove the check for this flag. When the flag is not enabled for a course, the endpoints will provide a + response and status code indicating as much. + +#. **The read (GET) API** + + a. The read API supports either fetching subsection scores for a single user, by `username`, or fetching + a paginated result of subsection grade data for all enrollees in the requested course. + + b. We will use the data schema required by the EE's front-end implementation. This will allow us to port + over much of EE's front-end code with only minor modifications. Note that there are some fields specified + in EE's schema that will not be needed in the edX implementation, and we may remove those fields in future + versions of this API. + + c. We will use the Django Rest Framework `CursorPagination` class as the base pagination class for all students' data + in a course. The query set that we paginate is the set of active enrollees for the requested course. As a result + of using this pagination class, paginated responses will not contain a `count` key, and the pagination query + parameter `cursor` will have very opaque values. Furthermore, due to the version of DRF installed in edx-platform, + there is no available page size parameter available to clients of this API (although this can easily be added + to our pagination class). + + d. The same pagination class as above is used as the pagination class for the `CourseGradesView` API. This is for + consistency, and also so that responses from this endpoint will be properly paginated (they previously contained + only the paginated data, and relied on the client "knowing" that further pages were available by using the + `?page=N` query parameter). + +#. **The write (POST) API** + + a. The write API will be a `bulk-update` endpoint that allows for the creation/modification of subsection + grades for multiple users and sections in a single request. This allows clients of the API to limit + the number of network requests made and to more easily manage client-side data. + + b. The write API will act as a `create-or-update` endpoint. That is, if a persistent subsection grade record + does not currently exist for a given user/subsection that the client intends to update, part of the + request involves creating a corresponding persistent subsection grade record in addition to creating an override + associated with that grade. We do this because our grading system makes an assumption that a subsection's + grade value is 0 if no record for that subsection exists, and therefore, a client reading grade data + cannot make a distinction if a given subsection grade is 0 because no attempt or record exists, or if + there is a recorded score somewhere of zero. This should be completely opaque to that client, and thus, + we create a grade if necessary in this endpoint. + + c. We won't fail the entire request if one item in the batch data raises an exception. Instead, we will + report the status (as a boolean value) for each requested grade override item in the request back to the client, + along with a reason for items that have ``success: false`` entry. + + d. A status code of ``422`` will be returned for requests that contain any failed item. This allows a client + to easily tell if any item in their request payload was problematic and needs special handling. If all + requested items succeed, a ``202 (accepted)`` is returned. This status code was chosen because an + asynchronous celery task is enqueued for each subsection grade that needs to be updated. + + e. We have to thread a ``force_update_subsections`` keyword argument through the Django signal invocation + that enqueues the subsection update task. This is because we may be creating a new subsection grade + with no score data available from either ``courseware.StudentModule`` records or from the `Submissions` API. + In this case, the only score data available exists in the grade override record, and the subsection ``update()`` + call should be forced to read from this record. diff --git a/lms/djangoapps/grades/docs/decisions/0002-gradebook-front-end.rst b/lms/djangoapps/grades/docs/decisions/0002-gradebook-front-end.rst new file mode 100644 index 00000000000..f395c230970 --- /dev/null +++ b/lms/djangoapps/grades/docs/decisions/0002-gradebook-front-end.rst @@ -0,0 +1,26 @@ +2. Gradebook User Interface and Experience +------------------------------------------ + +Status +====== + +Proposed + +Context +======= + +We are implementing a "Writable Gradebook" feature from the instructor dashboard. For additional +background see 0001-gradebook-api_. + +.. _0001-gradebook-api: 0001-gradebook-api.rst + +Decisions +========= + +#. **Don't display non-graded sections** + + a. Any subsections that are marked as ``graded: false`` will not be displayed in the UI. This allows + us to ignore problems where such sections don't possess a ``label``. + + b. We will eliminate the `comment` column/field in the modal that allows a user to edit grades. We currently + have no model in which such data can be stored. diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index c900834c3ce..6e288dfbbab 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -227,6 +227,7 @@ def enqueue_subsection_update(sender, **kwargs): # pylint: disable=unused-argum event_transaction_id=unicode(get_event_transaction_id()), event_transaction_type=unicode(get_event_transaction_type()), score_db_table=kwargs['score_db_table'], + force_update_subsections=kwargs.get('force_update_subsections', False), ), countdown=RECALCULATE_GRADE_DELAY_SECONDS, ) diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index 7e09ad7d851..0b5e149c56a 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -237,6 +237,7 @@ def _recalculate_subsection_grade(self, **kwargs): kwargs['only_if_higher'], kwargs['user_id'], kwargs['score_deleted'], + kwargs.get('force_update_subsections', False), ) except Exception as exc: if not isinstance(exc, KNOWN_RETRY_ERRORS): @@ -296,7 +297,9 @@ def _has_db_updated_with_new_score(self, scored_block_usage_key, **kwargs): return db_is_updated -def _update_subsection_grades(course_key, scored_block_usage_key, only_if_higher, user_id, score_deleted): +def _update_subsection_grades( + course_key, scored_block_usage_key, only_if_higher, user_id, score_deleted, force_update_subsections=False +): """ A helper function to update subsection grades in the database for each subsection containing the given block, and to signal @@ -321,7 +324,8 @@ def _update_subsection_grades(course_key, scored_block_usage_key, only_if_higher subsection_grade = subsection_grade_factory.update( course_structure[subsection_usage_key], only_if_higher, - score_deleted + score_deleted, + force_update_subsections, ) SUBSECTION_SCORE_CHANGED.send( sender=None, diff --git a/lms/djangoapps/grades/tests/test_tasks.py b/lms/djangoapps/grades/tests/test_tasks.py index 66fd2e1919b..5400afa90cc 100644 --- a/lms/djangoapps/grades/tests/test_tasks.py +++ b/lms/djangoapps/grades/tests/test_tasks.py @@ -148,6 +148,7 @@ class RecalculateSubsectionGradeTest(HasCourseWithProblemsMixin, ModuleStoreTest send_args = self.problem_weighted_score_changed_kwargs local_task_args = self.recalculate_subsection_grade_kwargs.copy() local_task_args['event_transaction_type'] = u'edx.grades.problem.submitted' + local_task_args['force_update_subsections'] = False with self.mock_csm_get_score() and patch( 'lms.djangoapps.grades.tasks.recalculate_subsection_grade_v3.apply_async', return_value=None diff --git a/openedx/core/djangoapps/user_authn/tests/utils.py b/openedx/core/djangoapps/user_authn/tests/utils.py index 82318cce8f7..306ea9e3014 100644 --- a/openedx/core/djangoapps/user_authn/tests/utils.py +++ b/openedx/core/djangoapps/user_authn/tests/utils.py @@ -4,6 +4,7 @@ from enum import Enum from itertools import product import ddt +import pytz from mock import patch from django.conf import settings from oauth2_provider import models as dot_models @@ -37,6 +38,13 @@ def setup_login_oauth_client(): ) +def utcnow(): + """ + Helper function to return the current UTC time localized to the UTC timezone. + """ + return datetime.now(pytz.UTC) + + @ddt.ddt class AuthAndScopesTestMixin(object): """ @@ -102,7 +110,7 @@ class AuthAndScopesTestMixin(object): return dot_models.AccessToken.objects.create( user=user, application=dot_app, - expires=datetime.utcnow() + timedelta(weeks=1), + expires=utcnow() + timedelta(weeks=1), scope='read write', token='test_token', ) @@ -244,7 +252,7 @@ class AuthAndScopesTestMixin(object): def test_expired_oauth_token(self): token = self._create_oauth_token(self.student) - token.expires = datetime.utcnow() - timedelta(weeks=1) + token.expires = utcnow() - timedelta(weeks=1) token.save() resp = self.get_response(AuthType.oauth, token=token) self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED) -- GitLab