Merge pull request #19587 from edx/rir/audit
Create GET route for Subsection Grades, Overrides, and the Override H…
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
),
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user