Revert "Unrevert "Fix Gating to use grades API, instead of its own calculations""
This reverts commit 7aacae9a4f.
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""
|
||||
Tests for ProctoredExamTransformer.
|
||||
"""
|
||||
from mock import patch
|
||||
from mock import patch, Mock
|
||||
from nose.plugins.attrib import attr
|
||||
|
||||
import ddt
|
||||
@@ -133,16 +133,18 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
|
||||
(
|
||||
'H',
|
||||
'A',
|
||||
'B',
|
||||
('course', 'A', 'B', 'C',)
|
||||
),
|
||||
(
|
||||
'H',
|
||||
'ProctoredExam',
|
||||
'D',
|
||||
('course', 'A', 'B', 'C'),
|
||||
),
|
||||
)
|
||||
@ddt.unpack
|
||||
def test_gated(self, gated_block_ref, gating_block_ref, expected_blocks_before_completion):
|
||||
def test_gated(self, gated_block_ref, gating_block_ref, gating_block_child, expected_blocks_before_completion):
|
||||
"""
|
||||
First, checks that a student cannot see the gated block when it is gated by the gating block and no
|
||||
attempt has been made to complete the gating block.
|
||||
@@ -162,14 +164,17 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
|
||||
# clear the request cache to simulate a new request
|
||||
self.clear_caches()
|
||||
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the gating block
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
self.user,
|
||||
self.blocks[gating_block_ref].location,
|
||||
100.0,
|
||||
)
|
||||
with self.assertNumQueries(3):
|
||||
# mock the api that the lms gating api calls to get the score for each block to always return 1 (ie 100%)
|
||||
with patch('gating.api.get_module_score', Mock(return_value=1)):
|
||||
|
||||
# this call triggers reevaluation of prerequisites fulfilled by the parent of the
|
||||
# block passed in, so we pass in a child of the gating block
|
||||
lms_gating_api.evaluate_prerequisite(
|
||||
self.course,
|
||||
self.blocks[gating_block_child],
|
||||
self.user.id,
|
||||
)
|
||||
with self.assertNumQueries(2):
|
||||
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
|
||||
|
||||
def test_staff_access(self):
|
||||
|
||||
@@ -10,14 +10,34 @@ import logging
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from opaque_keys.edx.keys import UsageKey
|
||||
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score
|
||||
from lms.djangoapps.grades.module_grades import get_module_score
|
||||
from util import milestones_helpers
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_xblock_parent(xblock, category=None):
|
||||
"""
|
||||
Returns the parent of the given XBlock. If an optional category is supplied,
|
||||
traverses the ancestors of the XBlock and returns the first with the
|
||||
given category.
|
||||
|
||||
Arguments:
|
||||
xblock (XBlock): Get the parent of this XBlock
|
||||
category (str): Find an ancestor with this category (e.g. sequential)
|
||||
"""
|
||||
parent = xblock.get_parent()
|
||||
if parent and category:
|
||||
if parent.category == category:
|
||||
return parent
|
||||
else:
|
||||
return _get_xblock_parent(parent, category)
|
||||
return parent
|
||||
|
||||
|
||||
@gating_api.gating_enabled(default=False)
|
||||
def evaluate_prerequisite(course, user, subsection_usage_key, new_score):
|
||||
def evaluate_prerequisite(course, block, user_id):
|
||||
"""
|
||||
Finds the parent subsection of the content in the course and evaluates
|
||||
any milestone relationships attached to that subsection. If the calculated
|
||||
@@ -32,33 +52,39 @@ def evaluate_prerequisite(course, user, subsection_usage_key, new_score):
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
prereq_milestone = gating_api.get_gating_milestone(
|
||||
course.id,
|
||||
subsection_usage_key,
|
||||
'fulfills'
|
||||
)
|
||||
if prereq_milestone:
|
||||
gated_content_milestones = defaultdict(list)
|
||||
for milestone in gating_api.find_gating_milestones(course.id, None, 'requires'):
|
||||
gated_content_milestones[milestone['id']].append(milestone)
|
||||
sequential = _get_xblock_parent(block, 'sequential')
|
||||
if sequential:
|
||||
prereq_milestone = gating_api.get_gating_milestone(
|
||||
course.id,
|
||||
sequential.location.for_branch(None),
|
||||
'fulfills'
|
||||
)
|
||||
if prereq_milestone:
|
||||
gated_content_milestones = defaultdict(list)
|
||||
for milestone in gating_api.find_gating_milestones(course.id, None, 'requires'):
|
||||
gated_content_milestones[milestone['id']].append(milestone)
|
||||
|
||||
gated_content = gated_content_milestones.get(prereq_milestone['id'])
|
||||
if gated_content:
|
||||
for milestone in gated_content:
|
||||
# Default minimum score to 100
|
||||
min_score = 100
|
||||
requirements = milestone.get('requirements')
|
||||
if requirements:
|
||||
try:
|
||||
min_score = int(requirements.get('min_score'))
|
||||
except (ValueError, TypeError):
|
||||
# Use default value of 100
|
||||
pass
|
||||
gated_content = gated_content_milestones.get(prereq_milestone['id'])
|
||||
if gated_content:
|
||||
user = User.objects.get(id=user_id)
|
||||
score = get_module_score(user, course, sequential) * 100
|
||||
for milestone in gated_content:
|
||||
# Default minimum score to 100
|
||||
min_score = 100
|
||||
requirements = milestone.get('requirements')
|
||||
if requirements:
|
||||
try:
|
||||
min_score = int(requirements.get('min_score'))
|
||||
except (ValueError, TypeError):
|
||||
log.warning(
|
||||
'Failed to find minimum score for gating milestone %s, defaulting to 100',
|
||||
json.dumps(milestone)
|
||||
)
|
||||
|
||||
if new_score >= min_score:
|
||||
milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
|
||||
else:
|
||||
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
|
||||
if score >= min_score:
|
||||
milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone)
|
||||
else:
|
||||
milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone)
|
||||
|
||||
|
||||
def evaluate_entrance_exam(course, block, user_id):
|
||||
|
||||
@@ -4,7 +4,7 @@ Signal handlers for the gating djangoapp
|
||||
from django.dispatch import receiver
|
||||
|
||||
from gating import api as gating_api
|
||||
from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED, SUBSECTION_SCORE_CHANGED
|
||||
from lms.djangoapps.grades.signals.signals import PROBLEM_SCORE_CHANGED
|
||||
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -24,26 +24,5 @@ def handle_score_changed(**kwargs):
|
||||
"""
|
||||
course = modulestore().get_course(CourseKey.from_string(kwargs.get('course_id')))
|
||||
block = modulestore().get_item(UsageKey.from_string(kwargs.get('usage_id')))
|
||||
gating_api.evaluate_prerequisite(course, block, kwargs.get('user_id'))
|
||||
gating_api.evaluate_entrance_exam(course, block, kwargs.get('user_id'))
|
||||
|
||||
|
||||
@receiver(SUBSECTION_SCORE_CHANGED)
|
||||
def handle_subsection_score_changed(**kwargs):
|
||||
"""
|
||||
Receives the SUBSECTION_SCORE_CHANGED signal and triggers the evaluation of
|
||||
any milestone relationships which are attached to the updated content.
|
||||
Arguments:
|
||||
kwargs (dict): Contains user ID, course key, and content usage key
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
course = kwargs['course']
|
||||
if course.enable_subsection_gating:
|
||||
subsection_grade = kwargs['subsection_grade']
|
||||
new_score = subsection_grade.graded_total.earned / subsection_grade.graded_total.possible * 100.0
|
||||
gating_api.evaluate_prerequisite(
|
||||
course,
|
||||
kwargs['user'],
|
||||
subsection_grade.location,
|
||||
new_score,
|
||||
)
|
||||
|
||||
@@ -6,18 +6,15 @@ from nose.plugins.attrib import attr
|
||||
from ddt import ddt, data, unpack
|
||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from courseware.tests.helpers import LoginEnrollmentTestCase
|
||||
|
||||
from lms.djangoapps.courseware.access import has_access
|
||||
from lms.djangoapps.courseware.tests.helpers import get_request_for_user
|
||||
from lms.djangoapps.grades.tests.utils import answer_problem
|
||||
from lms.djangoapps.grades.new.course_grade import CourseGradeFactory
|
||||
from milestones import api as milestones_api
|
||||
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||
from openedx.core.lib.gating import api as gating_api
|
||||
from request_cache.middleware import RequestCache
|
||||
from gating.api import _get_xblock_parent, evaluate_prerequisite
|
||||
|
||||
|
||||
class GatingTestCase(ModuleStoreTestCase):
|
||||
class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
|
||||
"""
|
||||
Base TestCase class for setting up a basic course structure
|
||||
and testing the gating feature
|
||||
@@ -37,16 +34,6 @@ class GatingTestCase(ModuleStoreTestCase):
|
||||
display_name='edX 101'
|
||||
)
|
||||
self.course.enable_subsection_gating = True
|
||||
grading_policy = {
|
||||
"GRADER": [{
|
||||
"type": "Homework",
|
||||
"min_count": 3,
|
||||
"drop_count": 0,
|
||||
"short_label": "HW",
|
||||
"weight": 1.0
|
||||
}]
|
||||
}
|
||||
self.course.grading_policy = grading_policy
|
||||
self.course.save()
|
||||
self.store.update_item(self.course, 0)
|
||||
|
||||
@@ -54,195 +41,136 @@ class GatingTestCase(ModuleStoreTestCase):
|
||||
self.chapter1 = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category='chapter',
|
||||
display_name='chapter 1'
|
||||
display_name='untitled chapter 1'
|
||||
)
|
||||
|
||||
# create sequentials
|
||||
self.seq1 = ItemFactory.create(
|
||||
parent_location=self.chapter1.location,
|
||||
category='sequential',
|
||||
display_name='gating sequential 1',
|
||||
graded=True,
|
||||
format='Homework',
|
||||
display_name='untitled sequential 1'
|
||||
)
|
||||
self.seq2 = ItemFactory.create(
|
||||
parent_location=self.chapter1.location,
|
||||
category='sequential',
|
||||
display_name='gated sequential 2',
|
||||
graded=True,
|
||||
format='Homework',
|
||||
display_name='untitled sequential 2'
|
||||
)
|
||||
self.seq3 = ItemFactory.create(
|
||||
parent_location=self.chapter1.location,
|
||||
category='sequential',
|
||||
display_name='sequential 3',
|
||||
graded=True,
|
||||
format='Homework',
|
||||
|
||||
# create vertical
|
||||
self.vert1 = ItemFactory.create(
|
||||
parent_location=self.seq1.location,
|
||||
category='vertical',
|
||||
display_name='untitled vertical 1'
|
||||
)
|
||||
|
||||
# create problem
|
||||
self.gating_prob1 = ItemFactory.create(
|
||||
parent_location=self.seq1.location,
|
||||
self.prob1 = ItemFactory.create(
|
||||
parent_location=self.vert1.location,
|
||||
category='problem',
|
||||
display_name='gating problem 1',
|
||||
)
|
||||
self.gated_prob2 = ItemFactory.create(
|
||||
parent_location=self.seq2.location,
|
||||
category='problem',
|
||||
display_name='gated problem 2',
|
||||
)
|
||||
self.prob3 = ItemFactory.create(
|
||||
parent_location=self.seq3.location,
|
||||
category='problem',
|
||||
display_name='problem 3',
|
||||
display_name='untitled problem 1'
|
||||
)
|
||||
|
||||
# create orphan
|
||||
self.orphan = ItemFactory.create(
|
||||
self.prob2 = ItemFactory.create(
|
||||
parent_location=self.course.location,
|
||||
category='problem',
|
||||
display_name='orphan'
|
||||
)
|
||||
|
||||
self.prereq_milestone = None
|
||||
|
||||
def setup_gating_milestone(self, min_score):
|
||||
"""
|
||||
Setup a gating milestone for testing.
|
||||
Gating content: seq1 (must be fulfilled before access to seq2)
|
||||
Gated content: seq2 (requires completion of seq1 before access)
|
||||
"""
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, min_score)
|
||||
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
|
||||
|
||||
def verify_access_to_gated_content(self, user, expected_access):
|
||||
"""
|
||||
Verifies access to gated content for the given user is as expected.
|
||||
"""
|
||||
# clear the request cache to flush any cached access results
|
||||
RequestCache.clear_request_cache()
|
||||
|
||||
# access to gating content (seq1) remains constant
|
||||
self.assertTrue(has_access(user, 'load', self.seq1, self.course.id))
|
||||
|
||||
# access to gated content (seq2) is as expected
|
||||
self.assertEquals(bool(has_access(user, 'load', self.seq2, self.course.id)), expected_access)
|
||||
|
||||
def verify_user_has_prereq_milestone(self, user, expected_has_milestone):
|
||||
"""
|
||||
Verifies whether or not the user has the prereq milestone
|
||||
"""
|
||||
self.assertEquals(
|
||||
milestones_api.user_has_milestone({'id': user.id}, self.prereq_milestone),
|
||||
expected_has_milestone,
|
||||
display_name='untitled problem 2'
|
||||
)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
class TestGatedContent(GatingTestCase, MilestonesTestCaseMixin):
|
||||
class TestGetXBlockParent(GatingTestCase):
|
||||
"""
|
||||
Tests for gated content.
|
||||
Tests for the get_xblock_parent function
|
||||
"""
|
||||
def setUp(self):
|
||||
super(TestGatedContent, self).setUp()
|
||||
self.setup_gating_milestone(100)
|
||||
self.non_staff_user, _ = self.create_non_staff_user()
|
||||
|
||||
def test_gated_for_nonstaff(self):
|
||||
self.verify_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
|
||||
self.verify_access_to_gated_content(self.non_staff_user, expected_access=False)
|
||||
def test_get_direct_parent(self):
|
||||
""" Test test_get_direct_parent """
|
||||
|
||||
def test_not_gated_for_staff(self):
|
||||
self.verify_user_has_prereq_milestone(self.user, expected_has_milestone=False)
|
||||
self.verify_access_to_gated_content(self.user, expected_access=True)
|
||||
result = _get_xblock_parent(self.vert1)
|
||||
self.assertEqual(result.location, self.seq1.location)
|
||||
|
||||
def _verify_course_grade(self, user, expected_percent):
|
||||
"""
|
||||
Verifies the given user's course grade is the expected percentage.
|
||||
Also verifies the user's grade information contains values for
|
||||
all problems in the course, whether or not they are currently
|
||||
gated.
|
||||
"""
|
||||
course_grade = CourseGradeFactory(user).create(self.course)
|
||||
for prob in [self.gating_prob1, self.gated_prob2, self.prob3]:
|
||||
self.assertIn(prob.location, course_grade.locations_to_scores)
|
||||
self.assertNotIn(self.orphan.location, course_grade.locations_to_scores)
|
||||
def test_get_parent_with_category(self):
|
||||
""" Test test_get_parent_of_category """
|
||||
|
||||
self.assertEquals(course_grade.percent, expected_percent)
|
||||
result = _get_xblock_parent(self.vert1, 'sequential')
|
||||
self.assertEqual(result.location, self.seq1.location)
|
||||
result = _get_xblock_parent(self.vert1, 'chapter')
|
||||
self.assertEqual(result.location, self.chapter1.location)
|
||||
|
||||
def test_gated_content_always_in_grades(self):
|
||||
request = get_request_for_user(self.non_staff_user)
|
||||
def test_get_parent_none(self):
|
||||
""" Test test_get_parent_none """
|
||||
|
||||
# start with a grade from a non-gated subsection
|
||||
answer_problem(self.course, request, self.prob3, 10, 10)
|
||||
|
||||
# verify gated status and overall course grade percentage
|
||||
self.verify_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False)
|
||||
self.verify_access_to_gated_content(self.non_staff_user, expected_access=False)
|
||||
self._verify_course_grade(self.non_staff_user, .33)
|
||||
|
||||
# fulfill the gated requirements
|
||||
answer_problem(self.course, request, self.gating_prob1, 10, 10)
|
||||
|
||||
# verify gated status and overall course grade percentage
|
||||
self.verify_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True)
|
||||
self.verify_access_to_gated_content(self.non_staff_user, expected_access=True)
|
||||
self._verify_course_grade(self.non_staff_user, .67)
|
||||
result = _get_xblock_parent(self.vert1, 'unit')
|
||||
self.assertIsNone(result)
|
||||
|
||||
|
||||
@attr(shard=3)
|
||||
@ddt
|
||||
class TestHandleSubsectionGradeUpdates(GatingTestCase, MilestonesTestCaseMixin):
|
||||
class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
|
||||
"""
|
||||
Tests for gated content when subsection grade is updated.
|
||||
Tests for the evaluate_prerequisite function
|
||||
"""
|
||||
|
||||
def setUp(self):
|
||||
super(TestHandleSubsectionGradeUpdates, self).setUp()
|
||||
self.user, _ = self.create_non_staff_user() # run tests for a non-staff user
|
||||
self.request = get_request_for_user(self.user)
|
||||
super(TestEvaluatePrerequisite, self).setUp()
|
||||
self.user_dict = {'id': self.user.id}
|
||||
self.prereq_milestone = None
|
||||
|
||||
def test_signal_handler_called(self):
|
||||
with patch('lms.djangoapps.gating.signals.gating_api.evaluate_prerequisite') as mock_handler:
|
||||
self.assertFalse(mock_handler.called)
|
||||
answer_problem(self.course, self.request, self.gating_prob1, 1, 1)
|
||||
self.assertTrue(mock_handler.called)
|
||||
def _setup_gating_milestone(self, min_score):
|
||||
"""
|
||||
Setup a gating milestone for testing
|
||||
"""
|
||||
|
||||
@data((1, 2, True), (1, 1, True), (0, 1, False))
|
||||
@unpack
|
||||
def test_min_score_achieved(self, earned, max_possible, result):
|
||||
self.setup_gating_milestone(50)
|
||||
|
||||
self.verify_user_has_prereq_milestone(self.user, expected_has_milestone=False)
|
||||
self.verify_access_to_gated_content(self.user, expected_access=False)
|
||||
|
||||
answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
|
||||
|
||||
self.verify_user_has_prereq_milestone(self.user, expected_has_milestone=result)
|
||||
self.verify_access_to_gated_content(self.user, expected_access=result)
|
||||
|
||||
@data((1, 2, False), (1, 1, True))
|
||||
@unpack
|
||||
def test_invalid_min_score(self, earned, max_possible, result):
|
||||
self.setup_gating_milestone(None)
|
||||
|
||||
answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible)
|
||||
self.verify_user_has_prereq_milestone(self.user, expected_has_milestone=result)
|
||||
|
||||
def test_orphaned_xblock(self):
|
||||
with patch('lms.djangoapps.gating.signals.gating_api.evaluate_prerequisite') as mock_handler:
|
||||
self.assertFalse(mock_handler.called)
|
||||
answer_problem(self.course, self.request, self.orphan, 1, 1)
|
||||
self.assertFalse(mock_handler.called)
|
||||
|
||||
@patch('gating.api.milestones_helpers')
|
||||
def test_no_prerequisites(self, mock_milestones):
|
||||
answer_problem(self.course, self.request, self.gating_prob1, 1, 1)
|
||||
self.assertFalse(mock_milestones.called)
|
||||
|
||||
@patch('gating.api.milestones_helpers')
|
||||
def test_no_gated_content(self, mock_milestones):
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
answer_problem(self.course, self.request, self.gating_prob1, 1, 1)
|
||||
self.assertFalse(mock_milestones.called)
|
||||
gating_api.set_required_content(self.course.id, self.seq2.location, self.seq1.location, min_score)
|
||||
self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
|
||||
|
||||
@patch('gating.api.get_module_score')
|
||||
@data((.5, True), (1, True), (0, False))
|
||||
@unpack
|
||||
def test_min_score_achieved(self, module_score, result, mock_module_score):
|
||||
""" Test test_min_score_achieved """
|
||||
|
||||
self._setup_gating_milestone(50)
|
||||
|
||||
mock_module_score.return_value = module_score
|
||||
evaluate_prerequisite(self.course, self.prob1, self.user.id)
|
||||
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
|
||||
|
||||
@patch('gating.api.log.warning')
|
||||
@patch('gating.api.get_module_score')
|
||||
@data((.5, False), (1, True))
|
||||
@unpack
|
||||
def test_invalid_min_score(self, module_score, result, mock_module_score, mock_log):
|
||||
""" Test test_invalid_min_score """
|
||||
|
||||
self._setup_gating_milestone(None)
|
||||
|
||||
mock_module_score.return_value = module_score
|
||||
evaluate_prerequisite(self.course, self.prob1, self.user.id)
|
||||
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
|
||||
self.assertTrue(mock_log.called)
|
||||
|
||||
@patch('gating.api.get_module_score')
|
||||
def test_orphaned_xblock(self, mock_module_score):
|
||||
""" Test test_orphaned_xblock """
|
||||
|
||||
evaluate_prerequisite(self.course, self.prob2, self.user.id)
|
||||
self.assertFalse(mock_module_score.called)
|
||||
|
||||
@patch('gating.api.get_module_score')
|
||||
def test_no_prerequisites(self, mock_module_score):
|
||||
""" Test test_no_prerequisites """
|
||||
|
||||
evaluate_prerequisite(self.course, self.prob1, self.user.id)
|
||||
self.assertFalse(mock_module_score.called)
|
||||
|
||||
@patch('gating.api.get_module_score')
|
||||
def test_no_gated_content(self, mock_module_score):
|
||||
""" Test test_no_gated_content """
|
||||
|
||||
# Setup gating milestones data
|
||||
gating_api.add_prerequisite(self.course.id, self.seq1.location)
|
||||
|
||||
evaluate_prerequisite(self.course, self.prob1, self.user.id)
|
||||
self.assertFalse(mock_module_score.called)
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
"""
|
||||
Unit tests for gating.signals module
|
||||
"""
|
||||
from mock import patch, MagicMock
|
||||
from mock import patch
|
||||
|
||||
from student.tests.factories import UserFactory
|
||||
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
|
||||
from xmodule.modulestore.tests.factories import CourseFactory
|
||||
from xmodule.modulestore.django import modulestore
|
||||
|
||||
from gating.signals import handle_subsection_score_changed
|
||||
from gating.signals import handle_score_changed
|
||||
|
||||
|
||||
class TestHandleScoreChanged(ModuleStoreTestCase):
|
||||
@@ -26,21 +26,25 @@ class TestHandleScoreChanged(ModuleStoreTestCase):
|
||||
""" Test evaluate_prerequisite is called when course.enable_subsection_gating is True """
|
||||
self.course.enable_subsection_gating = True
|
||||
modulestore().update_item(self.course, 0)
|
||||
handle_subsection_score_changed(
|
||||
handle_score_changed(
|
||||
sender=None,
|
||||
course=self.course,
|
||||
user=self.user,
|
||||
subsection_grade=MagicMock(),
|
||||
points_possible=1,
|
||||
points_earned=1,
|
||||
user_id=self.user.id,
|
||||
course_id=unicode(self.course.id),
|
||||
usage_id=unicode(self.test_usage_key)
|
||||
)
|
||||
mock_evaluate.assert_called()
|
||||
mock_evaluate.assert_called_with(self.course, self.course, self.user.id) # pylint: disable=no-member
|
||||
|
||||
@patch('gating.signals.gating_api.evaluate_prerequisite')
|
||||
def test_gating_disabled(self, mock_evaluate):
|
||||
""" Test evaluate_prerequisite is not called when course.enable_subsection_gating is False """
|
||||
handle_subsection_score_changed(
|
||||
handle_score_changed(
|
||||
sender=None,
|
||||
course=self.course,
|
||||
user=self.user,
|
||||
subsection_grade=MagicMock(),
|
||||
points_possible=1,
|
||||
points_earned=1,
|
||||
user_id=self.user.id,
|
||||
course_id=unicode(self.course.id),
|
||||
usage_id=unicode(self.test_usage_key)
|
||||
)
|
||||
mock_evaluate.assert_not_called()
|
||||
|
||||
96
lms/djangoapps/grades/module_grades.py
Normal file
96
lms/djangoapps/grades/module_grades.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
Functionality for module-level grades.
|
||||
"""
|
||||
# TODO The score computation in this file is not accurate
|
||||
# since it is summing percentages instead of computing a
|
||||
# final percentage of the individual sums.
|
||||
# Regardless, this file and its code should be removed soon
|
||||
# as part of TNL-5062.
|
||||
|
||||
from django.test.client import RequestFactory
|
||||
from courseware.model_data import FieldDataCache, ScoresClient
|
||||
from courseware.module_render import get_module_for_descriptor
|
||||
from opaque_keys.edx.locator import BlockUsageLocator
|
||||
from util.module_utils import yield_dynamic_descriptor_descendants
|
||||
|
||||
|
||||
def _get_mock_request(student):
|
||||
"""
|
||||
Make a fake request because grading code expects to be able to look at
|
||||
the request. We have to attach the correct user to the request before
|
||||
grading that student.
|
||||
"""
|
||||
request = RequestFactory().get('/')
|
||||
request.user = student
|
||||
return request
|
||||
|
||||
|
||||
def _calculate_score_for_modules(user_id, course, modules):
|
||||
"""
|
||||
Calculates the cumulative score (percent) of the given modules
|
||||
"""
|
||||
|
||||
# removing branch and version from exam modules locator
|
||||
# otherwise student module would not return scores since module usage keys would not match
|
||||
modules = [m for m in modules]
|
||||
locations = [
|
||||
BlockUsageLocator(
|
||||
course_key=course.id,
|
||||
block_type=module.location.block_type,
|
||||
block_id=module.location.block_id
|
||||
)
|
||||
if isinstance(module.location, BlockUsageLocator) and module.location.version
|
||||
else module.location
|
||||
for module in modules
|
||||
]
|
||||
|
||||
scores_client = ScoresClient(course.id, user_id)
|
||||
scores_client.fetch_scores(locations)
|
||||
|
||||
# Iterate over all of the exam modules to get score percentage of user for each of them
|
||||
module_percentages = []
|
||||
ignore_categories = ['course', 'chapter', 'sequential', 'vertical', 'randomize', 'library_content']
|
||||
for index, module in enumerate(modules):
|
||||
if module.category not in ignore_categories and (module.graded or module.has_score):
|
||||
module_score = scores_client.get(locations[index])
|
||||
if module_score:
|
||||
correct = module_score.correct or 0
|
||||
total = module_score.total or 1
|
||||
module_percentages.append(correct / total)
|
||||
|
||||
return sum(module_percentages) / float(len(module_percentages)) if module_percentages else 0
|
||||
|
||||
|
||||
def get_module_score(user, course, module):
|
||||
"""
|
||||
Collects all children of the given module and calculates the cumulative
|
||||
score for this set of modules for the given user.
|
||||
|
||||
Arguments:
|
||||
user (User): The user
|
||||
course (CourseModule): The course
|
||||
module (XBlock): The module
|
||||
|
||||
Returns:
|
||||
float: The cumulative score
|
||||
"""
|
||||
def inner_get_module(descriptor):
|
||||
"""
|
||||
Delegate to get_module_for_descriptor
|
||||
"""
|
||||
field_data_cache = FieldDataCache([descriptor], course.id, user)
|
||||
return get_module_for_descriptor(
|
||||
user,
|
||||
_get_mock_request(user),
|
||||
descriptor,
|
||||
field_data_cache,
|
||||
course.id,
|
||||
course=course
|
||||
)
|
||||
|
||||
modules = yield_dynamic_descriptor_descendants(
|
||||
module,
|
||||
user.id,
|
||||
inner_get_module
|
||||
)
|
||||
return _calculate_score_for_modules(user.id, course, modules)
|
||||
@@ -253,6 +253,11 @@ class SubsectionGradeFactory(object):
|
||||
"""
|
||||
Updates the SubsectionGrade object for the student and subsection.
|
||||
"""
|
||||
# Save ourselves the extra queries if the course does not persist
|
||||
# subsection grades.
|
||||
if not PersistentGradesEnabledFlag.feature_enabled(self.course.id):
|
||||
return
|
||||
|
||||
self._log_event(log.warning, u"update, subsection: {}".format(subsection.location))
|
||||
|
||||
block_structure = self._get_block_structure(block_structure)
|
||||
@@ -277,10 +282,8 @@ class SubsectionGradeFactory(object):
|
||||
):
|
||||
return orig_subsection_grade
|
||||
|
||||
if PersistentGradesEnabledFlag.feature_enabled(self.course.id):
|
||||
grade_model = calculated_grade.update_or_create_model(self.student)
|
||||
self._update_saved_subsection_grade(subsection.location, grade_model)
|
||||
|
||||
grade_model = calculated_grade.update_or_create_model(self.student)
|
||||
self._update_saved_subsection_grade(subsection.location, grade_model)
|
||||
return calculated_grade
|
||||
|
||||
@lazy
|
||||
|
||||
@@ -33,6 +33,7 @@ def recalculate_subsection_grade(user_id, course_id, usage_id, only_if_higher):
|
||||
value.
|
||||
"""
|
||||
course_key = CourseLocator.from_string(course_id)
|
||||
|
||||
student = User.objects.get(id=user_id)
|
||||
scored_block_usage_key = UsageKey.from_string(usage_id).replace(course_key=course_key)
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
from .utils import answer_problem
|
||||
from .. import course_grades
|
||||
from ..course_grades import summary as grades_summary
|
||||
from ..module_grades import get_module_score
|
||||
from ..new.course_grade import CourseGradeFactory
|
||||
from ..new.subsection_grade import SubsectionGradeFactory
|
||||
|
||||
@@ -341,3 +342,194 @@ class TestScoreForModule(SharedModuleStoreTestCase):
|
||||
earned, possible = self.course_grade.score_for_module(self.m.location)
|
||||
self.assertEqual(earned, 0)
|
||||
self.assertEqual(possible, 0)
|
||||
|
||||
|
||||
class TestGetModuleScore(LoginEnrollmentTestCase, SharedModuleStoreTestCase):
|
||||
"""
|
||||
Test get_module_score
|
||||
"""
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
super(TestGetModuleScore, cls).setUpClass()
|
||||
cls.course = CourseFactory.create()
|
||||
cls.chapter = ItemFactory.create(
|
||||
parent=cls.course,
|
||||
category="chapter",
|
||||
display_name="Test Chapter"
|
||||
)
|
||||
cls.seq1 = ItemFactory.create(
|
||||
parent=cls.chapter,
|
||||
category='sequential',
|
||||
display_name="Test Sequential 1",
|
||||
graded=True
|
||||
)
|
||||
cls.seq2 = ItemFactory.create(
|
||||
parent=cls.chapter,
|
||||
category='sequential',
|
||||
display_name="Test Sequential 2",
|
||||
graded=True
|
||||
)
|
||||
cls.seq3 = ItemFactory.create(
|
||||
parent=cls.chapter,
|
||||
category='sequential',
|
||||
display_name="Test Sequential 3",
|
||||
graded=True
|
||||
)
|
||||
cls.vert1 = ItemFactory.create(
|
||||
parent=cls.seq1,
|
||||
category='vertical',
|
||||
display_name='Test Vertical 1'
|
||||
)
|
||||
cls.vert2 = ItemFactory.create(
|
||||
parent=cls.seq2,
|
||||
category='vertical',
|
||||
display_name='Test Vertical 2'
|
||||
)
|
||||
cls.vert3 = ItemFactory.create(
|
||||
parent=cls.seq3,
|
||||
category='vertical',
|
||||
display_name='Test Vertical 3'
|
||||
)
|
||||
cls.randomize = ItemFactory.create(
|
||||
parent=cls.vert2,
|
||||
category='randomize',
|
||||
display_name='Test Randomize'
|
||||
)
|
||||
cls.library_content = ItemFactory.create(
|
||||
parent=cls.vert3,
|
||||
category='library_content',
|
||||
display_name='Test Library Content'
|
||||
)
|
||||
problem_xml = MultipleChoiceResponseXMLFactory().build_xml(
|
||||
question_text='The correct answer is Choice 3',
|
||||
choices=[False, False, True, False],
|
||||
choice_names=['choice_0', 'choice_1', 'choice_2', 'choice_3']
|
||||
)
|
||||
cls.problem1 = ItemFactory.create(
|
||||
parent=cls.vert1,
|
||||
category="problem",
|
||||
display_name="Test Problem 1",
|
||||
data=problem_xml
|
||||
)
|
||||
cls.problem2 = ItemFactory.create(
|
||||
parent=cls.vert1,
|
||||
category="problem",
|
||||
display_name="Test Problem 2",
|
||||
data=problem_xml
|
||||
)
|
||||
cls.problem3 = ItemFactory.create(
|
||||
parent=cls.randomize,
|
||||
category="problem",
|
||||
display_name="Test Problem 3",
|
||||
data=problem_xml
|
||||
)
|
||||
cls.problem4 = ItemFactory.create(
|
||||
parent=cls.randomize,
|
||||
category="problem",
|
||||
display_name="Test Problem 4",
|
||||
data=problem_xml
|
||||
)
|
||||
|
||||
cls.problem5 = ItemFactory.create(
|
||||
parent=cls.library_content,
|
||||
category="problem",
|
||||
display_name="Test Problem 5",
|
||||
data=problem_xml
|
||||
)
|
||||
cls.problem6 = ItemFactory.create(
|
||||
parent=cls.library_content,
|
||||
category="problem",
|
||||
display_name="Test Problem 6",
|
||||
data=problem_xml
|
||||
)
|
||||
|
||||
def setUp(self):
|
||||
"""
|
||||
Set up test course
|
||||
"""
|
||||
super(TestGetModuleScore, self).setUp()
|
||||
|
||||
self.request = get_request_for_user(UserFactory())
|
||||
self.client.login(username=self.request.user.username, password="test")
|
||||
CourseEnrollment.enroll(self.request.user, self.course.id)
|
||||
|
||||
self.course_structure = get_course_blocks(self.request.user, self.course.location)
|
||||
|
||||
# warm up the score cache to allow accurate query counts, even if tests are run in random order
|
||||
get_module_score(self.request.user, self.course, self.seq1)
|
||||
|
||||
def test_subsection_scores(self):
|
||||
"""
|
||||
Test test_get_module_score
|
||||
"""
|
||||
# One query is for getting the list of disabled XBlocks (which is
|
||||
# then stored in the request).
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
|
||||
self.assertEqual(score, 0)
|
||||
self.assertEqual(new_score.all_total.earned, 0)
|
||||
|
||||
answer_problem(self.course, self.request, self.problem1)
|
||||
answer_problem(self.course, self.request, self.problem2)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
|
||||
self.assertEqual(score, 1.0)
|
||||
self.assertEqual(new_score.all_total.earned, 2.0)
|
||||
# These differ because get_module_score normalizes the subsection score
|
||||
# to 1, which can cause incorrect aggregation behavior that will be
|
||||
# fixed by TNL-5062.
|
||||
|
||||
answer_problem(self.course, self.request, self.problem1)
|
||||
answer_problem(self.course, self.request, self.problem2, 0)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
new_score = SubsectionGradeFactory(self.request.user, self.course, self.course_structure).create(self.seq1)
|
||||
self.assertEqual(score, .5)
|
||||
self.assertEqual(new_score.all_total.earned, 1.0)
|
||||
|
||||
def test_get_module_score_with_empty_score(self):
|
||||
"""
|
||||
Test test_get_module_score_with_empty_score
|
||||
"""
|
||||
set_score(self.request.user.id, self.problem1.location, None, None) # pylint: disable=no-member
|
||||
set_score(self.request.user.id, self.problem2.location, None, None) # pylint: disable=no-member
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
self.assertEqual(score, 0)
|
||||
|
||||
answer_problem(self.course, self.request, self.problem1)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
self.assertEqual(score, 0.5)
|
||||
|
||||
answer_problem(self.course, self.request, self.problem2)
|
||||
|
||||
with self.assertNumQueries(1):
|
||||
score = get_module_score(self.request.user, self.course, self.seq1)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_get_module_score_with_randomize(self):
|
||||
"""
|
||||
Test test_get_module_score_with_randomize
|
||||
"""
|
||||
answer_problem(self.course, self.request, self.problem3)
|
||||
answer_problem(self.course, self.request, self.problem4)
|
||||
|
||||
score = get_module_score(self.request.user, self.course, self.seq2)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
def test_get_module_score_with_library_content(self):
|
||||
"""
|
||||
Test test_get_module_score_with_library_content
|
||||
"""
|
||||
answer_problem(self.course, self.request, self.problem5)
|
||||
answer_problem(self.course, self.request, self.problem6)
|
||||
|
||||
score = get_module_score(self.request.user, self.course, self.seq3)
|
||||
self.assertEqual(score, 1.0)
|
||||
|
||||
@@ -178,8 +178,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase):
|
||||
with self.store.default_store(default_store):
|
||||
self.set_up_course(enable_subsection_grades=False)
|
||||
self.assertFalse(PersistentGradesEnabledFlag.feature_enabled(self.course.id))
|
||||
additional_queries = 1 if default_store == ModuleStoreEnum.Type.mongo else 0
|
||||
with check_mongo_calls(2) and self.assertNumQueries(5):
|
||||
with check_mongo_calls(2) and self.assertNumQueries(2):
|
||||
recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values()))
|
||||
|
||||
@skip("Pending completion of TNL-5089")
|
||||
|
||||
Reference in New Issue
Block a user