Files
edx-platform/lms/djangoapps/grades/api/tests/test_views.py
Jillian Vogel 4d992565ac Allows course staff to access any enrolled learner's grades via the Grades API
Also adds JwtAuthentication to the list of allowed authentication
methods, so the Grades REST API can be easily accessed from Enterprise
management commands.
2017-03-24 17:51:09 +10:30

547 lines
19 KiB
Python

"""
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 urllib import urlencode
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 student.tests.factories import CourseEnrollmentFactory, UserFactory
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
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")
with cls.store.bulk_operations(cls.course.id):
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, tzinfo=UTC),
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)
cls.staff = StaffFactory(course_key=cls.course.id, password=cls.password)
cls.global_staff = GlobalStaffFactory.create()
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,
}
)
query_string = ''
if username:
query_string = '?' + urlencode(dict(username=username))
return base_url + query_string
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.
"""
with check_mongo_calls(6):
resp = self.client.get(self.get_url(self.student.username))
self.assertEqual(resp.status_code, status.HTTP_200_OK)
# redo with block structure now in the cache
with check_mongo_calls(3):
resp = self.client.get(self.get_url(self.student.username))
self.assertEqual(resp.status_code, status.HTTP_200_OK)
# and again, with the username defaulting to the current user
with check_mongo_calls(3):
resp = self.client.get(self.get_url(None))
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_403_FORBIDDEN)
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_403_FORBIDDEN)
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'
)
@ddt.data(
'staff', 'global_staff'
)
def test_staff_can_see_student(self, staff_user):
"""
Ensure that staff members can see her student's grades.
"""
self.client.logout()
self.client.login(username=getattr(self, staff_user).username, password=self.password)
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(
'staff', 'global_staff'
)
def test_staff_requests_nonexistent_user(self, staff_user):
"""
Test that a staff request for a nonexistent username returns an error.
"""
self.client.logout()
self.client.login(username=getattr(self, staff_user).username, password=self.password)
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_does_not_exist') # pylint: disable=no-member
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
@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, tzinfo=UTC),
end=datetime(2015, 1, 16, tzinfo=UTC),
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)