Commit c0eba5d4 by Nimisha Asthagiri Committed by GitHub

Merge pull request #13285 from mitocw/gdm_current_grade_implementation

Current Grade Rest API implementation
parents 096ae039 cc65094b
"""
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
""" 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'
),
)
""" 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): ...@@ -52,6 +52,18 @@ class CourseGrade(object):
locations_to_weighted_scores.update(subsection_grade.locations_to_weighted_scores) locations_to_weighted_scores.update(subsection_grade.locations_to_weighted_scores)
return 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 @property
def has_access_to_course(self): def has_access_to_course(self):
""" """
...@@ -60,22 +72,41 @@ class CourseGrade(object): ...@@ -60,22 +72,41 @@ class CourseGrade(object):
""" """
return len(self.course_structure) > 0 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): def summary(self):
""" """
Returns the grade summary as calculated by the course's grader. Returns the grade summary as calculated by the course's grader.
""" """
# Grading policy might be overriden by a CCX, need to reset it grade_summary = self.grade_value
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
)
# We round the grade here, to make sure that the grade is a whole percentage and # 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 # doesn't get displayed differently than it gets grades
grade_summary['percent'] = round(grade_summary['percent'] * 100 + 0.05) / 100 grade_summary['percent'] = self.percent
grade_summary['grade'] = self._compute_letter_grade(grade_summary['percent']) grade_summary['grade'] = self.letter_grade
grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format grade_summary['totaled_scores'] = self.subsection_grade_totals_by_format
grade_summary['raw_scores'] = list(self.locations_to_weighted_scores.itervalues()) grade_summary['raw_scores'] = list(self.locations_to_weighted_scores.itervalues())
......
...@@ -492,6 +492,7 @@ urlpatterns += ( ...@@ -492,6 +492,7 @@ urlpatterns += (
name='courseware_position', name='courseware_position',
), ),
# progress page
url( url(
r'^courses/{}/progress$'.format( r'^courses/{}/progress$'.format(
settings.COURSE_ID_PATTERN, settings.COURSE_ID_PATTERN,
...@@ -499,6 +500,7 @@ urlpatterns += ( ...@@ -499,6 +500,7 @@ urlpatterns += (
'courseware.views.views.progress', 'courseware.views.views.progress',
name='progress', name='progress',
), ),
# Takes optional student_id for instructor use--shows profile as that student sees it. # Takes optional student_id for instructor use--shows profile as that student sees it.
url( url(
r'^courses/{}/progress/(?P<student_id>[^/]*)/$'.format( r'^courses/{}/progress/(?P<student_id>[^/]*)/$'.format(
...@@ -508,6 +510,13 @@ urlpatterns += ( ...@@ -508,6 +510,13 @@ urlpatterns += (
name='student_progress', name='student_progress',
), ),
# rest api for grades
url(
r'^api/grades/',
include('lms.djangoapps.grades.api.urls', namespace='grades_api')
),
# For the instructor # For the instructor
url( url(
r'^courses/{}/instructor$'.format( r'^courses/{}/instructor$'.format(
......
...@@ -32,11 +32,14 @@ class DeveloperErrorViewMixin(object): ...@@ -32,11 +32,14 @@ class DeveloperErrorViewMixin(object):
(auth failure, method not allowed, etc.) by generating an error response (auth failure, method not allowed, etc.) by generating an error response
conforming to our API conventions with a developer message. 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 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): def make_validation_error_response(self, validation_error):
""" """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment