Merge pull request #13285 from mitocw/gdm_current_grade_implementation
Current Grade Rest API implementation
This commit is contained in:
0
lms/djangoapps/grades/api/__init__.py
Normal file
0
lms/djangoapps/grades/api/__init__.py
Normal file
0
lms/djangoapps/grades/api/tests/__init__.py
Normal file
0
lms/djangoapps/grades/api/tests/__init__.py
Normal file
247
lms/djangoapps/grades/api/tests/test_views.py
Normal file
247
lms/djangoapps/grades/api/tests/test_views.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""
|
||||
Tests for the views
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import ddt
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import patch
|
||||
from opaque_keys import InvalidKeyError
|
||||
from pytz import UTC
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.grades.tests.utils import mock_get_score
|
||||
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
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
"""
|
||||
Tests for the Current Grade View
|
||||
|
||||
The following tests assume that the grading policy is the edX default one:
|
||||
{
|
||||
"GRADER": [
|
||||
{
|
||||
"drop_count": 2,
|
||||
"min_count": 12,
|
||||
"short_label": "HW",
|
||||
"type": "Homework",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"drop_count": 2,
|
||||
"min_count": 12,
|
||||
"type": "Lab",
|
||||
"weight": 0.15
|
||||
},
|
||||
{
|
||||
"drop_count": 0,
|
||||
"min_count": 1,
|
||||
"short_label": "Midterm",
|
||||
"type": "Midterm Exam",
|
||||
"weight": 0.3
|
||||
},
|
||||
{
|
||||
"drop_count": 0,
|
||||
"min_count": 1,
|
||||
"short_label": "Final",
|
||||
"type": "Final Exam",
|
||||
"weight": 0.4
|
||||
}
|
||||
],
|
||||
"GRADE_CUTOFFS": {
|
||||
"Pass": 0.5
|
||||
}
|
||||
}
|
||||
"""
|
||||
MODULESTORE = TEST_DATA_SPLIT_MODULESTORE
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CurrentGradeViewTest, cls).setUpClass()
|
||||
|
||||
cls.course = CourseFactory.create(display_name='test course', run="Testing_course")
|
||||
|
||||
chapter = ItemFactory.create(
|
||||
category='chapter',
|
||||
parent_location=cls.course.location,
|
||||
display_name="Chapter 1",
|
||||
)
|
||||
# create a problem for each type and minimum count needed by the grading policy
|
||||
# A section is not considered if the student answers less than "min_count" problems
|
||||
for grading_type, min_count in (("Homework", 12), ("Lab", 12), ("Midterm Exam", 1), ("Final Exam", 1)):
|
||||
for num in xrange(min_count):
|
||||
section = ItemFactory.create(
|
||||
category='sequential',
|
||||
parent_location=chapter.location,
|
||||
due=datetime(2013, 9, 18, 11, 30, 00),
|
||||
display_name='Sequential {} {}'.format(grading_type, num),
|
||||
format=grading_type,
|
||||
graded=True,
|
||||
)
|
||||
vertical = ItemFactory.create(
|
||||
category='vertical',
|
||||
parent_location=section.location,
|
||||
display_name='Vertical {} {}'.format(grading_type, num),
|
||||
)
|
||||
ItemFactory.create(
|
||||
category='problem',
|
||||
parent_location=vertical.location,
|
||||
display_name='Problem {} {}'.format(grading_type, num),
|
||||
)
|
||||
|
||||
cls.course_key = cls.course.id
|
||||
|
||||
cls.password = 'test'
|
||||
cls.student = UserFactory(username='dummy', password=cls.password)
|
||||
cls.other_student = UserFactory(username='foo', password=cls.password)
|
||||
cls.other_user = UserFactory(username='bar', password=cls.password)
|
||||
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.namespaced_url = 'grades_api:user_grade_detail'
|
||||
|
||||
def setUp(self):
|
||||
super(CurrentGradeViewTest, self).setUp()
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
|
||||
def get_url(self, username):
|
||||
"""
|
||||
Helper function to create the url
|
||||
"""
|
||||
base_url = reverse(
|
||||
self.namespaced_url,
|
||||
kwargs={
|
||||
'course_id': self.course_key,
|
||||
}
|
||||
)
|
||||
return "{0}?username={1}".format(base_url, username)
|
||||
|
||||
def test_anonymous(self):
|
||||
"""
|
||||
Test that an anonymous user cannot access the API and an error is received.
|
||||
"""
|
||||
self.client.logout()
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_self_get_grade(self):
|
||||
"""
|
||||
Test that a user can successfully request her own grade.
|
||||
"""
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
|
||||
def test_nonexistent_user(self):
|
||||
"""
|
||||
Test that a request for a nonexistent username returns an error.
|
||||
"""
|
||||
resp = self.client.get(self.get_url('IDoNotExist'))
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('error_code', resp.data) # pylint: disable=no-member
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch') # pylint: disable=no-member
|
||||
|
||||
def test_other_get_grade(self):
|
||||
"""
|
||||
Test that if a user requests the grade for another user, she receives an error.
|
||||
"""
|
||||
self.client.logout()
|
||||
self.client.login(username=self.other_student.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('error_code', resp.data) # pylint: disable=no-member
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch') # pylint: disable=no-member
|
||||
|
||||
def test_self_get_grade_not_enrolled(self):
|
||||
"""
|
||||
Test that a user receives an error if she requests
|
||||
her own grade in a course where she is not enrolled.
|
||||
"""
|
||||
# a user not enrolled in the course cannot request her grade
|
||||
self.client.logout()
|
||||
self.client.login(username=self.other_user.username, password=self.password)
|
||||
resp = self.client.get(self.get_url(self.other_user.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('error_code', resp.data) # pylint: disable=no-member
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
'user_or_course_does_not_exist'
|
||||
)
|
||||
|
||||
def test_wrong_course_key(self):
|
||||
"""
|
||||
Test that a request for an invalid course key returns an error.
|
||||
"""
|
||||
def mock_from_string(*args, **kwargs): # pylint: disable=unused-argument
|
||||
"""Mocked function to always raise an exception"""
|
||||
raise InvalidKeyError('foo', 'bar')
|
||||
with patch('opaque_keys.edx.keys.CourseKey.from_string', side_effect=mock_from_string):
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('error_code', resp.data) # pylint: disable=no-member
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
'invalid_course_key'
|
||||
)
|
||||
|
||||
def test_course_does_not_exist(self):
|
||||
"""
|
||||
Test that requesting a valid, nonexistent course key returns an error as expected.
|
||||
"""
|
||||
base_url = reverse(
|
||||
self.namespaced_url,
|
||||
kwargs={
|
||||
'course_id': 'course-v1:MITx+8.MechCX+2014_T1',
|
||||
}
|
||||
)
|
||||
url = "{0}?username={1}".format(base_url, self.student.username)
|
||||
resp = self.client.get(url)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
self.assertIn('error_code', resp.data) # pylint: disable=no-member
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
'user_or_course_does_not_exist'
|
||||
)
|
||||
|
||||
def test_no_grade(self):
|
||||
"""
|
||||
Test the grade for a user who has not answered any test.
|
||||
"""
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
expected_data = [{
|
||||
'username': self.student.username,
|
||||
'letter_grade': None,
|
||||
'percent': 0.0,
|
||||
'course_key': str(self.course_key),
|
||||
'passed': False
|
||||
}]
|
||||
self.assertEqual(resp.data, expected_data) # pylint: disable=no-member
|
||||
|
||||
@ddt.data(
|
||||
((2, 5), {'letter_grade': None, 'percent': 0.4, 'passed': False}),
|
||||
((5, 5), {'letter_grade': 'Pass', 'percent': 1, 'passed': True}),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_grade(self, grade, result):
|
||||
"""
|
||||
Test that the user gets her grade in case she answered tests with an insufficient score.
|
||||
"""
|
||||
with mock_get_score(*grade):
|
||||
resp = self.client.get(self.get_url(self.student.username))
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
expected_data = {
|
||||
'username': self.student.username,
|
||||
'course_key': str(self.course_key),
|
||||
}
|
||||
expected_data.update(result)
|
||||
self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member
|
||||
18
lms/djangoapps/grades/api/urls.py
Normal file
18
lms/djangoapps/grades/api/urls.py
Normal file
@@ -0,0 +1,18 @@
|
||||
""" Grades API URLs. """
|
||||
from django.conf import settings
|
||||
from django.conf.urls import (
|
||||
patterns,
|
||||
url,
|
||||
)
|
||||
|
||||
from lms.djangoapps.grades.api import views
|
||||
|
||||
urlpatterns = patterns(
|
||||
'',
|
||||
url(
|
||||
r'^v0/course_grade/{course_id}/users/$'.format(
|
||||
course_id=settings.COURSE_ID_PATTERN
|
||||
),
|
||||
views.UserGradeView.as_view(), name='user_grade_detail'
|
||||
),
|
||||
)
|
||||
144
lms/djangoapps/grades/api/views.py
Normal file
144
lms/djangoapps/grades/api/views.py
Normal file
@@ -0,0 +1,144 @@
|
||||
""" API v0 views. """
|
||||
import logging
|
||||
|
||||
from django.http import Http404
|
||||
from opaque_keys import InvalidKeyError
|
||||
from opaque_keys.edx.keys import CourseKey
|
||||
from rest_framework import status
|
||||
from rest_framework.authentication import SessionAuthentication
|
||||
from rest_framework.generics import GenericAPIView
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from rest_framework.response import Response
|
||||
|
||||
from lms.djangoapps.ccx.utils import prep_course_for_grading
|
||||
from lms.djangoapps.courseware import courses
|
||||
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
|
||||
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UserGradeView(DeveloperErrorViewMixin, GenericAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
* Get the current course grades for users in a course.
|
||||
Currently, getting the grade for only an individual user is supported.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/grades/v0/course_grade/{course_id}/users/?username={username}
|
||||
|
||||
**GET Parameters**
|
||||
|
||||
A GET request must include the following parameters.
|
||||
|
||||
* course_id: A string representation of a Course ID.
|
||||
* username: A string representation of a user's username.
|
||||
|
||||
**GET Response Values**
|
||||
|
||||
If the request for information about the course grade
|
||||
is successful, an HTTP 200 "OK" response is returned.
|
||||
|
||||
The HTTP 200 response has the following values.
|
||||
|
||||
* username: A string representation of a user's username passed in the request.
|
||||
|
||||
* course_id: A string representation of a Course ID.
|
||||
|
||||
* passed: Boolean representing whether the course has been
|
||||
passed according 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
|
||||
|
||||
|
||||
**Example GET Response**
|
||||
|
||||
[{
|
||||
"username": "bob",
|
||||
"course_key": "edX/DemoX/Demo_Course",
|
||||
"passed": false,
|
||||
"percent": 0.03,
|
||||
"letter_grade": None,
|
||||
}]
|
||||
|
||||
"""
|
||||
authentication_classes = (
|
||||
OAuth2AuthenticationAllowInactiveUser,
|
||||
SessionAuthentication,
|
||||
)
|
||||
permission_classes = (IsAuthenticated, )
|
||||
|
||||
def get(self, request, course_id):
|
||||
"""
|
||||
Gets a course progress status.
|
||||
|
||||
Args:
|
||||
request (Request): Django request object.
|
||||
course_id (string): URI element specifying the course location.
|
||||
|
||||
Return:
|
||||
A JSON serialized representation of the requesting user's current grade status.
|
||||
"""
|
||||
username = request.GET.get('username')
|
||||
|
||||
# only the student can access her own grade status info
|
||||
if request.user.username != username:
|
||||
log.info(
|
||||
'User %s tried to access the grade for user %s.',
|
||||
request.user.username,
|
||||
username
|
||||
)
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user requested does not match the logged in user.',
|
||||
error_code='user_mismatch'
|
||||
)
|
||||
|
||||
# build the course key
|
||||
try:
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
except InvalidKeyError:
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The provided course key cannot be parsed.',
|
||||
error_code='invalid_course_key'
|
||||
)
|
||||
# load the course
|
||||
try:
|
||||
course = courses.get_course_with_access(
|
||||
request.user,
|
||||
'load',
|
||||
course_key,
|
||||
depth=None,
|
||||
check_if_enrolled=True
|
||||
)
|
||||
except Http404:
|
||||
log.info('Course with ID "%s" not found', course_id)
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user, the course or both do not exist.',
|
||||
error_code='user_or_course_does_not_exist'
|
||||
)
|
||||
prep_course_for_grading(course, request)
|
||||
course_grade = CourseGradeFactory(request.user).create(course)
|
||||
if not course_grade.has_access_to_course:
|
||||
# This means the student didn't have access to the course
|
||||
log.info('User %s not allowed to access grade for course %s', request.user.username, username)
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The user does not have access to the course.',
|
||||
error_code='user_does_not_have_access_to_course'
|
||||
)
|
||||
|
||||
return Response([{
|
||||
'username': username,
|
||||
'course_key': course_id,
|
||||
'passed': course_grade.passed,
|
||||
'percent': course_grade.percent,
|
||||
'letter_grade': course_grade.letter_grade,
|
||||
}])
|
||||
@@ -52,6 +52,18 @@ class CourseGrade(object):
|
||||
locations_to_weighted_scores.update(subsection_grade.locations_to_weighted_scores)
|
||||
return locations_to_weighted_scores
|
||||
|
||||
@lazy
|
||||
def grade_value(self):
|
||||
"""
|
||||
Helper function to extract the grade value as calculated by the course's grader.
|
||||
"""
|
||||
# Grading policy might be overriden by a CCX, need to reset it
|
||||
self.course.set_grading_policy(self.course.grading_policy)
|
||||
return self.course.grader.grade(
|
||||
self.subsection_grade_totals_by_format,
|
||||
generate_random_scores=settings.GENERATE_PROFILE_SCORES
|
||||
)
|
||||
|
||||
@property
|
||||
def has_access_to_course(self):
|
||||
"""
|
||||
@@ -60,22 +72,41 @@ class CourseGrade(object):
|
||||
"""
|
||||
return len(self.course_structure) > 0
|
||||
|
||||
@lazy
|
||||
@property
|
||||
def percent(self):
|
||||
"""
|
||||
Returns a rounded percent from the overall grade.
|
||||
"""
|
||||
return round(self.grade_value['percent'] * 100 + 0.05) / 100
|
||||
|
||||
@property
|
||||
def letter_grade(self):
|
||||
"""
|
||||
Returns a letter representing the grade.
|
||||
"""
|
||||
return self._compute_letter_grade(self.percent)
|
||||
|
||||
@property
|
||||
def passed(self):
|
||||
"""
|
||||
Check user's course passing status. Return True if passed.
|
||||
"""
|
||||
nonzero_cutoffs = [cutoff for cutoff in self.course.grade_cutoffs.values() if cutoff > 0]
|
||||
success_cutoff = min(nonzero_cutoffs) if nonzero_cutoffs else None
|
||||
return success_cutoff and self.percent >= success_cutoff
|
||||
|
||||
@property
|
||||
def summary(self):
|
||||
"""
|
||||
Returns the grade summary as calculated by the course's grader.
|
||||
"""
|
||||
# Grading policy might be overriden by a CCX, need to reset it
|
||||
self.course.set_grading_policy(self.course.grading_policy)
|
||||
grade_summary = self.course.grader.grade(
|
||||
self.subsection_grade_totals_by_format,
|
||||
generate_random_scores=settings.GENERATE_PROFILE_SCORES
|
||||
)
|
||||
grade_summary = self.grade_value
|
||||
|
||||
# We round the grade here, to make sure that the grade is a whole percentage and
|
||||
# doesn't get displayed differently than it gets grades
|
||||
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100
|
||||
grade_summary['grade'] = self._compute_letter_grade(grade_summary['percent'])
|
||||
grade_summary['percent'] = self.percent
|
||||
grade_summary['grade'] = self.letter_grade
|
||||
|
||||
grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format
|
||||
grade_summary['raw_scores'] = list(self.locations_to_weighted_scores.itervalues())
|
||||
|
||||
|
||||
@@ -492,6 +492,7 @@ urlpatterns += (
|
||||
name='courseware_position',
|
||||
),
|
||||
|
||||
# progress page
|
||||
url(
|
||||
r'^courses/{}/progress$'.format(
|
||||
settings.COURSE_ID_PATTERN,
|
||||
@@ -499,6 +500,7 @@ urlpatterns += (
|
||||
'courseware.views.views.progress',
|
||||
name='progress',
|
||||
),
|
||||
|
||||
# Takes optional student_id for instructor use--shows profile as that student sees it.
|
||||
url(
|
||||
r'^courses/{}/progress/(?P<student_id>[^/]*)/$'.format(
|
||||
@@ -508,6 +510,13 @@ urlpatterns += (
|
||||
name='student_progress',
|
||||
),
|
||||
|
||||
# rest api for grades
|
||||
url(
|
||||
r'^api/grades/',
|
||||
include('lms.djangoapps.grades.api.urls', namespace='grades_api')
|
||||
),
|
||||
|
||||
|
||||
# For the instructor
|
||||
url(
|
||||
r'^courses/{}/instructor$'.format(
|
||||
|
||||
@@ -32,11 +32,14 @@ class DeveloperErrorViewMixin(object):
|
||||
(auth failure, method not allowed, etc.) by generating an error response
|
||||
conforming to our API conventions with a developer message.
|
||||
"""
|
||||
def make_error_response(self, status_code, developer_message):
|
||||
def make_error_response(self, status_code, developer_message, error_code=None):
|
||||
"""
|
||||
Build an error response with the given status code and developer_message
|
||||
"""
|
||||
return Response({"developer_message": developer_message}, status=status_code)
|
||||
error_data = {"developer_message": developer_message}
|
||||
if error_code is not None:
|
||||
error_data['error_code'] = error_code
|
||||
return Response(error_data, status=status_code)
|
||||
|
||||
def make_validation_error_response(self, validation_error):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user