Merge pull request #19422 from edx/arch/remove-grades-v0
Remove Grades v0 REST API (DEPR-3)
This commit is contained in:
@@ -1,548 +0,0 @@
|
||||
"""
|
||||
Tests for the views
|
||||
"""
|
||||
from datetime import datetime
|
||||
from urllib import urlencode
|
||||
|
||||
import ddt
|
||||
from django.urls import reverse
|
||||
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
|
||||
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 capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
|
||||
from lms.djangoapps.grades.tests.utils import mock_passing_grade
|
||||
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, check_mongo_calls
|
||||
|
||||
|
||||
@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
|
||||
}
|
||||
}
|
||||
"""
|
||||
shard = 4
|
||||
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(3):
|
||||
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)
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch')
|
||||
|
||||
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)
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch')
|
||||
|
||||
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)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'],
|
||||
'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)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'],
|
||||
'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)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'],
|
||||
'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)
|
||||
|
||||
@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)
|
||||
self.assertEqual(resp.data['error_code'], 'user_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)
|
||||
|
||||
@ddt.data(
|
||||
({'letter_grade': None, 'percent': 0.4, 'passed': False}),
|
||||
({'letter_grade': 'Pass', 'percent': 1, 'passed': True}),
|
||||
)
|
||||
def test_grade(self, grade):
|
||||
"""
|
||||
Test that the user gets her grade in case she answered tests with an insufficient score.
|
||||
"""
|
||||
with mock_passing_grade(letter_grade=grade['letter_grade'], percent=grade['percent']):
|
||||
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(grade)
|
||||
self.assertEqual(resp.data, [expected_data])
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class GradingPolicyTestMixin(object):
|
||||
"""
|
||||
Mixin class for Grading Policy tests
|
||||
"""
|
||||
shard = 4
|
||||
view_name = None
|
||||
|
||||
def setUp(self):
|
||||
super(GradingPolicyTestMixin, self).setUp()
|
||||
self.create_user_and_access_token()
|
||||
|
||||
def create_user_and_access_token(self):
|
||||
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):
|
||||
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.
|
||||
"""
|
||||
shard = 4
|
||||
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.
|
||||
"""
|
||||
shard = 4
|
||||
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)
|
||||
@@ -5,23 +5,9 @@ Grades API URLs.
|
||||
from django.conf import settings
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from lms.djangoapps.grades.api import views
|
||||
|
||||
|
||||
app_name = 'lms.djangoapps.grades'
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'^v0/course_grade/{course_id}/users/$'.format(
|
||||
course_id=settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
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'
|
||||
),
|
||||
url(r'^v1/', include('grades.api.v1.urls', namespace='v1'))
|
||||
]
|
||||
|
||||
259
lms/djangoapps/grades/api/v1/tests/test_grading_policy_view.py
Normal file
259
lms/djangoapps/grades/api/v1/tests/test_grading_policy_view.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Tests for the views
|
||||
"""
|
||||
from datetime import datetime
|
||||
|
||||
import ddt
|
||||
from django.urls import reverse
|
||||
from edx_oauth2_provider.tests.factories import AccessTokenFactory, ClientFactory
|
||||
from pytz import UTC
|
||||
|
||||
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory
|
||||
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class GradingPolicyTestMixin(object):
|
||||
"""
|
||||
Mixin class for Grading Policy tests
|
||||
"""
|
||||
shard = 4
|
||||
view_name = None
|
||||
|
||||
def setUp(self):
|
||||
super(GradingPolicyTestMixin, self).setUp()
|
||||
self.create_user_and_access_token()
|
||||
|
||||
def create_user_and_access_token(self):
|
||||
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):
|
||||
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=403, 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.
|
||||
"""
|
||||
shard = 4
|
||||
view_name = 'grades_api:v1: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.
|
||||
"""
|
||||
shard = 4
|
||||
view_name = 'grades_api:v1: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)
|
||||
@@ -3,7 +3,6 @@ from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from lms.djangoapps.grades.api.v1 import gradebook_views, views
|
||||
from lms.djangoapps.grades.api.views import CourseGradingPolicy
|
||||
|
||||
|
||||
app_name = 'lms.djangoapps.grades'
|
||||
@@ -21,7 +20,7 @@ urlpatterns = [
|
||||
),
|
||||
url(
|
||||
r'^policy/courses/{course_id}/$'.format(course_id=settings.COURSE_ID_PATTERN),
|
||||
CourseGradingPolicy.as_view(),
|
||||
views.CourseGradingPolicy.as_view(),
|
||||
name='course_grading_policy'
|
||||
),
|
||||
url(
|
||||
|
||||
@@ -2,9 +2,15 @@
|
||||
import logging
|
||||
from contextlib import contextmanager
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.generics import ListAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from edx_rest_framework_extensions import permissions
|
||||
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
|
||||
from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.grades.api.serializers import GradingPolicySerializer
|
||||
from lms.djangoapps.grades.api.v1.utils import (
|
||||
CourseEnrollmentPagination,
|
||||
GradeViewMixin,
|
||||
@@ -14,7 +20,9 @@ from lms.djangoapps.grades.api.v1.utils import (
|
||||
)
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from lms.djangoapps.grades.models import PersistentCourseGrade
|
||||
from opaque_keys import InvalidKeyError
|
||||
from openedx.core.lib.api.authentication import OAuth2AuthenticationAllowInactiveUser
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -136,3 +144,68 @@ class CourseGradesView(GradeViewMixin, PaginatedAPIView):
|
||||
user_grades.append(self._serialize_user_grade(user, course_key, course_grade))
|
||||
|
||||
return self.get_paginated_response(user_grades)
|
||||
|
||||
|
||||
class CourseGradingPolicy(GradeViewMixin, ListAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
|
||||
Get the course grading policy.
|
||||
|
||||
**Example requests**:
|
||||
|
||||
GET /api/grades/v1/policy/courses/{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
|
||||
|
||||
authentication_classes = (
|
||||
JwtAuthentication,
|
||||
OAuth2AuthenticationAllowInactiveUser,
|
||||
SessionAuthenticationAllowInactiveUser,
|
||||
)
|
||||
|
||||
def _get_course(self, request, course_id):
|
||||
"""
|
||||
Returns the course after parsing the id, checking access, and checking existence.
|
||||
"""
|
||||
try:
|
||||
course_key = get_course_key(request, course_id)
|
||||
except InvalidKeyError:
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
developer_message='The provided course key cannot be parsed.',
|
||||
error_code='invalid_course_key'
|
||||
)
|
||||
|
||||
if not has_access(request.user, 'staff', course_key):
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The course does not exist.',
|
||||
error_code='user_or_course_does_not_exist',
|
||||
)
|
||||
|
||||
course = modulestore().get_course(course_key, depth=0)
|
||||
if not course:
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The course does not exist.',
|
||||
error_code='user_or_course_does_not_exist',
|
||||
)
|
||||
return course
|
||||
|
||||
def get(self, request, course_id, *args, **kwargs): # pylint: disable=arguments-differ
|
||||
course = self._get_course(request, course_id)
|
||||
return Response(GradingPolicySerializer(course.raw_grader, many=True).data)
|
||||
|
||||
@@ -1,217 +0,0 @@
|
||||
""" API v0 views. """
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
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.exceptions import AuthenticationFailed
|
||||
from rest_framework.generics import GenericAPIView, ListAPIView
|
||||
from rest_framework.response import Response
|
||||
|
||||
from courseware.access import has_access
|
||||
from lms.djangoapps.courseware import courses
|
||||
from lms.djangoapps.courseware.exceptions import CourseAccessRedirect
|
||||
from lms.djangoapps.grades.api.serializers import GradingPolicySerializer
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
|
||||
from student.roles import CourseStaffRole
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
USER_MODEL = get_user_model()
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
"""
|
||||
Mixin class for Grades related views.
|
||||
"""
|
||||
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:
|
||||
raise self.api_error(
|
||||
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)
|
||||
except CourseAccessRedirect:
|
||||
log.info('User %s does not have access to course with ID "%s"', user.username, course_key_string)
|
||||
|
||||
raise self.api_error(
|
||||
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 _get_effective_user(self, request, course):
|
||||
"""
|
||||
Returns the user object corresponding to the request's 'username' parameter,
|
||||
or the current request.user if no 'username' was provided.
|
||||
|
||||
Verifies that the request.user has access to the requested users's grades.
|
||||
Returns a 403 error response if access is denied, or a 404 error response if the user does not exist.
|
||||
"""
|
||||
|
||||
# Use the request user's if none provided.
|
||||
if 'username' in request.GET:
|
||||
username = request.GET.get('username')
|
||||
else:
|
||||
username = request.user.username
|
||||
|
||||
if request.user.username == username:
|
||||
# Any user may request her own grades
|
||||
return request.user
|
||||
|
||||
# Only a user with staff access may request grades for a user other than herself.
|
||||
if not has_access(request.user, CourseStaffRole.ROLE, course):
|
||||
log.info(
|
||||
'User %s tried to access the grade for user %s.',
|
||||
request.user.username,
|
||||
username
|
||||
)
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
developer_message='The user requested does not match the logged in user.',
|
||||
error_code='user_mismatch'
|
||||
)
|
||||
|
||||
try:
|
||||
return USER_MODEL.objects.get(username=username)
|
||||
|
||||
except USER_MODEL.DoesNotExist:
|
||||
raise self.api_error(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username does not exist.',
|
||||
error_code='user_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**
|
||||
|
||||
* Get the current course grades for a user in a course.
|
||||
|
||||
The currently logged-in user may request her own grades, or a user with staff access to the course may request
|
||||
any enrolled user's grades.
|
||||
|
||||
**Example Request**
|
||||
|
||||
GET /api/grades/v0/course_grade/{course_id}/users/?username={username}
|
||||
|
||||
**GET Parameters**
|
||||
|
||||
A GET request may include the following parameters.
|
||||
|
||||
* course_id: (required) A string representation of a Course ID.
|
||||
* username: (optional) A string representation of a user's username.
|
||||
Defaults to the currently logged-in 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,
|
||||
}]
|
||||
|
||||
"""
|
||||
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.
|
||||
"""
|
||||
|
||||
course = self._get_course(course_id, request.user, 'load')
|
||||
grade_user = self._get_effective_user(request, course)
|
||||
course_grade = CourseGradeFactory().read(grade_user, course)
|
||||
|
||||
return Response([{
|
||||
'username': grade_user.username,
|
||||
'course_key': course_id,
|
||||
'passed': course_grade.passed,
|
||||
'percent': course_grade.percent,
|
||||
'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')
|
||||
return Response(GradingPolicySerializer(course.raw_grader, many=True).data)
|
||||
Reference in New Issue
Block a user