Commit e0b38661 by Nimisha Asthagiri Committed by GitHub

Merge pull request #13640 from edx/beryl/grading_policy_api

Grading Policy endpoint in Grades API
parents 83011e1c dea41472
"""
API Serializers
"""
from collections import defaultdict
from rest_framework import serializers
# pylint: disable=abstract-method
class GradingPolicySerializer(serializers.Serializer):
"""
Serializer for course grading policy.
"""
assignment_type = serializers.CharField(source='type')
count = serializers.IntegerField(source='min_count')
dropped = serializers.IntegerField(source='drop_count')
weight = serializers.FloatField()
def to_representation(self, obj):
"""
Return a representation of the grading policy.
"""
# Backwards compatibility with the behavior of DRF v2.
# When the grader dictionary was missing keys, DRF v2 would default to None;
# DRF v3 unhelpfully raises an exception.
return dict(
super(GradingPolicySerializer, self).to_representation(
defaultdict(lambda: None, obj)
)
)
...@@ -2,7 +2,6 @@ ...@@ -2,7 +2,6 @@
Tests for the views Tests for the views
""" """
from datetime import datetime from datetime import datetime
import ddt import ddt
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from mock import patch from mock import patch
...@@ -11,8 +10,12 @@ from pytz import UTC ...@@ -11,8 +10,12 @@ from pytz import UTC
from rest_framework import status from rest_framework import status
from rest_framework.test import APITestCase from rest_framework.test import APITestCase
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
from lms.djangoapps.grades.tests.utils import mock_get_score from lms.djangoapps.grades.tests.utils import mock_get_score
from student.tests.factories import CourseEnrollmentFactory, UserFactory from student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase, TEST_DATA_SPLIT_MODULESTORE
...@@ -184,8 +187,10 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): ...@@ -184,8 +187,10 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
def mock_from_string(*args, **kwargs): # pylint: disable=unused-argument def mock_from_string(*args, **kwargs): # pylint: disable=unused-argument
"""Mocked function to always raise an exception""" """Mocked function to always raise an exception"""
raise InvalidKeyError('foo', 'bar') raise InvalidKeyError('foo', 'bar')
with patch('opaque_keys.edx.keys.CourseKey.from_string', side_effect=mock_from_string): with patch('opaque_keys.edx.keys.CourseKey.from_string', side_effect=mock_from_string):
resp = self.client.get(self.get_url(self.student.username)) resp = self.client.get(self.get_url(self.student.username))
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND) self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
self.assertIn('error_code', resp.data) # pylint: disable=no-member self.assertIn('error_code', resp.data) # pylint: disable=no-member
self.assertEqual( self.assertEqual(
...@@ -245,3 +250,245 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase): ...@@ -245,3 +250,245 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
} }
expected_data.update(result) expected_data.update(result)
self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member
@ddt.ddt
class GradingPolicyTestMixin(object):
"""
Mixin class for Grading Policy tests
"""
view_name = None
def setUp(self):
super(GradingPolicyTestMixin, self).setUp()
self.create_user_and_access_token()
def create_user_and_access_token(self):
# pylint: disable=missing-docstring
self.user = GlobalStaffFactory.create()
self.oauth_client = ClientFactory.create()
self.access_token = AccessTokenFactory.create(user=self.user, client=self.oauth_client).token
@classmethod
def create_course_data(cls):
# pylint: disable=missing-docstring
cls.invalid_course_id = 'foo/bar/baz'
cls.course = CourseFactory.create(display_name='An Introduction to API Testing', raw_grader=cls.raw_grader)
cls.course_id = unicode(cls.course.id)
with cls.store.bulk_operations(cls.course.id, emit_signals=False):
cls.sequential = ItemFactory.create(
category="sequential",
parent_location=cls.course.location,
display_name="Lesson 1",
format="Homework",
graded=True
)
factory = MultipleChoiceResponseXMLFactory()
args = {'choices': [False, True, False]}
problem_xml = factory.build_xml(**args)
cls.problem = ItemFactory.create(
category="problem",
parent_location=cls.sequential.location,
display_name="Problem 1",
format="Homework",
data=problem_xml,
)
cls.video = ItemFactory.create(
category="video",
parent_location=cls.sequential.location,
display_name="Video 1",
)
cls.html = ItemFactory.create(
category="html",
parent_location=cls.sequential.location,
display_name="HTML 1",
)
def http_get(self, uri, **headers):
"""
Submit an HTTP GET request
"""
default_headers = {
'HTTP_AUTHORIZATION': 'Bearer ' + self.access_token
}
default_headers.update(headers)
response = self.client.get(uri, follow=True, **default_headers)
return response
def assert_get_for_course(self, course_id=None, expected_status_code=200, **headers):
"""
Submit an HTTP GET request to the view for the given course.
Validates the status_code of the response is as expected.
"""
response = self.http_get(
reverse(self.view_name, kwargs={'course_id': course_id or self.course_id}),
**headers
)
self.assertEqual(response.status_code, expected_status_code)
return response
def get_auth_header(self, user):
"""
Returns Bearer auth header with a generated access token
for the given user.
"""
access_token = AccessTokenFactory.create(user=user, client=self.oauth_client).token
return 'Bearer ' + access_token
def test_get_invalid_course(self):
"""
The view should return a 404 for an invalid course ID.
"""
self.assert_get_for_course(course_id=self.invalid_course_id, expected_status_code=404)
def test_get(self):
"""
The view should return a 200 for a valid course ID.
"""
return self.assert_get_for_course()
def test_not_authenticated(self):
"""
The view should return HTTP status 401 if user is unauthenticated.
"""
self.assert_get_for_course(expected_status_code=401, HTTP_AUTHORIZATION=None)
def test_staff_authorized(self):
"""
The view should return a 200 when provided an access token
for course staff.
"""
user = StaffFactory(course_key=self.course.id)
auth_header = self.get_auth_header(user)
self.assert_get_for_course(HTTP_AUTHORIZATION=auth_header)
def test_not_authorized(self):
"""
The view should return HTTP status 404 when provided an
access token for an unauthorized user.
"""
user = UserFactory()
auth_header = self.get_auth_header(user)
self.assert_get_for_course(expected_status_code=404, HTTP_AUTHORIZATION=auth_header)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_course_keys(self, modulestore_type):
"""
The view should be addressable by course-keys from both module stores.
"""
course = CourseFactory.create(
start=datetime(2014, 6, 16, 14, 30),
end=datetime(2015, 1, 16),
org="MTD",
default_store=modulestore_type,
)
self.assert_get_for_course(course_id=unicode(course.id))
class CourseGradingPolicyTests(GradingPolicyTestMixin, SharedModuleStoreTestCase):
"""
Tests for CourseGradingPolicy view.
"""
view_name = 'grades_api:course_grading_policy'
raw_grader = [
{
"min_count": 24,
"weight": 0.2,
"type": "Homework",
"drop_count": 0,
"short_label": "HW"
},
{
"min_count": 4,
"weight": 0.8,
"type": "Exam",
"drop_count": 0,
"short_label": "Exam"
}
]
@classmethod
def setUpClass(cls):
super(CourseGradingPolicyTests, cls).setUpClass()
cls.create_course_data()
def test_get(self):
"""
The view should return grading policy for a course.
"""
response = super(CourseGradingPolicyTests, self).test_get()
expected = [
{
"count": 24,
"weight": 0.2,
"assignment_type": "Homework",
"dropped": 0
},
{
"count": 4,
"weight": 0.8,
"assignment_type": "Exam",
"dropped": 0
}
]
self.assertListEqual(response.data, expected)
class CourseGradingPolicyMissingFieldsTests(GradingPolicyTestMixin, SharedModuleStoreTestCase):
"""
Tests for CourseGradingPolicy view when fields are missing.
"""
view_name = 'grades_api:course_grading_policy'
# Raw grader with missing keys
raw_grader = [
{
"min_count": 24,
"weight": 0.2,
"type": "Homework",
"drop_count": 0,
"short_label": "HW"
},
{
# Deleted "min_count" key
"weight": 0.8,
"type": "Exam",
"drop_count": 0,
"short_label": "Exam"
}
]
@classmethod
def setUpClass(cls):
super(CourseGradingPolicyMissingFieldsTests, cls).setUpClass()
cls.create_course_data()
def test_get(self):
"""
The view should return grading policy for a course.
"""
response = super(CourseGradingPolicyMissingFieldsTests, self).test_get()
expected = [
{
"count": 24,
"weight": 0.2,
"assignment_type": "Homework",
"dropped": 0
},
{
"count": None,
"weight": 0.8,
"assignment_type": "Exam",
"dropped": 0
}
]
self.assertListEqual(response.data, expected)
...@@ -11,8 +11,14 @@ urlpatterns = patterns( ...@@ -11,8 +11,14 @@ urlpatterns = patterns(
'', '',
url( url(
r'^v0/course_grade/{course_id}/users/$'.format( r'^v0/course_grade/{course_id}/users/$'.format(
course_id=settings.COURSE_ID_PATTERN course_id=settings.COURSE_ID_PATTERN,
), ),
views.UserGradeView.as_view(), name='user_grade_detail' views.UserGradeView.as_view(), name='user_grade_detail'
), ),
url(
r'^v0/courses/{course_id}/policy/$'.format(
course_id=settings.COURSE_ID_PATTERN,
),
views.CourseGradingPolicy.as_view(), name='course_grading_policy'
),
) )
...@@ -6,12 +6,14 @@ from opaque_keys import InvalidKeyError ...@@ -6,12 +6,14 @@ from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from rest_framework import status from rest_framework import status
from rest_framework.authentication import SessionAuthentication from rest_framework.authentication import SessionAuthentication
from rest_framework.generics import GenericAPIView from rest_framework.exceptions import AuthenticationFailed
from rest_framework.generics import GenericAPIView, ListAPIView
from rest_framework.permissions import IsAuthenticated from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from lms.djangoapps.ccx.utils import prep_course_for_grading from lms.djangoapps.ccx.utils import prep_course_for_grading
from lms.djangoapps.courseware import courses from lms.djangoapps.courseware import courses
from lms.djangoapps.grades.api.serializers import GradingPolicySerializer
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
...@@ -19,7 +21,55 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin ...@@ -19,7 +21,55 @@ from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
class UserGradeView(DeveloperErrorViewMixin, GenericAPIView): class GradeViewMixin(DeveloperErrorViewMixin):
"""
Mixin class for Grades related views.
"""
authentication_classes = (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthentication,
)
permission_classes = (IsAuthenticated,)
def _get_course(self, course_key_string, user, access_action):
"""
Returns the course for the given course_key_string after
verifying the requested access to the course by the given user.
"""
try:
course_key = CourseKey.from_string(course_key_string)
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'
)
try:
return courses.get_course_with_access(
user,
access_action,
course_key,
check_if_enrolled=True
)
except Http404:
log.info('Course with ID "%s" not found', course_key_string)
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'
)
def perform_authentication(self, request):
"""
Ensures that the user is authenticated (e.g. not an AnonymousUser), unless DEBUG mode is enabled.
"""
super(GradeViewMixin, self).perform_authentication(request)
if request.user.is_anonymous():
raise AuthenticationFailed
class UserGradeView(GradeViewMixin, GenericAPIView):
""" """
**Use Case** **Use Case**
...@@ -67,12 +117,6 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView): ...@@ -67,12 +117,6 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView):
}] }]
""" """
authentication_classes = (
OAuth2AuthenticationAllowInactiveUser,
SessionAuthentication,
)
permission_classes = (IsAuthenticated, )
def get(self, request, course_id): def get(self, request, course_id):
""" """
Gets a course progress status. Gets a course progress status.
...@@ -99,31 +143,10 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView): ...@@ -99,31 +143,10 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView):
error_code='user_mismatch' error_code='user_mismatch'
) )
# build the course key course = self._get_course(course_id, request.user, 'load')
try: if isinstance(course, Response):
course_key = CourseKey.from_string(course_id) return course
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) prep_course_for_grading(course, request)
course_grade = CourseGradeFactory(request.user).create(course) course_grade = CourseGradeFactory(request.user).create(course)
if not course_grade.has_access_to_course: if not course_grade.has_access_to_course:
...@@ -142,3 +165,36 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView): ...@@ -142,3 +165,36 @@ class UserGradeView(DeveloperErrorViewMixin, GenericAPIView):
'percent': course_grade.percent, 'percent': course_grade.percent,
'letter_grade': course_grade.letter_grade, 'letter_grade': course_grade.letter_grade,
}]) }])
class CourseGradingPolicy(GradeViewMixin, ListAPIView):
"""
**Use Case**
Get the course grading policy.
**Example requests**:
GET /api/grades/v0/policy/{course_id}/
**Response Values**
* assignment_type: The type of the assignment, as configured by course
staff. For example, course staff might make the assignment types Homework,
Quiz, and Exam.
* count: The number of assignments of the type.
* dropped: Number of assignments of the type that are dropped.
* weight: The weight, or effect, of the assignment type on the learner's
final grade.
"""
allow_empty = False
def get(self, request, course_id, **kwargs):
course = self._get_course(course_id, request.user, 'staff')
if isinstance(course, Response):
return course
return Response(GradingPolicySerializer(course.raw_grader, many=True).data)
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