1177 lines
48 KiB
Python
1177 lines
48 KiB
Python
"""
|
|
Tests for v1 views
|
|
"""
|
|
from __future__ import unicode_literals
|
|
import json
|
|
from collections import OrderedDict, namedtuple
|
|
from datetime import datetime
|
|
|
|
import ddt
|
|
from django.urls 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 six import text_type
|
|
|
|
from course_modes.models import CourseMode
|
|
from lms.djangoapps.courseware.tests.factories import GlobalStaffFactory
|
|
from lms.djangoapps.grades.api.v1.views import CourseGradesView, CourseEnrollmentPagination
|
|
from lms.djangoapps.grades.config.waffle import waffle_flags, WRITABLE_GRADEBOOK
|
|
from lms.djangoapps.grades.course_data import CourseData
|
|
from lms.djangoapps.grades.course_grade import CourseGrade
|
|
from lms.djangoapps.grades.models import PersistentSubsectionGrade, PersistentSubsectionGradeOverrideHistory
|
|
from lms.djangoapps.grades.subsection_grade import ReadSubsectionGrade
|
|
from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory
|
|
from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory
|
|
from openedx.core.djangoapps.user_authn.tests.utils import AuthAndScopesTestMixin
|
|
from openedx.core.djangoapps.waffle_utils.testutils import override_waffle_flag
|
|
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
|
|
|
|
def _create_user_enrollments(self, *users):
|
|
date = datetime(2013, 1, 22, tzinfo=UTC)
|
|
for user in users:
|
|
CourseEnrollmentFactory(
|
|
course_id=self.course.id,
|
|
user=user,
|
|
created=date,
|
|
)
|
|
|
|
def setUp(self):
|
|
super(GradeViewTestMixin, self).setUp()
|
|
self.password = 'test'
|
|
self.global_staff = GlobalStaffFactory.create()
|
|
self.student = UserFactory(password=self.password, username='student')
|
|
self.other_student = UserFactory(password=self.password, username='other_student')
|
|
self._create_user_enrollments(self.student, self.other_student)
|
|
|
|
@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, AuthAndScopesTestMixin, 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}
|
|
"""
|
|
default_scopes = CourseGradesView.required_scopes
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(SingleUserGradesTests, cls).setUpClass()
|
|
cls.namespaced_url = 'grades_api:v1:course_grades'
|
|
|
|
def get_url(self, username):
|
|
""" This method is required by AuthAndScopesTestMixin. """
|
|
base_url = reverse(
|
|
self.namespaced_url,
|
|
kwargs={
|
|
'course_id': self.course_key,
|
|
}
|
|
)
|
|
return "{0}?username={1}".format(base_url, username)
|
|
|
|
def assert_success_response_for_student(self, response):
|
|
""" This method is required by AuthAndScopesTestMixin. """
|
|
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(response.data, expected_data)
|
|
|
|
def test_nonexistent_user(self):
|
|
"""
|
|
Test that a request for a nonexistent username returns an error.
|
|
"""
|
|
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_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
|
|
unenrolled_user = UserFactory(password=self.password)
|
|
self.client.login(username=unenrolled_user.username, password=self.password)
|
|
resp = self.client.get(self.get_url(unenrolled_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.
|
|
"""
|
|
self.client.login(username=self.student.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,
|
|
'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')
|
|
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
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.
|
|
"""
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
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.
|
|
"""
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
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])
|
|
|
|
|
|
@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):
|
|
resp = self.client.get(self.get_url())
|
|
self.assertEqual(resp.status_code, status.HTTP_401_UNAUTHORIZED)
|
|
|
|
def test_student(self):
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
resp = self.client.get(self.get_url())
|
|
self.assertEqual(resp.status_code, status.HTTP_403_FORBIDDEN)
|
|
|
|
def test_course_does_not_exist(self):
|
|
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.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)
|
|
expected_data = OrderedDict([
|
|
('next', None),
|
|
('previous', None),
|
|
('results', []),
|
|
])
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
def test_staff_can_get_all_grades(self):
|
|
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 = OrderedDict([
|
|
('next', None),
|
|
('previous', None),
|
|
('results', [
|
|
{
|
|
'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(expected_data, resp.data)
|
|
|
|
|
|
class GradebookViewTestBase(GradeViewTestMixin, APITestCase):
|
|
"""
|
|
Base class for the gradebook GET and POST view tests.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(GradebookViewTestBase, cls).setUpClass()
|
|
cls.namespaced_url = 'grades_api:v1:course_gradebook'
|
|
cls.waffle_flag = waffle_flags()[WRITABLE_GRADEBOOK]
|
|
|
|
cls.course = CourseFactory.create(display_name='test-course', run='run-1')
|
|
cls.course_key = cls.course.id
|
|
cls.course_overview = CourseOverviewFactory.create(id=cls.course.id)
|
|
|
|
cls.chapter_1 = ItemFactory.create(
|
|
category='chapter',
|
|
parent_location=cls.course.location,
|
|
display_name="Chapter 1",
|
|
)
|
|
cls.chapter_2 = ItemFactory.create(
|
|
category='chapter',
|
|
parent_location=cls.course.location,
|
|
display_name="Chapter 2",
|
|
)
|
|
cls.subsections = {
|
|
cls.chapter_1.location: [
|
|
ItemFactory.create(
|
|
category='sequential',
|
|
parent_location=cls.chapter_1.location,
|
|
due=datetime(2017, 12, 18, 11, 30, 00),
|
|
display_name='HW 1',
|
|
format='Homework',
|
|
graded=True,
|
|
),
|
|
ItemFactory.create(
|
|
category='sequential',
|
|
parent_location=cls.chapter_1.location,
|
|
due=datetime(2017, 12, 18, 11, 30, 00),
|
|
display_name='Lab 1',
|
|
format='Lab',
|
|
graded=True,
|
|
),
|
|
],
|
|
cls.chapter_2.location: [
|
|
ItemFactory.create(
|
|
category='sequential',
|
|
parent_location=cls.chapter_2.location,
|
|
due=datetime(2017, 12, 18, 11, 30, 00),
|
|
display_name='HW 2',
|
|
format='Homework',
|
|
graded=True,
|
|
),
|
|
ItemFactory.create(
|
|
category='sequential',
|
|
parent_location=cls.chapter_2.location,
|
|
due=datetime(2017, 12, 18, 11, 30, 00),
|
|
display_name='Lab 2',
|
|
format='Lab',
|
|
graded=True,
|
|
),
|
|
],
|
|
}
|
|
cls.course_data = CourseData(None, course=cls.course)
|
|
# we have to force the collection of course data from the block_structure API
|
|
# so that CourseGrade.course_data objects can later have a non-null effective_structure
|
|
_ = cls.course_data.collected_structure
|
|
|
|
def get_url(self, course_key=None):
|
|
"""
|
|
Helper function to create the course gradebook API url.
|
|
"""
|
|
return reverse(
|
|
self.namespaced_url,
|
|
kwargs={
|
|
'course_id': course_key or self.course_key,
|
|
}
|
|
)
|
|
|
|
def login_staff(self):
|
|
"""
|
|
Helper function to login the global staff user, who has permissions to read from the
|
|
Gradebook API.
|
|
"""
|
|
self.client.login(username=self.global_staff.username, password=self.password)
|
|
|
|
|
|
@ddt.ddt
|
|
class GradebookViewTest(GradebookViewTestBase):
|
|
"""
|
|
Tests for the gradebook view.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(GradebookViewTest, cls).setUpClass()
|
|
cls.mock_subsection_grades = {
|
|
cls.subsections[cls.chapter_1.location][0].location: cls.mock_subsection_grade(
|
|
cls.subsections[cls.chapter_1.location][0],
|
|
earned_all=1.0,
|
|
possible_all=2.0,
|
|
earned_graded=1.0,
|
|
possible_graded=2.0,
|
|
),
|
|
cls.subsections[cls.chapter_1.location][1].location: cls.mock_subsection_grade(
|
|
cls.subsections[cls.chapter_1.location][1],
|
|
earned_all=1.0,
|
|
possible_all=2.0,
|
|
earned_graded=1.0,
|
|
possible_graded=2.0,
|
|
),
|
|
cls.subsections[cls.chapter_2.location][0].location: cls.mock_subsection_grade(
|
|
cls.subsections[cls.chapter_2.location][0],
|
|
earned_all=1.0,
|
|
possible_all=2.0,
|
|
earned_graded=1.0,
|
|
possible_graded=2.0,
|
|
),
|
|
cls.subsections[cls.chapter_2.location][1].location: cls.mock_subsection_grade(
|
|
cls.subsections[cls.chapter_2.location][1],
|
|
earned_all=1.0,
|
|
possible_all=2.0,
|
|
earned_graded=1.0,
|
|
possible_graded=2.0,
|
|
),
|
|
}
|
|
|
|
def get_url(self, course_key=None, username=None, username_contains=None): # pylint: disable=arguments-differ
|
|
"""
|
|
Helper function to create the course gradebook API read url.
|
|
"""
|
|
base_url = super(GradebookViewTest, self).get_url(course_key)
|
|
if username:
|
|
return "{0}?username={1}".format(base_url, username)
|
|
if username_contains:
|
|
return "{0}?username_contains={1}".format(base_url, username_contains)
|
|
return base_url
|
|
|
|
@staticmethod
|
|
def mock_subsection_grade(subsection, **kwargs):
|
|
"""
|
|
Helper function to mock a subsection grade.
|
|
"""
|
|
model = MagicMock(**kwargs)
|
|
factory = MagicMock()
|
|
return ReadSubsectionGrade(subsection, model, factory)
|
|
|
|
def mock_course_grade(self, user, **kwargs):
|
|
"""
|
|
Helper function to return a mock CourseGrade object.
|
|
"""
|
|
course_grade = CourseGrade(user=user, course_data=self.course_data, **kwargs)
|
|
course_grade.subsection_grade = lambda key: self.mock_subsection_grades[key]
|
|
return course_grade
|
|
|
|
def expected_subsection_grades(self, letter_grade=None):
|
|
"""
|
|
Helper function to generate expected subsection detail results.
|
|
"""
|
|
return [
|
|
OrderedDict([
|
|
('category', 'Homework'),
|
|
('displayed_value', '0.50'),
|
|
('is_graded', True),
|
|
('grade_description', '(1.00/2.00)'),
|
|
('label', 'HW 01'),
|
|
('letter_grade', letter_grade),
|
|
('module_id', text_type(self.subsections[self.chapter_1.location][0].location)),
|
|
('percent', 0.5),
|
|
('score_earned', 1.0),
|
|
('score_possible', 2.0),
|
|
('subsection_name', 'HW 1')
|
|
]),
|
|
OrderedDict([
|
|
('category', 'Lab'),
|
|
('displayed_value', '0.50'),
|
|
('is_graded', True),
|
|
('grade_description', '(1.00/2.00)'),
|
|
('label', 'Lab 01'),
|
|
('letter_grade', letter_grade),
|
|
('module_id', text_type(self.subsections[self.chapter_1.location][1].location)),
|
|
('percent', 0.5),
|
|
('score_earned', 1.0),
|
|
('score_possible', 2.0),
|
|
('subsection_name', 'Lab 1')
|
|
]),
|
|
OrderedDict([
|
|
('category', 'Homework'),
|
|
('displayed_value', '0.50'),
|
|
('is_graded', True),
|
|
('grade_description', '(1.00/2.00)'),
|
|
('label', 'HW 02'),
|
|
('letter_grade', letter_grade),
|
|
('module_id', text_type(self.subsections[self.chapter_2.location][0].location)),
|
|
('percent', 0.5),
|
|
('score_earned', 1.0),
|
|
('score_possible', 2.0),
|
|
('subsection_name', 'HW 2')
|
|
]),
|
|
OrderedDict([
|
|
('category', 'Lab'),
|
|
('displayed_value', '0.50'),
|
|
('is_graded', True),
|
|
('grade_description', '(1.00/2.00)'),
|
|
('label', 'Lab 02'),
|
|
('letter_grade', letter_grade),
|
|
('module_id', text_type(self.subsections[self.chapter_2.location][1].location)),
|
|
('percent', 0.5),
|
|
('score_earned', 1.0),
|
|
('score_possible', 2.0),
|
|
('subsection_name', 'Lab 2')
|
|
]),
|
|
]
|
|
|
|
def _assert_data_all_users(self, response):
|
|
"""
|
|
Helper method to assert that self.student and self.other_student
|
|
have the expected gradebook data.
|
|
"""
|
|
expected_results = [
|
|
OrderedDict([
|
|
('course_id', text_type(self.course.id)),
|
|
('email', self.student.email),
|
|
('user_id', self.student.id),
|
|
('username', self.student.username),
|
|
('full_name', self.student.get_full_name()),
|
|
('passed', True),
|
|
('percent', 0.85),
|
|
('letter_grade', 'A'),
|
|
('progress_page_url', reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(self.course.id), student_id=self.student.id)
|
|
)),
|
|
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
|
|
]),
|
|
OrderedDict([
|
|
('course_id', text_type(self.course.id)),
|
|
('email', self.other_student.email),
|
|
('user_id', self.other_student.id),
|
|
('username', self.other_student.username),
|
|
('full_name', self.other_student.get_full_name()),
|
|
('passed', False),
|
|
('percent', 0.45),
|
|
('letter_grade', None),
|
|
('progress_page_url', reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(self.course.id), student_id=self.other_student.id)
|
|
)),
|
|
('section_breakdown', self.expected_subsection_grades()),
|
|
]),
|
|
]
|
|
|
|
self.assertEqual(status.HTTP_200_OK, response.status_code)
|
|
actual_data = dict(response.data)
|
|
self.assertIsNone(actual_data['next'])
|
|
self.assertIsNone(actual_data['previous'])
|
|
self.assertEqual(expected_results, actual_data['results'])
|
|
|
|
def _assert_empty_response(self, response):
|
|
"""
|
|
Helper method for assertions about OK, empty responses.
|
|
"""
|
|
self.assertEqual(status.HTTP_200_OK, response.status_code)
|
|
actual_data = dict(response.data)
|
|
self.assertIsNone(actual_data['next'])
|
|
self.assertIsNone(actual_data['previous'])
|
|
self.assertEqual([], actual_data['results'])
|
|
|
|
def test_feature_not_enabled(self):
|
|
self.client.login(username=self.global_staff.username, password=self.password)
|
|
with override_waffle_flag(self.waffle_flag, active=False):
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.empty_course.id)
|
|
)
|
|
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
|
|
|
|
def test_anonymous(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
resp = self.client.get(self.get_url())
|
|
self.assertEqual(status.HTTP_401_UNAUTHORIZED, resp.status_code)
|
|
|
|
def test_student(self):
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
resp = self.client.get(self.get_url())
|
|
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
|
|
|
|
def test_course_does_not_exist(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1')
|
|
)
|
|
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
|
|
|
|
def test_user_does_not_exist(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id, username='not-a-real-user')
|
|
)
|
|
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
|
|
|
|
def test_user_not_enrolled(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.empty_course.id, username=self.student.username)
|
|
)
|
|
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
|
|
|
|
def test_course_no_enrollments(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.empty_course.id)
|
|
)
|
|
self._assert_empty_response(resp)
|
|
|
|
def test_gradebook_data_for_course(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.side_effect = [
|
|
self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85),
|
|
self.mock_course_grade(self.other_student, passed=False, letter_grade=None, percent=0.45),
|
|
]
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id)
|
|
)
|
|
self._assert_data_all_users(resp)
|
|
|
|
def test_gradebook_data_for_single_learner(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.return_value = self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85)
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id, username=self.student.username)
|
|
)
|
|
expected_results = OrderedDict([
|
|
('course_id', text_type(self.course.id)),
|
|
('email', self.student.email),
|
|
('user_id', self.student.id),
|
|
('username', self.student.username),
|
|
('full_name', self.student.get_full_name()),
|
|
('passed', True),
|
|
('percent', 0.85),
|
|
('letter_grade', 'A'),
|
|
('progress_page_url', reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(self.course.id), student_id=self.student.id)
|
|
)),
|
|
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
|
|
])
|
|
|
|
self.assertEqual(status.HTTP_200_OK, resp.status_code)
|
|
actual_data = dict(resp.data)
|
|
self.assertEqual(expected_results, actual_data)
|
|
|
|
def test_gradebook_data_filter_username_contains(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.return_value = self.mock_course_grade(
|
|
self.other_student, passed=True, letter_grade='A', percent=0.85
|
|
)
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id, username_contains='other')
|
|
)
|
|
expected_results = [
|
|
OrderedDict([
|
|
('course_id', text_type(self.course.id)),
|
|
('email', self.other_student.email),
|
|
('user_id', self.other_student.id),
|
|
('username', self.other_student.username),
|
|
('full_name', self.other_student.get_full_name()),
|
|
('passed', True),
|
|
('percent', 0.85),
|
|
('letter_grade', 'A'),
|
|
('progress_page_url', reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(self.course.id), student_id=self.other_student.id)
|
|
)),
|
|
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
|
|
]),
|
|
]
|
|
|
|
self.assertEqual(status.HTTP_200_OK, resp.status_code)
|
|
actual_data = dict(resp.data)
|
|
self.assertIsNone(actual_data['next'])
|
|
self.assertIsNone(actual_data['previous'])
|
|
self.assertEqual(expected_results, actual_data['results'])
|
|
|
|
def test_gradebook_data_filter_username_contains_no_match(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.return_value = self.mock_course_grade(
|
|
self.other_student, passed=True, letter_grade='A', percent=0.85
|
|
)
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id, username_contains='fooooooooooooooooo')
|
|
)
|
|
self._assert_empty_response(resp)
|
|
|
|
def test_filter_cohort_id_and_enrollment_mode(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.return_value = self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85)
|
|
|
|
cohort = CohortFactory(course_id=self.course.id, name="TestCohort", users=[self.student])
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
# both of our test users are in the audit track, so this is functionally equivalent
|
|
# to just `?cohort_id=cohort.id`.
|
|
query = '?cohort_id={}&enrollment_mode={}'.format(cohort.id, CourseMode.AUDIT)
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id) + query
|
|
)
|
|
|
|
expected_results = [
|
|
OrderedDict([
|
|
('course_id', text_type(self.course.id)),
|
|
('email', self.student.email),
|
|
('user_id', self.student.id),
|
|
('username', self.student.username),
|
|
('full_name', self.student.get_full_name()),
|
|
('passed', True),
|
|
('percent', 0.85),
|
|
('letter_grade', 'A'),
|
|
('progress_page_url', reverse(
|
|
'student_progress',
|
|
kwargs=dict(course_id=text_type(self.course.id), student_id=self.student.id)
|
|
)),
|
|
('section_breakdown', self.expected_subsection_grades(letter_grade='A')),
|
|
]),
|
|
]
|
|
|
|
self.assertEqual(status.HTTP_200_OK, resp.status_code)
|
|
actual_data = dict(resp.data)
|
|
self.assertIsNone(actual_data['next'])
|
|
self.assertIsNone(actual_data['previous'])
|
|
self.assertEqual(expected_results, actual_data['results'])
|
|
|
|
def test_filter_cohort_id_does_not_exist(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.return_value = self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85)
|
|
|
|
empty_cohort = CohortFactory(course_id=self.course.id, name="TestCohort", users=[])
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id) + '?cohort_id={}'.format(empty_cohort.id)
|
|
)
|
|
self._assert_empty_response(resp)
|
|
|
|
def test_filter_enrollment_mode(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.side_effect = [
|
|
self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85),
|
|
self.mock_course_grade(self.other_student, passed=False, letter_grade=None, percent=0.45),
|
|
]
|
|
|
|
# Enroll a verified student, for whom data should not be returned.
|
|
verified_student = UserFactory()
|
|
_ = CourseEnrollmentFactory(
|
|
course_id=self.course.id,
|
|
user=verified_student,
|
|
created=datetime(2013, 1, 1, tzinfo=UTC),
|
|
mode=CourseMode.VERIFIED,
|
|
)
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id) + '?enrollment_mode={}'.format(CourseMode.AUDIT)
|
|
)
|
|
|
|
self._assert_data_all_users(resp)
|
|
|
|
def test_filter_enrollment_mode_no_students(self):
|
|
with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_grade:
|
|
mock_grade.side_effect = [
|
|
self.mock_course_grade(self.student, passed=True, letter_grade='A', percent=0.85),
|
|
self.mock_course_grade(self.other_student, passed=False, letter_grade=None, percent=0.45),
|
|
]
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id) + '?enrollment_mode={}'.format(CourseMode.VERIFIED)
|
|
)
|
|
self._assert_empty_response(resp)
|
|
|
|
@ddt.data(None, 2, 3, 10, 60, 80)
|
|
def test_page_size_parameter(self, page_size):
|
|
user_size = 60
|
|
with patch(
|
|
'lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read'
|
|
) as mock_grade:
|
|
users = UserFactory.create_batch(user_size)
|
|
mocked_course_grades = []
|
|
for user in users:
|
|
self._create_user_enrollments(user)
|
|
mocked_course_grades.append(self.mock_course_grade(user, passed=True, letter_grade='A', percent=0.85))
|
|
|
|
mock_grade.side_effect = mocked_course_grades
|
|
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
query = ''
|
|
if page_size:
|
|
query = '?page_size={}'.format(page_size)
|
|
resp = self.client.get(
|
|
self.get_url(course_key=self.course.id) + query
|
|
)
|
|
self.assertEqual(status.HTTP_200_OK, resp.status_code)
|
|
actual_data = dict(resp.data)
|
|
expected_page_size = page_size or CourseEnrollmentPagination.page_size
|
|
if expected_page_size > user_size:
|
|
expected_page_size = user_size
|
|
self.assertEqual(len(actual_data['results']), expected_page_size)
|
|
|
|
|
|
class GradebookBulkUpdateViewTest(GradebookViewTestBase):
|
|
"""
|
|
Tests for the gradebook bulk-update view.
|
|
"""
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super(GradebookBulkUpdateViewTest, cls).setUpClass()
|
|
cls.namespaced_url = 'grades_api:v1:course_gradebook_bulk_update'
|
|
|
|
def test_feature_not_enabled(self):
|
|
self.client.login(username=self.global_staff.username, password=self.password)
|
|
with override_waffle_flag(self.waffle_flag, active=False):
|
|
resp = self.client.post(
|
|
self.get_url(course_key=self.empty_course.id)
|
|
)
|
|
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
|
|
|
|
def test_anonymous(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
resp = self.client.post(self.get_url())
|
|
self.assertEqual(status.HTTP_401_UNAUTHORIZED, resp.status_code)
|
|
|
|
def test_student(self):
|
|
self.client.login(username=self.student.username, password=self.password)
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
resp = self.client.post(self.get_url())
|
|
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
|
|
|
|
def test_course_does_not_exist(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
resp = self.client.post(
|
|
self.get_url(course_key='course-v1:MITx+8.MechCX+2014_T1')
|
|
)
|
|
self.assertEqual(status.HTTP_404_NOT_FOUND, resp.status_code)
|
|
|
|
def test_grades_frozen(self):
|
|
"""
|
|
Should receive a 403 when grades have been frozen for a course.
|
|
"""
|
|
with patch('lms.djangoapps.grades.api.v1.views.are_grades_frozen', return_value=True):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
post_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'grade': {}, # doesn't matter what we put here.
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
self.assertEqual(status.HTTP_403_FORBIDDEN, resp.status_code)
|
|
|
|
def test_user_not_enrolled(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
unenrolled_student = UserFactory()
|
|
post_data = [
|
|
{
|
|
'user_id': unenrolled_student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'grade': {}, # doesn't matter what we put here.
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
expected_data = [
|
|
{
|
|
'user_id': unenrolled_student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'success': False,
|
|
'reason': 'CourseEnrollment matching query does not exist.',
|
|
},
|
|
]
|
|
self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
def test_user_does_not_exist(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
post_data = [
|
|
{
|
|
'user_id': -123,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'grade': {}, # doesn't matter what we put here.
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
expected_data = [
|
|
{
|
|
'user_id': -123,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'success': False,
|
|
'reason': 'User matching query does not exist.',
|
|
},
|
|
]
|
|
self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
def test_invalid_usage_key(self):
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
post_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': 'not-a-valid-usage-key',
|
|
'grade': {}, # doesn't matter what we put here.
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
expected_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': 'not-a-valid-usage-key',
|
|
'success': False,
|
|
'reason': "<class 'opaque_keys.edx.locator.BlockUsageLocator'>: not-a-valid-usage-key",
|
|
},
|
|
]
|
|
self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
def test_subsection_does_not_exist(self):
|
|
"""
|
|
When trying to override a grade for a valid usage key that does not exist in the requested course,
|
|
we should get an error reason specifying that the key does not exist in the course.
|
|
"""
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
usage_id = 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@workflow'
|
|
post_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': usage_id,
|
|
'grade': {}, # doesn't matter what we put here.
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
expected_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': usage_id,
|
|
'success': False,
|
|
'reason': 'usage_key {} does not exist in this course.'.format(usage_id),
|
|
},
|
|
]
|
|
self.assertEqual(status.HTTP_422_UNPROCESSABLE_ENTITY, resp.status_code)
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
def test_override_is_created(self):
|
|
"""
|
|
Test that when we make multiple requests to update grades for the same user/subsection,
|
|
the score from the most recent request is recorded.
|
|
"""
|
|
with override_waffle_flag(self.waffle_flag, active=True):
|
|
self.login_staff()
|
|
post_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'grade': {
|
|
'earned_all_override': 3,
|
|
'possible_all_override': 3,
|
|
'earned_graded_override': 2,
|
|
'possible_graded_override': 2,
|
|
},
|
|
},
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][1].location),
|
|
'grade': {
|
|
'earned_all_override': 1,
|
|
'possible_all_override': 4,
|
|
'earned_graded_override': 1,
|
|
'possible_graded_override': 4,
|
|
},
|
|
}
|
|
]
|
|
|
|
resp = self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
expected_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][0].location),
|
|
'success': True,
|
|
'reason': None,
|
|
},
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][1].location),
|
|
'success': True,
|
|
'reason': None,
|
|
},
|
|
]
|
|
self.assertEqual(status.HTTP_202_ACCEPTED, resp.status_code)
|
|
self.assertEqual(expected_data, resp.data)
|
|
|
|
second_post_data = [
|
|
{
|
|
'user_id': self.student.id,
|
|
'usage_id': text_type(self.subsections[self.chapter_1.location][1].location),
|
|
'grade': {
|
|
'earned_all_override': 3,
|
|
'possible_all_override': 4,
|
|
'earned_graded_override': 3,
|
|
'possible_graded_override': 4,
|
|
},
|
|
},
|
|
]
|
|
|
|
self.client.post(
|
|
self.get_url(),
|
|
data=json.dumps(second_post_data),
|
|
content_type='application/json',
|
|
)
|
|
|
|
GradeFields = namedtuple('GradeFields', ['earned_all', 'possible_all', 'earned_graded', 'possible_graded'])
|
|
|
|
# We should now have PersistentSubsectionGradeOverride records corresponding to
|
|
# our bulk-update request, and PersistentSubsectionGrade records with grade values
|
|
# equal to those of the override.
|
|
for usage_key, expected_grades in (
|
|
(self.subsections[self.chapter_1.location][0].location, GradeFields(3, 3, 2, 2)),
|
|
(self.subsections[self.chapter_1.location][1].location, GradeFields(3, 4, 3, 4)),
|
|
):
|
|
# this selects related PersistentSubsectionGradeOverride objects.
|
|
grade = PersistentSubsectionGrade.read_grade(
|
|
user_id=self.student.id,
|
|
usage_key=usage_key,
|
|
)
|
|
for field_name in expected_grades._fields:
|
|
expected_value = getattr(expected_grades, field_name)
|
|
self.assertEqual(expected_value, getattr(grade, field_name))
|
|
self.assertEqual(expected_value, getattr(grade.override, field_name + '_override'))
|
|
|
|
update_records = PersistentSubsectionGradeOverrideHistory.objects.filter(user=self.global_staff)
|
|
self.assertEqual(update_records.count(), 3)
|
|
for audit_item in update_records:
|
|
self.assertEqual(audit_item.user, self.global_staff)
|
|
self.assertIsNotNone(audit_item.created)
|
|
self.assertEqual(audit_item.feature, PersistentSubsectionGradeOverrideHistory.GRADEBOOK)
|
|
self.assertEqual(audit_item.action, PersistentSubsectionGradeOverrideHistory.CREATE_OR_UPDATE)
|