Merge pull request #17602 from Microsoft/edx_master
Bulk Grades API (v1 grades api w/courses endpoint)
This commit is contained in:
@@ -93,6 +93,22 @@ def get_course_enrollment(username, course_id):
|
||||
return None
|
||||
|
||||
|
||||
def get_user_enrollments(course_key):
|
||||
"""Based on the course id, return all user enrollments in the course
|
||||
Args:
|
||||
course_key (CourseKey): Identifier of the course
|
||||
from which to retrieve enrollments.
|
||||
Returns:
|
||||
A course's user enrollments as a queryset
|
||||
Raises:
|
||||
CourseEnrollment.DoesNotExist
|
||||
"""
|
||||
return CourseEnrollment.objects.filter(
|
||||
course_id=course_key,
|
||||
is_active=True
|
||||
).order_by('created')
|
||||
|
||||
|
||||
def create_course_enrollment(username, course_id, mode, is_active):
|
||||
"""Create a new course enrollment for the given user.
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ from enrollment.errors import (
|
||||
CourseEnrollmentFullError,
|
||||
UserNotFoundError
|
||||
)
|
||||
from enrollment.serializers import CourseEnrollmentSerializer
|
||||
from openedx.core.lib.exceptions import CourseNotFoundError
|
||||
from student.models import AlreadyEnrolledError, CourseEnrollment, CourseFullError, EnrollmentClosedError
|
||||
from student.tests.factories import UserFactory
|
||||
@@ -179,6 +180,51 @@ class EnrollmentDataTest(ModuleStoreTestCase):
|
||||
self.assertEqual(self.user.username, result['user'])
|
||||
self.assertEqual(enrollment, result)
|
||||
|
||||
@ddt.data(
|
||||
# Default (no course modes in the database)
|
||||
# Expect that users are automatically enrolled as "honor".
|
||||
([], 'honor'),
|
||||
|
||||
# Audit / Verified / Honor
|
||||
# We should always go to the "choose your course" page.
|
||||
# We should also be enrolled as "honor" by default.
|
||||
(['honor', 'verified', 'audit'], 'verified'),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_get_user_enrollments(self, course_modes, enrollment_mode):
|
||||
self._create_course_modes(course_modes)
|
||||
|
||||
# Try to get enrollments before they exist.
|
||||
result = data.get_user_enrollments(self.course.id)
|
||||
self.assertFalse(result.exists())
|
||||
|
||||
# Create 10 test users to enroll in the course
|
||||
users = []
|
||||
for i in xrange(10):
|
||||
users.append(UserFactory.create(
|
||||
username=self.USERNAME + str(i),
|
||||
email=self.EMAIL + str(i),
|
||||
password=self.PASSWORD + str(i)
|
||||
))
|
||||
|
||||
# Create the original enrollments.
|
||||
created_enrollments = []
|
||||
for user in users:
|
||||
created_enrollments.append(data.create_course_enrollment(
|
||||
user.username,
|
||||
unicode(self.course.id),
|
||||
enrollment_mode,
|
||||
True
|
||||
))
|
||||
|
||||
# Compare the created enrollments with the results
|
||||
# from the get user enrollments request.
|
||||
results = data.get_user_enrollments(
|
||||
self.course.id
|
||||
)
|
||||
self.assertTrue(result.exists())
|
||||
self.assertEqual(CourseEnrollmentSerializer(results, many=True).data, created_enrollments)
|
||||
|
||||
@ddt.data(
|
||||
# Default (no course modes in the database)
|
||||
# Expect that users are automatically enrolled as "honor".
|
||||
|
||||
@@ -170,8 +170,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
"""
|
||||
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
|
||||
self.assertIn('error_code', resp.data)
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch')
|
||||
|
||||
def test_other_get_grade(self):
|
||||
"""
|
||||
@@ -181,8 +181,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
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
|
||||
self.assertIn('error_code', resp.data)
|
||||
self.assertEqual(resp.data['error_code'], 'user_mismatch')
|
||||
|
||||
def test_self_get_grade_not_enrolled(self):
|
||||
"""
|
||||
@@ -194,9 +194,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
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.assertIn('error_code', resp.data)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
resp.data['error_code'],
|
||||
'user_or_course_does_not_exist'
|
||||
)
|
||||
|
||||
@@ -212,9 +212,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
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.assertIn('error_code', resp.data)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
resp.data['error_code'],
|
||||
'invalid_course_key'
|
||||
)
|
||||
|
||||
@@ -231,9 +231,9 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
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.assertIn('error_code', resp.data)
|
||||
self.assertEqual(
|
||||
resp.data['error_code'], # pylint: disable=no-member
|
||||
resp.data['error_code'],
|
||||
'user_or_course_does_not_exist'
|
||||
)
|
||||
|
||||
@@ -255,7 +255,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
'course_key': str(self.course_key),
|
||||
'passed': False
|
||||
}]
|
||||
self.assertEqual(resp.data, expected_data) # pylint: disable=no-member
|
||||
self.assertEqual(resp.data, expected_data)
|
||||
|
||||
@ddt.data(
|
||||
'staff', 'global_staff'
|
||||
@@ -268,8 +268,8 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
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
|
||||
self.assertIn('error_code', resp.data)
|
||||
self.assertEqual(resp.data['error_code'], 'user_does_not_exist')
|
||||
|
||||
def test_no_grade(self):
|
||||
"""
|
||||
@@ -284,7 +284,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
'course_key': str(self.course_key),
|
||||
'passed': False
|
||||
}]
|
||||
self.assertEqual(resp.data, expected_data) # pylint: disable=no-member
|
||||
self.assertEqual(resp.data, expected_data)
|
||||
|
||||
@ddt.data(
|
||||
({'letter_grade': None, 'percent': 0.4, 'passed': False}),
|
||||
@@ -302,7 +302,7 @@ class CurrentGradeViewTest(SharedModuleStoreTestCase, APITestCase):
|
||||
'course_key': str(self.course_key),
|
||||
}
|
||||
expected_data.update(grade)
|
||||
self.assertEqual(resp.data, [expected_data]) # pylint: disable=no-member
|
||||
self.assertEqual(resp.data, [expected_data])
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
|
||||
@@ -3,7 +3,7 @@ Grades API URLs.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
from django.conf.urls import include, url
|
||||
|
||||
from lms.djangoapps.grades.api import views
|
||||
|
||||
@@ -20,4 +20,5 @@ urlpatterns = [
|
||||
),
|
||||
views.CourseGradingPolicy.as_view(), name='course_grading_policy'
|
||||
),
|
||||
url(r'^v1/', include('grades.api.v1.urls', namespace='v1'))
|
||||
]
|
||||
|
||||
0
lms/djangoapps/grades/api/v1/__init__.py
Normal file
0
lms/djangoapps/grades/api/v1/__init__.py
Normal file
0
lms/djangoapps/grades/api/v1/tests/__init__.py
Normal file
0
lms/djangoapps/grades/api/v1/tests/__init__.py
Normal file
385
lms/djangoapps/grades/api/v1/tests/test_views.py
Normal file
385
lms/djangoapps/grades/api/v1/tests/test_views.py
Normal file
@@ -0,0 +1,385 @@
|
||||
"""
|
||||
Tests for v1 views
|
||||
"""
|
||||
from datetime import datetime
|
||||
import ddt
|
||||
|
||||
from django.core.urlresolvers import reverse
|
||||
from mock import MagicMock, patch
|
||||
from opaque_keys import InvalidKeyError
|
||||
from pytz import UTC
|
||||
from rest_framework import status
|
||||
from rest_framework.test import APITestCase
|
||||
|
||||
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory, StaffFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
||||
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
|
||||
|
||||
|
||||
class GradeViewTestMixin(SharedModuleStoreTestCase):
|
||||
"""
|
||||
Mixin class for grades related view tests
|
||||
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(GradeViewTestMixin, cls).setUpClass()
|
||||
|
||||
cls.course = cls._create_test_course_with_default_grading_policy(
|
||||
display_name='test course', run="Testing_course"
|
||||
)
|
||||
cls.empty_course = cls._create_test_course_with_default_grading_policy(
|
||||
display_name='empty test course', run="Empty_testing_course"
|
||||
)
|
||||
|
||||
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_key, 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,
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
super(GradeViewTestMixin, self).setUp()
|
||||
self.client.login(username=self.student.username, password=self.password)
|
||||
|
||||
@classmethod
|
||||
def _create_test_course_with_default_grading_policy(cls, display_name, run):
|
||||
"""
|
||||
Utility method to create a course with a default grading policy
|
||||
"""
|
||||
course = CourseFactory.create(display_name=display_name, run=run)
|
||||
_ = CourseOverviewFactory.create(id=course.id)
|
||||
|
||||
chapter = ItemFactory.create(
|
||||
category='chapter',
|
||||
parent_location=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(2017, 12, 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),
|
||||
)
|
||||
|
||||
return course
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class SingleUserGradesTests(GradeViewTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for grades related to a course and specific user
|
||||
e.g. /api/grades/v1/courses/{course_id}/?username={username}
|
||||
/api/grades/v1/courses/?course_id={course_id}&username={username}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(SingleUserGradesTests, cls).setUpClass()
|
||||
cls.namespaced_url = 'grades_api:v1:course_grades'
|
||||
|
||||
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.
|
||||
"""
|
||||
self.client.logout()
|
||||
self.client.login(username=self.global_staff.username, password=self.password)
|
||||
resp = self.client.get(self.get_url('IDoNotExist'))
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
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)
|
||||
|
||||
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_not_enrolled'
|
||||
)
|
||||
|
||||
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,
|
||||
'email': self.student.email,
|
||||
'course_id': str(self.course_key),
|
||||
'passed': False,
|
||||
'percent': 0.0,
|
||||
'letter_grade': None
|
||||
}]
|
||||
|
||||
self.assertEqual(resp.data, expected_data)
|
||||
|
||||
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'],
|
||||
'course_does_not_exist'
|
||||
)
|
||||
|
||||
@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 patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
||||
grade_fields = {
|
||||
'letter_grade': grade['letter_grade'],
|
||||
'percent': grade['percent'],
|
||||
'passed': grade['letter_grade'] is not None,
|
||||
|
||||
}
|
||||
mock_grade.return_value = MagicMock(**grade_fields)
|
||||
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,
|
||||
'email': self.student.email,
|
||||
'course_id': str(self.course_key),
|
||||
}
|
||||
|
||||
expected_data.update(grade)
|
||||
self.assertEqual(resp.data, [expected_data])
|
||||
|
||||
def test_staff_can_see_student(self):
|
||||
"""
|
||||
Ensure that staff members can see her student's grades.
|
||||
"""
|
||||
self.client.logout()
|
||||
self.client.login(username=self.global_staff.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,
|
||||
'email': self.student.email,
|
||||
'letter_grade': None,
|
||||
'percent': 0.0,
|
||||
'course_id': str(self.course_key),
|
||||
'passed': False
|
||||
}]
|
||||
self.assertEqual(resp.data, expected_data)
|
||||
|
||||
|
||||
@ddt.ddt
|
||||
class CourseGradesViewTest(GradeViewTestMixin, APITestCase):
|
||||
"""
|
||||
Tests for grades related to all users in a course
|
||||
e.g. /api/grades/v1/courses/{course_id}/
|
||||
/api/grades/v1/courses/?course_id={course_id}
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(CourseGradesViewTest, cls).setUpClass()
|
||||
cls.namespaced_url = 'grades_api:v1:course_grades'
|
||||
|
||||
def get_url(self, course_key=None):
|
||||
"""
|
||||
Helper function to create the url
|
||||
"""
|
||||
base_url = reverse(
|
||||
self.namespaced_url,
|
||||
kwargs={
|
||||
'course_id': course_key or self.course_key,
|
||||
}
|
||||
)
|
||||
|
||||
return base_url
|
||||
|
||||
def test_anonymous(self):
|
||||
self.client.logout()
|
||||
resp = self.client.get(self.get_url())
|
||||
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
||||
|
||||
def test_student(self):
|
||||
resp = self.client.get(self.get_url())
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_course_does_not_exist(self):
|
||||
self.client.logout()
|
||||
self.client.login(username=self.global_staff.username, password=self.password)
|
||||
resp = self.client.get(
|
||||
self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1')
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_404_NOT_FOUND)
|
||||
|
||||
def test_course_no_enrollments(self):
|
||||
self.client.logout()
|
||||
self.client.login(username=self.global_staff.username, password=self.password)
|
||||
resp = self.client.get(
|
||||
self.get_url(course_key=self.empty_course.id)
|
||||
)
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
self.assertEqual(resp.data, [])
|
||||
|
||||
def test_staff_can_get_all_grades(self):
|
||||
self.client.logout()
|
||||
self.client.login(username=self.global_staff.username, password=self.password)
|
||||
resp = self.client.get(self.get_url())
|
||||
|
||||
# this should have permission to access this API endpoint
|
||||
self.assertEqual(resp.status_code, status.HTTP_200_OK)
|
||||
expected_data = [
|
||||
{
|
||||
'username': self.student.username,
|
||||
'email': self.student.email,
|
||||
'course_id': str(self.course.id),
|
||||
'passed': False,
|
||||
'percent': 0.0,
|
||||
'letter_grade': None
|
||||
},
|
||||
{
|
||||
'username': self.other_student.username,
|
||||
'email': self.other_student.email,
|
||||
'course_id': str(self.course.id),
|
||||
'passed': False,
|
||||
'percent': 0.0,
|
||||
'letter_grade': None
|
||||
},
|
||||
]
|
||||
|
||||
self.assertEqual(resp.data, expected_data)
|
||||
25
lms/djangoapps/grades/api/v1/urls.py
Normal file
25
lms/djangoapps/grades/api/v1/urls.py
Normal file
@@ -0,0 +1,25 @@
|
||||
""" Grades API v1 URLs. """
|
||||
from django.conf import settings
|
||||
from django.conf.urls import url
|
||||
|
||||
from lms.djangoapps.grades.api.v1 import views
|
||||
from lms.djangoapps.grades.api.views import CourseGradingPolicy
|
||||
|
||||
urlpatterns = [
|
||||
url(
|
||||
r'^courses/$',
|
||||
views.CourseGradesView.as_view(), name='course_grades'
|
||||
),
|
||||
url(
|
||||
r'^courses/{course_id}/$'.format(
|
||||
course_id=settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
views.CourseGradesView.as_view(), name='course_grades'
|
||||
),
|
||||
url(
|
||||
r'^policy/courses/{course_id}/$'.format(
|
||||
course_id=settings.COURSE_ID_PATTERN,
|
||||
),
|
||||
CourseGradingPolicy.as_view(), name='course_grading_policy'
|
||||
),
|
||||
]
|
||||
202
lms/djangoapps/grades/api/v1/views.py
Normal file
202
lms/djangoapps/grades/api/v1/views.py
Normal file
@@ -0,0 +1,202 @@
|
||||
""" API v0 views. """
|
||||
import logging
|
||||
|
||||
from django.contrib.auth import get_user_model
|
||||
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
|
||||
from rest_framework.response import Response
|
||||
|
||||
from enrollment import data as enrollment_data
|
||||
from student.models import CourseEnrollment
|
||||
from lms.djangoapps.grades.course_grade_factory import CourseGradeFactory
|
||||
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
|
||||
from openedx.core.lib.api.permissions import IsUserInUrlOrStaff
|
||||
from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, view_auth_classes
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
USER_MODEL = get_user_model()
|
||||
|
||||
|
||||
@view_auth_classes()
|
||||
class GradeViewMixin(DeveloperErrorViewMixin):
|
||||
"""
|
||||
Mixin class for Grades related views.
|
||||
"""
|
||||
def _get_single_user_grade(self, request, course_key):
|
||||
"""
|
||||
Returns a grade response for the user object corresponding to the request's 'username' parameter,
|
||||
or the current request.user if no 'username' was provided.
|
||||
Args:
|
||||
request (Request): django request object to check for username or request.user object
|
||||
course_key (CourseLocator): The course to retrieve user grades for.
|
||||
|
||||
Returns:
|
||||
A serializable list of grade responses
|
||||
"""
|
||||
if 'username' in request.GET:
|
||||
username = request.GET.get('username')
|
||||
else:
|
||||
username = request.user.username
|
||||
|
||||
grade_user = USER_MODEL.objects.get(username=username)
|
||||
|
||||
if not enrollment_data.get_course_enrollment(username, str(course_key)):
|
||||
raise CourseEnrollment.DoesNotExist
|
||||
|
||||
course_grade = CourseGradeFactory().read(grade_user, course_key=course_key)
|
||||
return Response([self._make_grade_response(grade_user, course_key, course_grade)])
|
||||
|
||||
def _get_user_grades(self, course_key):
|
||||
"""
|
||||
Get paginated grades for users in a course.
|
||||
Args:
|
||||
course_key (CourseLocator): The course to retrieve user grades for.
|
||||
|
||||
Returns:
|
||||
A serializable list of grade responses
|
||||
"""
|
||||
enrollments_in_course = enrollment_data.get_user_enrollments(course_key)
|
||||
|
||||
paged_enrollments = self.paginator.paginate_queryset(
|
||||
enrollments_in_course, self.request, view=self
|
||||
)
|
||||
users = (enrollment.user for enrollment in paged_enrollments)
|
||||
grades = CourseGradeFactory().iter(users, course_key=course_key)
|
||||
|
||||
grade_responses = []
|
||||
for user, course_grade, exc in grades:
|
||||
if not exc:
|
||||
grade_responses.append(self._make_grade_response(user, course_key, course_grade))
|
||||
|
||||
return Response(grade_responses)
|
||||
|
||||
def _make_grade_response(self, user, course_key, course_grade):
|
||||
"""
|
||||
Serialize a single grade to dict to use in Responses
|
||||
"""
|
||||
return {
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'course_id': str(course_key),
|
||||
'passed': course_grade.passed,
|
||||
'percent': course_grade.percent,
|
||||
'letter_grade': course_grade.letter_grade,
|
||||
}
|
||||
|
||||
def perform_authentication(self, request):
|
||||
"""
|
||||
Ensures that the user is authenticated (e.g. not an AnonymousUser).
|
||||
"""
|
||||
super(GradeViewMixin, self).perform_authentication(request)
|
||||
if request.user.is_anonymous():
|
||||
raise AuthenticationFailed
|
||||
|
||||
|
||||
class CourseGradesView(GradeViewMixin, GenericAPIView):
|
||||
"""
|
||||
**Use Case**
|
||||
* Get course grades of all users who are enrolled in a course.
|
||||
The currently logged-in user may request all enrolled user's grades information
|
||||
if they are allowed.
|
||||
**Example Request**
|
||||
GET /api/grades/v1/courses/{course_id}/ - Get grades for all users in course
|
||||
GET /api/grades/v1/courses/{course_id}/?username={username} - Get grades for specific user in course
|
||||
GET /api/grades/v1/courses/?course_id={course_id} - Get grades for all users in course
|
||||
GET /api/grades/v1/courses/?course_id={course_id}&username={username}- Get grades for specific user in course
|
||||
**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.
|
||||
**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.
|
||||
* email: A string representation of a user's email.
|
||||
* course_id: A string representation of a Course ID.
|
||||
* passed: Boolean representing whether the course has been
|
||||
passed according to 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",
|
||||
"email": "bob@example.com",
|
||||
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"passed": false,
|
||||
"percent": 0.03,
|
||||
"letter_grade": null,
|
||||
},
|
||||
{
|
||||
"username": "fred",
|
||||
"email": "fred@example.com",
|
||||
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"passed": true,
|
||||
"percent": 0.83,
|
||||
"letter_grade": "B",
|
||||
},
|
||||
{
|
||||
"username": "kate",
|
||||
"email": "kate@example.com",
|
||||
"course_id": "course-v1:edX+DemoX+Demo_Course",
|
||||
"passed": false,
|
||||
"percent": 0.19,
|
||||
"letter_grade": null,
|
||||
}]
|
||||
"""
|
||||
permission_classes = (IsUserInUrlOrStaff,)
|
||||
|
||||
def get(self, request, course_id=None):
|
||||
"""
|
||||
Gets a course progress status.
|
||||
Args:
|
||||
request (Request): Django request object.
|
||||
course_id (string): URI element specifying the course location.
|
||||
Can also be passed as a GET parameter instead.
|
||||
Return:
|
||||
A JSON serialized representation of the requesting user's current grade status.
|
||||
"""
|
||||
username = request.GET.get('username')
|
||||
|
||||
if not course_id:
|
||||
course_id = request.GET.get('course_id')
|
||||
|
||||
# Validate course exists with provided course_id
|
||||
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'
|
||||
)
|
||||
|
||||
if not CourseOverview.get_from_id_if_exists(course_key):
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message="Requested grade for unknown course {course}".format(course=course_id),
|
||||
error_code='course_does_not_exist'
|
||||
)
|
||||
|
||||
if username:
|
||||
# If there is a username passed, get grade for a single user
|
||||
try:
|
||||
return self._get_single_user_grade(request, course_key)
|
||||
except USER_MODEL.DoesNotExist:
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username does not exist.',
|
||||
error_code='user_does_not_exist'
|
||||
)
|
||||
except CourseEnrollment.DoesNotExist:
|
||||
return self.make_error_response(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
developer_message='The user matching the requested username is not enrolled in this course',
|
||||
error_code='user_not_enrolled'
|
||||
)
|
||||
else:
|
||||
# If no username passed, get paginated list of grades for all users in course
|
||||
return self._get_user_grades(course_key)
|
||||
@@ -324,6 +324,29 @@ class CourseOverview(TimeStampedModel):
|
||||
)
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def get_from_id_if_exists(cls, course_id):
|
||||
"""
|
||||
Return a CourseOverview for the provided course_id if it exists.
|
||||
Returns None if no CourseOverview exists with the provided course_id
|
||||
|
||||
This method will *not* generate new CourseOverviews or delete outdated
|
||||
ones. It exists only as a small optimization used when CourseOverviews
|
||||
are known to exist, for common situations like the student dashboard.
|
||||
|
||||
Callers should assume that this list is incomplete and fall back to
|
||||
get_from_id if they need to guarantee CourseOverview generation.
|
||||
"""
|
||||
try:
|
||||
course_overview = cls.objects.select_related('image_set').get(
|
||||
id=course_id,
|
||||
version__gte=cls.VERSION
|
||||
)
|
||||
except cls.DoesNotExist:
|
||||
course_overview = None
|
||||
|
||||
return course_overview
|
||||
|
||||
def clean_id(self, padding_char='='):
|
||||
"""
|
||||
Returns a unique deterministic base32-encoded ID for the course.
|
||||
|
||||
@@ -546,6 +546,18 @@ class CourseOverviewTestCase(CatalogIntegrationMixin, ModuleStoreTestCase):
|
||||
self.assertEqual(len(course_ids_to_overviews), 1)
|
||||
self.assertIn(course_with_overview_1.id, course_ids_to_overviews)
|
||||
|
||||
def test_get_from_id_if_exists(self):
|
||||
course_with_overview = CourseFactory.create(emit_signals=True)
|
||||
course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id)
|
||||
self.assertEqual(course_with_overview.id, course_id_to_overview.id)
|
||||
|
||||
overview_prev_version = CourseOverview.get_from_id_if_exists(course_with_overview.id)
|
||||
overview_prev_version.version = CourseOverview.VERSION - 1
|
||||
overview_prev_version.save()
|
||||
|
||||
course_id_to_overview = CourseOverview.get_from_id_if_exists(course_with_overview.id)
|
||||
self.assertEqual(course_id_to_overview, None)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
@ddt.ddt
|
||||
|
||||
@@ -62,7 +62,10 @@ class IsUserInUrl(permissions.BasePermission):
|
||||
Note: a 404 is returned for non-staff instead of a 403. This is to prevent
|
||||
users from being able to detect the existence of accounts.
|
||||
"""
|
||||
url_username = request.parser_context.get('kwargs', {}).get('username', '')
|
||||
url_username = (
|
||||
request.parser_context.get('kwargs', {}).get('username') or
|
||||
request.GET.get('username', '')
|
||||
)
|
||||
if request.user.username.lower() != url_username.lower():
|
||||
if request.user.is_staff:
|
||||
return False # staff gets 403
|
||||
|
||||
Reference in New Issue
Block a user