diff --git a/lms/djangoapps/grades/api/serializers.py b/lms/djangoapps/grades/api/serializers.py index 242d4fd504..e8d8d41302 100644 --- a/lms/djangoapps/grades/api/serializers.py +++ b/lms/djangoapps/grades/api/serializers.py @@ -60,3 +60,46 @@ class StudentGradebookEntrySerializer(serializers.Serializer): letter_grade = serializers.CharField() progress_page_url = serializers.CharField() section_breakdown = SectionBreakdownSerializer(many=True) + + +class SubsectionGradeOverrideSerializer(serializers.Serializer): + """ + Serializer for subsection grade override. + """ + earned_all_override = serializers.FloatField() + possible_all_override = serializers.FloatField() + earned_graded_override = serializers.FloatField() + possible_graded_override = serializers.FloatField() + + +class SubsectionGradeSerializer(serializers.Serializer): + """ + Serializer for subsection grade. + """ + earned_all = serializers.FloatField() + possible_all = serializers.FloatField() + earned_graded = serializers.FloatField() + possible_graded = serializers.FloatField() + + +class SubsectionGradeOverrideHistorySerializer(serializers.Serializer): + """ + Serializer for subsection grade override history. + """ + user = serializers.CharField() + comments = serializers.CharField() + created = serializers.DateTimeField() + feature = serializers.CharField() + action = serializers.CharField() + + +class SubsectionGradeResponseSerializer(serializers.Serializer): + """ + Serializer for subsection grade response. + """ + subsection_id = serializers.CharField() + user_id = serializers.IntegerField() + course_id = serializers.CharField() + original_grade = SubsectionGradeSerializer() + override = SubsectionGradeOverrideSerializer() + history = SubsectionGradeOverrideHistorySerializer(many=True) diff --git a/lms/djangoapps/grades/api/v1/gradebook_views.py b/lms/djangoapps/grades/api/v1/gradebook_views.py index 5f883c162d..b6b83661a4 100644 --- a/lms/djangoapps/grades/api/v1/gradebook_views.py +++ b/lms/djangoapps/grades/api/v1/gradebook_views.py @@ -10,11 +10,12 @@ from django.urls import reverse from rest_framework import status from rest_framework.generics import GenericAPIView from rest_framework.response import Response +from rest_framework.views import APIView from six import text_type from util.date_utils import to_timestamp from courseware.courses import get_course_by_id -from lms.djangoapps.grades.api.serializers import StudentGradebookEntrySerializer +from lms.djangoapps.grades.api.serializers import StudentGradebookEntrySerializer, SubsectionGradeResponseSerializer from lms.djangoapps.grades.api.v1.utils import ( USER_MODEL, CourseEnrollmentPagination, @@ -778,3 +779,160 @@ class GradebookBulkUpdateView(GradeViewMixin, PaginatedAPIView): subsection_grade_override, success ) + + +@view_auth_classes() +class SubsectionGradeView(GradeViewMixin, APIView): + """ + **Use Case** + * This api is to get information about a users original grade for a subsection. + It also exposes any overrides that now replace the original grade and a history of user changes + with time stamps of all changes. + **Example Request** + GET /api/grades/v1/subsection/{subsection_id}/?user_id={user_id} + **GET Parameters** + A GET request may include the following query parameters. + * user_id: (required) An integer represenation of a user + **GET Response Values** + If the request for subsection grade data is successful, + an HTTP 200 "OK" response is returned. + The HTTP 200 response has the following values: + * subsection_id: A string representation of the usage_key for a course subsection + * user_id: The user's integer id + * course_id: A string representation of a Course ID. + * original_grade: An object representation of a users original grade containing: + * earned_all: The float score a user earned for all graded and not graded problems + * possible_all: The float highest score a user can earn for all graded and not graded problems + * earned_graded: The float score a user earned for only graded probles + * possible_graded: The float highest score a user can earn for only graded problems + * override: An object representation of an over ride for a user's subsection grade containing: + * earned_all_override: The float overriden score a user earned for all graded and not graded problems + * possible_all_override: The float overriden highest score a user can earn for all graded + and not graded problems + * earned_graded_override: The float overriden grade a user earned for only graded problems + * possible_graded_override: The float overriden highest possible grade a user can earn + for only graded problems + * history: A list of history objects that contain + * user: The string representation of the user who was responsible for overriding the grade + * comments: A string comment about why that person changed the grade + * created: The date timestamp the grade was changed + * feature: The string representation of the feature through which the grade was overrriden + * action: The string representation of the CRUD action the override did + + An HTTP 404 may be returned for the following reasons: + * The requested subsection_id is invalid. + * The requested user_id is invalid. + * NOTE: if you pass in a valid subsection_id and a valid user_id with no data representation in the DB + then you will still recieve a 200 with a response with 'original_grade', 'override' and 'course_id' + set to None and the 'history' list will be empty. + + **Example GET Response** + { + "subsection_id": "block-v1:edX+DemoX+Demo_Course+type@sequential+block@basic_questions", + "user_id": 2, + "course_id": "course-v1:edX+DemoX+Demo_Course", + "original_grade": { + "earned_all": 0, + "possible_all": 11, + "earned_graded": 8, + "possible_graded": 11 + }, + "override": { + "earned_all_override": null, + "possible_all_override": null, + "earned_graded_override": 8, + "possible_graded_override": null + }, + "history": [ + { + "user": "edx", + "comments": null, + "created": "2018-12-03T18:52:36.087134Z", + "feature": "GRADEBOOK", + "action": "CREATEORUPDATE" + }, + { + "user": "edx", + "comments": null, + "created": "2018-12-03T20:41:02.507685Z", + "feature": "GRADEBOOK", + "action": "CREATEORUPDATE" + }, + { + "user": "edx", + "comments": null, + "created": "2018-12-03T20:46:08.933387Z", + "feature": "GRADEBOOK", + "action": "CREATEORUPDATE" + } + ] + } + """ + + def get(self, request, subsection_id): + """ + Returns subection grade data, override grade data and a history of changes made to + a specific users specific subsection grade. + + Args: + subsection_id: String representation of a usage_key, which is an opaque key of + a persistant subection grade. + user_id: An integer represenation of a user + + """ + try: + usage_key = UsageKey.from_string(subsection_id) + except InvalidKeyError: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='Invalid UsageKey', + error_code='invalid_usage_key' + ) + + if not has_course_author_access(request.user, usage_key.course_key): + raise DeveloperErrorViewMixin.api_error( + status_code=status.HTTP_403_FORBIDDEN, + developer_message='The requesting user does not have course author permissions.', + error_code='user_permissions', + ) + + try: + user_id = int(request.GET.get('user_id')) + except ValueError: + raise self.api_error( + status_code=status.HTTP_404_NOT_FOUND, + developer_message='Invalid UserID', + error_code='invalid_user_id' + ) + + try: + original_grade = PersistentSubsectionGrade.read_grade(user_id, usage_key) + except PersistentSubsectionGrade.DoesNotExist: + results = SubsectionGradeResponseSerializer({ + 'original_grade': None, + 'override': None, + 'history': [], + 'subsection_id': usage_key, + 'user_id': user_id, + 'course_id': None, + }) + + return Response(results.data) + + try: + override = original_grade.override + history = PersistentSubsectionGradeOverrideHistory.objects.filter(override_id=override.id) + except PersistentSubsectionGradeOverride.DoesNotExist: + override = None + history = [] + + results = SubsectionGradeResponseSerializer({ + 'original_grade': original_grade, + 'override': override, + 'history': history, + 'subsection_id': original_grade.usage_key, + 'user_id': original_grade.user_id, + 'course_id': original_grade.course_id, + }) + + return Response(results.data) diff --git a/lms/djangoapps/grades/api/v1/tests/test_gradebook_views.py b/lms/djangoapps/grades/api/v1/tests/test_gradebook_views.py index dea04f01b5..134dc565ee 100644 --- a/lms/djangoapps/grades/api/v1/tests/test_gradebook_views.py +++ b/lms/djangoapps/grades/api/v1/tests/test_gradebook_views.py @@ -9,7 +9,9 @@ from datetime import datetime import ddt from django.core.urlresolvers import reverse +from freezegun import freeze_time from mock import MagicMock, patch +from opaque_keys.edx.locator import BlockUsageLocator from pytz import UTC from rest_framework import status from rest_framework.test import APITestCase @@ -23,7 +25,10 @@ from lms.djangoapps.grades.config.waffle import WRITABLE_GRADEBOOK, waffle_flags from lms.djangoapps.grades.course_data import CourseData from lms.djangoapps.grades.course_grade import CourseGrade from lms.djangoapps.grades.models import ( + BlockRecord, + BlockRecordList, PersistentSubsectionGrade, + PersistentSubsectionGradeOverride, PersistentSubsectionGradeOverrideHistory ) from lms.djangoapps.grades.subsection_grade import ReadSubsectionGrade @@ -1204,3 +1209,234 @@ class GradebookBulkUpdateViewTest(GradebookViewTestBase): self.assertIsNotNone(audit_item.created) self.assertEqual(audit_item.feature, PersistentSubsectionGradeOverrideHistory.GRADEBOOK) self.assertEqual(audit_item.action, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE) + + +@ddt.ddt +class SubsectionGradeViewTest(GradebookViewTestBase): + """ Test for the audit api call """ + @classmethod + def setUpClass(cls): + super(SubsectionGradeViewTest, cls).setUpClass() + cls.namespaced_url = 'grades_api:v1:course_grade_overrides' + cls.locator_a = BlockUsageLocator( + course_key=cls.course_key, + block_type='problem', + block_id='block_id_a' + ) + cls.locator_b = BlockUsageLocator( + course_key=cls.course_key, + block_type='problem', + block_id='block_id_b' + ) + cls.record_a = BlockRecord(locator=cls.locator_a, weight=1, raw_possible=10, graded=False) + cls.record_b = BlockRecord(locator=cls.locator_b, weight=1, raw_possible=10, graded=True) + cls.block_records = BlockRecordList([cls.record_a, cls.record_b], cls.course_key) + cls.usage_key = cls.subsections[cls.chapter_1.location][0].location + cls.user_id = 12345 + cls.params = { + "user_id": cls.user_id, + "usage_key": cls.usage_key, + "course_version": "deadbeef", + "subtree_edited_timestamp": "2016-08-01 18:53:24.354741Z", + "earned_all": 6.0, + "possible_all": 12.0, + "earned_graded": 6.0, + "possible_graded": 8.0, + "visible_blocks": cls.block_records, + "first_attempted": datetime(2000, 1, 1, 12, 30, 45, tzinfo=UTC), + } + cls.grade = PersistentSubsectionGrade.update_or_create_grade(**cls.params) + + def get_url(self, subsection_id=None, user_id=None): # pylint: disable=arguments-differ + """ + Helper function to create the course gradebook API url. + """ + base_url = reverse( + self.namespaced_url, + kwargs={ + 'subsection_id': subsection_id or self.subsection_id, + } + ) + return "{0}?user_id={1}".format(base_url, user_id or self.user_id) + + @ddt.data( + 'login_staff', + 'login_course_admin', + 'login_course_staff', + ) + def test_no_override(self, login_method): + getattr(self, login_method)() + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key) + ) + + expected_data = { + 'original_grade': OrderedDict([ + ('earned_all', 6.0), + ('possible_all', 12.0), + ('earned_graded', 6.0), + ('possible_graded', 8.0) + ]), + 'user_id': 12345, + 'override': None, + 'course_id': text_type(self.course_key), + 'subsection_id': text_type(self.usage_key), + 'history': [] + } + + self.assertEqual(expected_data, resp.data) + + @ddt.data( + 'login_staff', + 'login_course_admin', + 'login_course_staff', + ) + def test_with_override_no_history(self, login_method): + getattr(self, login_method)() + + override = PersistentSubsectionGradeOverride.objects.create( + grade=self.grade, + earned_all_override=0.0, + possible_all_override=12.0, + earned_graded_override=0.0, + possible_graded_override=8.0 + ) + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key) + ) + + expected_data = { + 'original_grade': OrderedDict([ + ('earned_all', 6.0), + ('possible_all', 12.0), + ('earned_graded', 6.0), + ('possible_graded', 8.0) + ]), + 'user_id': 12345, + 'override': OrderedDict([ + ('earned_all_override', 0.0), + ('possible_all_override', 12.0), + ('earned_graded_override', 0.0), + ('possible_graded_override', 8.0) + ]), + 'course_id': text_type(self.course_key), + 'subsection_id': text_type(self.usage_key), + 'history': [] + } + + self.assertEqual(expected_data, resp.data) + + @ddt.data( + 'login_staff', + 'login_course_admin', + 'login_course_staff', + ) + @freeze_time('2019-01-01') + def test_with_override_with_history(self, login_method): + getattr(self, login_method)() + + override = PersistentSubsectionGradeOverride.update_or_create_override( + requesting_user=self.global_staff, + subsection_grade_model=self.grade, + earned_all_override=0.0, + earned_graded_override=0.0, + feature=PersistentSubsectionGradeOverrideHistory.GRADEBOOK, + ) + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key) + ) + + expected_data = { + 'original_grade': OrderedDict([ + ('earned_all', 6.0), + ('possible_all', 12.0), + ('earned_graded', 6.0), + ('possible_graded', 8.0) + ]), + 'user_id': 12345, + 'override': OrderedDict([ + ('earned_all_override', 0.0), + ('possible_all_override', 12.0), + ('earned_graded_override', 0.0), + ('possible_graded_override', 8.0) + ]), + 'course_id': text_type(self.course_key), + 'subsection_id': text_type(self.usage_key), + 'history': [{ + 'user': self.global_staff.username, + 'comments': None, + 'created': '2019-01-01T00:00:00Z', + 'feature': 'GRADEBOOK', + 'action': 'CREATEORUPDATE' + }] + } + + self.assertEqual(expected_data, resp.data) + + @ddt.data( + 'login_staff', + ) + def test_with_invalid_format_subsection_id(self, login_method): + getattr(self, login_method)() + + resp = self.client.get( + self.get_url(subsection_id='notAValidSubectionId') + ) + + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @ddt.data( + 'login_staff', + ) + def test_with_invalid_format_user_id(self, login_method): + getattr(self, login_method)() + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key, user_id='notAnIntegerUserId') + ) + + self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code) + + @ddt.data( + 'login_staff', + 'login_course_admin', + 'login_course_staff', + ) + def test_with_valid_subsection_id_and_valid_user_id_but_no_record(self, login_method): + getattr(self, login_method)() + + override = PersistentSubsectionGradeOverride.update_or_create_override( + requesting_user=self.global_staff, + subsection_grade_model=self.grade, + earned_all_override=0.0, + earned_graded_override=0.0, + feature=PersistentSubsectionGradeOverrideHistory.GRADEBOOK, + ) + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key, user_id=6789) + ) + + expected_data = { + 'original_grade': None, + 'user_id': 6789, + 'override': None, + 'course_id': None, + 'subsection_id': text_type(self.usage_key), + 'history': [] + } + + self.assertEqual(expected_data, resp.data) + + def test_with_unauthorized_user(self): + student = UserFactory(username='dummy', password='test') + self.client.login(username=student.username, password='test') + + resp = self.client.get( + self.get_url(subsection_id=self.usage_key) + ) + + self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code) diff --git a/lms/djangoapps/grades/api/v1/urls.py b/lms/djangoapps/grades/api/v1/urls.py index f3acf71ce6..0419aca9f5 100644 --- a/lms/djangoapps/grades/api/v1/urls.py +++ b/lms/djangoapps/grades/api/v1/urls.py @@ -38,4 +38,9 @@ urlpatterns = [ gradebook_views.CourseGradingView.as_view(), name='course_gradebook_grading_info' ), + url( + r'^subsection/(?P.*)/$', + gradebook_views.SubsectionGradeView.as_view(), + name='course_grade_overrides' + ), ]