Merge pull request #19587 from edx/rir/audit

Create GET route for Subsection Grades, Overrides, and the Override H…
This commit is contained in:
Richard I Reilly
2019-01-15 14:08:34 -05:00
committed by GitHub
4 changed files with 443 additions and 1 deletions

View File

@@ -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)

View File

@@ -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)

View File

@@ -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)

View File

@@ -38,4 +38,9 @@ urlpatterns = [
gradebook_views.CourseGradingView.as_view(),
name='course_gradebook_grading_info'
),
url(
r'^subsection/(?P<subsection_id>.*)/$',
gradebook_views.SubsectionGradeView.as_view(),
name='course_grade_overrides'
),
]