From caeac6921a4f5c6ff2eaa4cb07278e5cbba45f63 Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Tue, 1 Nov 2016 12:43:50 -0400 Subject: [PATCH 1/2] Fast-fail grades tasks if feature flag disabled --- lms/djangoapps/grades/tasks.py | 3 +++ lms/djangoapps/grades/tests/test_tasks.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index f65815389f..66a223a97b 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -13,6 +13,7 @@ from opaque_keys.edx.locator import CourseLocator from openedx.core.djangoapps.content.block_structure.api import get_course_in_cache from xmodule.modulestore.django import modulestore +from .config.models import PersistentGradesEnabledFlag from .new.course_grade import CourseGradeFactory from .new.subsection_grade import SubsectionGradeFactory from .signals.signals import SUBSECTION_SCORE_CHANGED @@ -76,6 +77,8 @@ def recalculate_course_grade(user_id, course_id): - user_id: serialized id of applicable User object - course_id: Unicode string representing the course """ + if not PersistentGradesEnabledFlag.feature_enabled(course_id): + return student = User.objects.get(id=user_id) course_key = CourseLocator.from_string(course_id) course = modulestore().get_course(course_key, depth=0) diff --git a/lms/djangoapps/grades/tests/test_tasks.py b/lms/djangoapps/grades/tests/test_tasks.py index 959d1c1ff5..74d0a34097 100644 --- a/lms/djangoapps/grades/tests/test_tasks.py +++ b/lms/djangoapps/grades/tests/test_tasks.py @@ -179,7 +179,7 @@ class RecalculateSubsectionGradeTest(ModuleStoreTestCase): 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(16 + additional_queries): + with check_mongo_calls(2) and self.assertNumQueries(5): recalculate_subsection_grade.apply(args=tuple(self.score_changed_kwargs.values())) @skip("Pending completion of TNL-5089") From a54c4ea4f915541ab772df9ccfb4aec8764ffb2e Mon Sep 17 00:00:00 2001 From: Eric Fischer Date: Tue, 1 Nov 2016 14:31:52 -0400 Subject: [PATCH 2/2] Revert "Unrevert "Fix Gating to use grades API, instead of its own calculations"" This reverts commit 7aacae9a4f181ae81c987251e26383a2ab3d20eb. --- .../transformers/tests/test_milestones.py | 25 +- lms/djangoapps/gating/api.py | 78 ++++-- lms/djangoapps/gating/signals.py | 25 +- lms/djangoapps/gating/tests/test_api.py | 260 +++++++----------- lms/djangoapps/gating/tests/test_signals.py | 26 +- lms/djangoapps/grades/module_grades.py | 96 +++++++ lms/djangoapps/grades/new/subsection_grade.py | 11 +- lms/djangoapps/grades/tasks.py | 1 + lms/djangoapps/grades/tests/test_grades.py | 192 +++++++++++++ lms/djangoapps/grades/tests/test_tasks.py | 3 +- 10 files changed, 475 insertions(+), 242 deletions(-) create mode 100644 lms/djangoapps/grades/module_grades.py diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py index f112789999..98b98119e9 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -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): diff --git a/lms/djangoapps/gating/api.py b/lms/djangoapps/gating/api.py index d3d31155b9..0f2a8ea55b 100644 --- a/lms/djangoapps/gating/api.py +++ b/lms/djangoapps/gating/api.py @@ -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): diff --git a/lms/djangoapps/gating/signals.py b/lms/djangoapps/gating/signals.py index 554ab642ab..368092dd4a 100644 --- a/lms/djangoapps/gating/signals.py +++ b/lms/djangoapps/gating/signals.py @@ -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, - ) diff --git a/lms/djangoapps/gating/tests/test_api.py b/lms/djangoapps/gating/tests/test_api.py index 4fca946872..3b1df7ce87 100644 --- a/lms/djangoapps/gating/tests/test_api.py +++ b/lms/djangoapps/gating/tests/test_api.py @@ -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) diff --git a/lms/djangoapps/gating/tests/test_signals.py b/lms/djangoapps/gating/tests/test_signals.py index 319b6e8f2f..bb3eb41910 100644 --- a/lms/djangoapps/gating/tests/test_signals.py +++ b/lms/djangoapps/gating/tests/test_signals.py @@ -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() diff --git a/lms/djangoapps/grades/module_grades.py b/lms/djangoapps/grades/module_grades.py new file mode 100644 index 0000000000..a0e95a57e9 --- /dev/null +++ b/lms/djangoapps/grades/module_grades.py @@ -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) diff --git a/lms/djangoapps/grades/new/subsection_grade.py b/lms/djangoapps/grades/new/subsection_grade.py index 6a624e862d..33623009e0 100644 --- a/lms/djangoapps/grades/new/subsection_grade.py +++ b/lms/djangoapps/grades/new/subsection_grade.py @@ -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 diff --git a/lms/djangoapps/grades/tasks.py b/lms/djangoapps/grades/tasks.py index 66a223a97b..ce0c7e6504 100644 --- a/lms/djangoapps/grades/tasks.py +++ b/lms/djangoapps/grades/tasks.py @@ -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) diff --git a/lms/djangoapps/grades/tests/test_grades.py b/lms/djangoapps/grades/tests/test_grades.py index a2f4dad369..655bd6f7ca 100644 --- a/lms/djangoapps/grades/tests/test_grades.py +++ b/lms/djangoapps/grades/tests/test_grades.py @@ -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) diff --git a/lms/djangoapps/grades/tests/test_tasks.py b/lms/djangoapps/grades/tests/test_tasks.py index 74d0a34097..5379338846 100644 --- a/lms/djangoapps/grades/tests/test_tasks.py +++ b/lms/djangoapps/grades/tests/test_tasks.py @@ -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")