Files
edx-platform/lms/djangoapps/grades/api/v1/tests/test_views.py
2018-12-06 10:53:16 -05:00

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)