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 b11b683e59..d3c26ac31a 100644 --- a/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_milestones.py @@ -52,7 +52,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM 'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'H', 'I', 'TimedExam', 'J', 'K' ) - # The special exams (proctored, practice, timed) should never be visible to students + # The special exams (proctored, practice, timed) are not visible to + # students via the Courses API. ALL_BLOCKS_EXCEPT_SPECIAL = ('course', 'A', 'B', 'C', 'H', 'I') def get_course_hierarchy(self): @@ -133,18 +134,16 @@ 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, gating_block_child, expected_blocks_before_completion): + def test_gated(self, gated_block_ref, gating_block_ref, 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. @@ -164,17 +163,15 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM # clear the request cache to simulate a new request self.clear_caches() - # 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 + # this call triggers reevaluation of prerequisites fulfilled by the gating block. + with patch('gating.api._get_subsection_percentage', Mock(return_value=100)): lms_gating_api.evaluate_prerequisite( self.course, - self.blocks[gating_block_child], - self.user.id, + Mock(location=self.blocks[gating_block_ref].location), + self.user, ) - with self.assertNumQueries(5): + + with self.assertNumQueries(6): 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 0f2a8ea55b..14bf7677d2 100644 --- a/lms/djangoapps/gating/api.py +++ b/lms/djangoapps/gating/api.py @@ -2,105 +2,87 @@ API for the gating djangoapp """ from collections import defaultdict -from django.contrib.auth.models import User from django.test.client import RequestFactory import json import logging +from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score 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 xmodule.modulestore.django import modulestore 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, block, user_id): +def evaluate_prerequisite(course, subsection_grade, user): """ - Finds the parent subsection of the content in the course and evaluates - any milestone relationships attached to that subsection. If the calculated - grade of the prerequisite subsection meets the minimum score required by - dependent subsections, the related milestone will be fulfilled for the user. - - Arguments: - course (CourseModule): The course - prereq_content_key (UsageKey): The prerequisite content usage key - user_id (int): ID of User for which evaluation should occur - - Returns: - None + Evaluates any gating milestone relationships attached to the given + subsection. If the subsection_grade meets the minimum score required + by dependent subsections, the related milestone will be marked + fulfilled for the user. """ - 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) + prereq_milestone = gating_api.get_gating_milestone(course.id, subsection_grade.location, 'fulfills') + if prereq_milestone: + gated_content_milestones = defaultdict(list) + for milestone in gating_api.find_gating_milestones(course.id, content_key=None, relationship='requires'): + gated_content_milestones[milestone['id']].append(milestone) - 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 score >= min_score: - milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone) - else: - milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone) + gated_content = gated_content_milestones.get(prereq_milestone['id']) + if gated_content: + for milestone in gated_content: + min_percentage = _get_minimum_required_percentage(milestone) + subsection_percentage = _get_subsection_percentage(subsection_grade) + if subsection_percentage >= min_percentage: + 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): +def _get_minimum_required_percentage(milestone): """ - Update milestone fulfillments for the specified content module + Returns the minimum percentage requirement for the given milestone. """ - # Fulfillment Use Case: Entrance Exam - # If this module is part of an entrance exam, we'll need to see if the student - # has reached the point at which they can collect the associated milestone - if milestones_helpers.is_entrance_exams_enabled(): - entrance_exam_enabled = getattr(course, 'entrance_exam_enabled', False) - in_entrance_exam = getattr(block, 'in_entrance_exam', False) - if entrance_exam_enabled and in_entrance_exam: + # 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) + ) + return min_score + + +def _get_subsection_percentage(subsection_grade): + """ + Returns the percentage value of the given subsection_grade. + """ + if subsection_grade.graded_total.possible: + return float(subsection_grade.graded_total.earned) / float(subsection_grade.graded_total.possible) * 100.0 + else: + return 0 + + +def evaluate_entrance_exam(course, subsection_grade, user): + """ + Evaluates any entrance exam milestone relationships attached + to the given subsection. If the subsection_grade meets the + minimum score required, the dependent milestone will be marked + fulfilled for the user. + """ + if milestones_helpers.is_entrance_exams_enabled() and getattr(course, 'entrance_exam_enabled', False): + subsection = modulestore().get_item(subsection_grade.location) + in_entrance_exam = getattr(subsection, 'in_entrance_exam', False) + if in_entrance_exam: # We don't have access to the true request object in this context, but we can use a mock request = RequestFactory().request() - request.user = User.objects.get(id=user_id) + request.user = user exam_pct = get_entrance_exam_score(request, course) if exam_pct >= course.entrance_exam_minimum_score_pct: exam_key = UsageKey.from_string(course.entrance_exam_id) @@ -110,7 +92,6 @@ def evaluate_entrance_exam(course, block, user_id): exam_key, relationship=relationship_types['FULFILLS'] ) - # Add each milestone to the user's set... - user = {'id': request.user.id} + # Mark each milestone dependent on the entrance exam as fulfilled by the user. for milestone in content_milestones: - milestones_helpers.add_user_milestone(user, milestone) + milestones_helpers.add_user_milestone({'id': request.user.id}, milestone) diff --git a/lms/djangoapps/gating/signals.py b/lms/djangoapps/gating/signals.py index 88bad17149..64a29dedfa 100644 --- a/lms/djangoapps/gating/signals.py +++ b/lms/djangoapps/gating/signals.py @@ -4,25 +4,21 @@ 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_WEIGHTED_SCORE_CHANGED -from opaque_keys.edx.keys import CourseKey, UsageKey -from xmodule.modulestore.django import modulestore +from lms.djangoapps.grades.signals.signals import SUBSECTION_SCORE_CHANGED -@receiver(PROBLEM_WEIGHTED_SCORE_CHANGED) -def handle_score_changed(**kwargs): +@receiver(SUBSECTION_SCORE_CHANGED) +def evaluate_subsection_gated_milestones(**kwargs): """ - Receives the PROBLEM_WEIGHTED_SCORE_CHANGED signal sent by LMS when a student's score has changed - for a given component and triggers the evaluation of any milestone relationships - which are attached to the updated content. + Receives the SUBSECTION_SCORE_CHANGED signal and triggers the + evaluation of any milestone relationships which are attached + to the subsection. Arguments: - kwargs (dict): Contains user ID, course key, and content usage key - + kwargs (dict): Contains user, course, course_structure, subsection_grade Returns: None """ - 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')) + subsection_grade = kwargs['subsection_grade'] + gating_api.evaluate_prerequisite(kwargs['course'], subsection_grade, kwargs.get('user')) + gating_api.evaluate_entrance_exam(kwargs['course'], subsection_grade, kwargs.get('user')) diff --git a/lms/djangoapps/gating/tests/test_api.py b/lms/djangoapps/gating/tests/test_api.py index b15930de9e..805eb96940 100644 --- a/lms/djangoapps/gating/tests/test_api.py +++ b/lms/djangoapps/gating/tests/test_api.py @@ -1,7 +1,7 @@ """ Unit tests for gating.signals module """ -from mock import patch +from mock import patch, Mock from nose.plugins.attrib import attr from ddt import ddt, data, unpack from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -11,7 +11,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase from milestones import api as milestones_api from milestones.tests.utils import MilestonesTestCaseMixin from openedx.core.lib.gating import api as gating_api -from gating.api import _get_xblock_parent, evaluate_prerequisite +from gating.api import evaluate_prerequisite class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): @@ -48,60 +48,14 @@ class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): self.seq1 = ItemFactory.create( parent_location=self.chapter1.location, category='sequential', - display_name='untitled sequential 1' + display_name='gating sequential' ) self.seq2 = ItemFactory.create( parent_location=self.chapter1.location, category='sequential', - display_name='untitled sequential 2' + display_name='gated sequential' ) - # create vertical - self.vert1 = ItemFactory.create( - parent_location=self.seq1.location, - category='vertical', - display_name='untitled vertical 1' - ) - - # create problem - self.prob1 = ItemFactory.create( - parent_location=self.vert1.location, - category='problem', - display_name='untitled problem 1' - ) - - # create orphan - self.prob2 = ItemFactory.create( - parent_location=self.course.location, - category='problem', - display_name='untitled problem 2' - ) - - -class TestGetXBlockParent(GatingTestCase): - """ - Tests for the get_xblock_parent function - """ - - def test_get_direct_parent(self): - """ Test test_get_direct_parent """ - - result = _get_xblock_parent(self.vert1) - self.assertEqual(result.location, self.seq1.location) - - def test_get_parent_with_category(self): - """ Test test_get_parent_of_category """ - 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_get_parent_none(self): - """ Test test_get_parent_none """ - - result = _get_xblock_parent(self.vert1, 'unit') - self.assertIsNone(result) - @attr(shard=3) @ddt @@ -114,62 +68,46 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin): super(TestEvaluatePrerequisite, self).setUp() self.user_dict = {'id': self.user.id} self.prereq_milestone = None + self.subsection_grade = Mock(location=self.seq1.location) def _setup_gating_milestone(self, min_score): """ Setup a gating milestone for testing """ - 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') - @patch('gating.api.get_module_score') - @data((.5, True), (1, True), (0, False)) + @patch('gating.api._get_subsection_percentage') + @data((50, True), (100, True), (0, False)) @unpack - def test_min_score_achieved(self, module_score, result, mock_module_score): - """ Test test_min_score_achieved """ - + def test_min_score_achieved(self, module_score, result, mock_score): self._setup_gating_milestone(50) + mock_score.return_value = module_score - mock_module_score.return_value = module_score - evaluate_prerequisite(self.course, self.prob1, self.user.id) + evaluate_prerequisite(self.course, self.subsection_grade, self.user) 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)) + @patch('gating.api._get_subsection_percentage') + @data((50, False), (100, True)) @unpack - def test_invalid_min_score(self, module_score, result, mock_module_score, mock_log): - """ Test test_invalid_min_score """ - + def test_invalid_min_score(self, module_score, result, mock_score, mock_log): self._setup_gating_milestone(None) + mock_score.return_value = module_score - mock_module_score.return_value = module_score - evaluate_prerequisite(self.course, self.prob1, self.user.id) + evaluate_prerequisite(self.course, self.subsection_grade, self.user) 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 """ + @patch('gating.api._get_subsection_percentage') + def test_no_prerequisites(self, mock_score): + evaluate_prerequisite(self.course, self.subsection_grade, self.user) + self.assertFalse(mock_score.called) - 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 + @patch('gating.api._get_subsection_percentage') + def test_no_gated_content(self, mock_score): 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) + evaluate_prerequisite(self.course, self.subsection_grade, self.user) + self.assertFalse(mock_score.called) diff --git a/lms/djangoapps/gating/tests/test_integration.py b/lms/djangoapps/gating/tests/test_integration.py new file mode 100644 index 0000000000..4bfa8c5df5 --- /dev/null +++ b/lms/djangoapps/gating/tests/test_integration.py @@ -0,0 +1,192 @@ +""" +Integration tests for gated content. +""" +import ddt +from nose.plugins.attrib import attr +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from lms.djangoapps.courseware.access import has_access +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.djangolib.testing.utils import get_mock_request +from openedx.core.lib.gating import api as gating_api +from request_cache.middleware import RequestCache +from student.tests.factories import UserFactory +from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase + + +@attr(shard=3) +@ddt.ddt +class TestGatedContent(MilestonesTestCaseMixin, SharedModuleStoreTestCase): + """ + Base TestCase class for setting up a basic course structure + and testing the gating feature + """ + @classmethod + def setUpClass(cls): + super(TestGatedContent, cls).setUpClass() + cls.set_up_course() + + def setUp(self): + super(TestGatedContent, self).setUp() + self.setup_gating_milestone(50) + self.non_staff_user = UserFactory() + self.staff_user = UserFactory(is_staff=True, is_superuser=True) + self.request = get_mock_request(self.non_staff_user) + + @classmethod + def set_up_course(cls): + """ + Set up a course for testing gated content. + """ + cls.course = CourseFactory.create( + org='edX', + number='EDX101', + run='EDX101_RUN1', + display_name='edX 101' + ) + with modulestore().bulk_operations(cls.course.id): + cls.course.enable_subsection_gating = True + grading_policy = { + "GRADER": [{ + "type": "Homework", + "min_count": 3, + "drop_count": 0, + "short_label": "HW", + "weight": 1.0 + }] + } + cls.course.grading_policy = grading_policy + cls.course.save() + cls.store.update_item(cls.course, 0) + + # create chapter + cls.chapter1 = ItemFactory.create( + parent_location=cls.course.location, + category='chapter', + display_name='chapter 1' + ) + + # create sequentials + cls.seq1 = ItemFactory.create( + parent_location=cls.chapter1.location, + category='sequential', + display_name='gating sequential 1', + graded=True, + format='Homework', + ) + cls.seq2 = ItemFactory.create( + parent_location=cls.chapter1.location, + category='sequential', + display_name='gated sequential 2', + graded=True, + format='Homework', + ) + cls.seq3 = ItemFactory.create( + parent_location=cls.chapter1.location, + category='sequential', + display_name='sequential 3', + graded=True, + format='Homework', + ) + + # create problem + cls.gating_prob1 = ItemFactory.create( + parent_location=cls.seq1.location, + category='problem', + display_name='gating problem 1', + ) + cls.gated_prob2 = ItemFactory.create( + parent_location=cls.seq2.location, + category='problem', + display_name='gated problem 2', + ) + cls.prob3 = ItemFactory.create( + parent_location=cls.seq3.location, + category='problem', + display_name='problem 3', + ) + + 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, str(self.seq1.location)) + gating_api.set_required_content(self.course.id, str(self.seq2.location), str(self.seq1.location), min_score) + self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills') + + def assert_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(bool(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 assert_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, + ) + + def assert_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().create(user, self.course) + for prob in [self.gating_prob1, self.gated_prob2, self.prob3]: + self.assertIn(prob.location, course_grade.locations_to_scores) + + self.assertEquals(course_grade.percent, expected_percent) + + def test_gated_for_nonstaff(self): + self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False) + self.assert_access_to_gated_content(self.non_staff_user, expected_access=False) + + def test_not_gated_for_staff(self): + self.assert_user_has_prereq_milestone(self.staff_user, expected_has_milestone=False) + self.assert_access_to_gated_content(self.staff_user, expected_access=True) + + def test_gated_content_always_in_grades(self): + # start with a grade from a non-gated subsection + answer_problem(self.course, self.request, self.prob3, 10, 10) + + # verify gated status and overall course grade percentage + self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False) + self.assert_access_to_gated_content(self.non_staff_user, expected_access=False) + self.assert_course_grade(self.non_staff_user, .33) + + # fulfill the gated requirements + answer_problem(self.course, self.request, self.gating_prob1, 10, 10) + + # verify gated status and overall course grade percentage + self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=True) + self.assert_access_to_gated_content(self.non_staff_user, expected_access=True) + self.assert_course_grade(self.non_staff_user, .67) + + @ddt.data((1, 1, True), (1, 2, True), (1, 3, False), (0, 1, False)) + @ddt.unpack + def test_ungating_when_fulfilled(self, earned, max_possible, result): + self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=False) + self.assert_access_to_gated_content(self.non_staff_user, expected_access=False) + + answer_problem(self.course, self.request, self.gating_prob1, earned, max_possible) + + self.assert_user_has_prereq_milestone(self.non_staff_user, expected_has_milestone=result) + self.assert_access_to_gated_content(self.non_staff_user, expected_access=result) diff --git a/lms/djangoapps/gating/tests/test_signals.py b/lms/djangoapps/gating/tests/test_signals.py index bb3eb41910..f88dbf15ae 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 +from mock import patch, Mock 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_score_changed +from gating.signals import evaluate_subsection_gated_milestones class TestHandleScoreChanged(ModuleStoreTestCase): @@ -19,32 +19,26 @@ class TestHandleScoreChanged(ModuleStoreTestCase): super(TestHandleScoreChanged, self).setUp() self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1') self.user = UserFactory.create() - self.test_usage_key = self.course.location + self.subsection_grade = Mock() - @patch('gating.signals.gating_api.evaluate_prerequisite') - def test_gating_enabled(self, mock_evaluate): - """ Test evaluate_prerequisite is called when course.enable_subsection_gating is True """ + @patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone') + def test_gating_enabled(self, mock_gating_milestone): self.course.enable_subsection_gating = True modulestore().update_item(self.course, 0) - handle_score_changed( + evaluate_subsection_gated_milestones( sender=None, - points_possible=1, - points_earned=1, - user_id=self.user.id, - course_id=unicode(self.course.id), - usage_id=unicode(self.test_usage_key) + user=self.user, + course=self.course, + subsection_grade=self.subsection_grade, ) - mock_evaluate.assert_called_with(self.course, self.course, self.user.id) # pylint: disable=no-member + self.assertTrue(mock_gating_milestone.called) - @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_score_changed( + @patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone') + def test_gating_disabled(self, mock_gating_milestone): + evaluate_subsection_gated_milestones( sender=None, - points_possible=1, - points_earned=1, - user_id=self.user.id, - course_id=unicode(self.course.id), - usage_id=unicode(self.test_usage_key) + user=self.user, + course=self.course, + subsection_grade=self.subsection_grade, ) - mock_evaluate.assert_not_called() + self.assertFalse(mock_gating_milestone.called) diff --git a/lms/djangoapps/grades/module_grades.py b/lms/djangoapps/grades/module_grades.py deleted file mode 100644 index a0e95a57e9..0000000000 --- a/lms/djangoapps/grades/module_grades.py +++ /dev/null @@ -1,96 +0,0 @@ -""" -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/tests/test_grades.py b/lms/djangoapps/grades/tests/test_grades.py index 1b6975e825..8caecfc573 100644 --- a/lms/djangoapps/grades/tests/test_grades.py +++ b/lms/djangoapps/grades/tests/test_grades.py @@ -8,9 +8,6 @@ from mock import patch from nose.plugins.attrib import attr from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory -from courseware.model_data import set_score -from courseware.tests.helpers import LoginEnrollmentTestCase - from lms.djangoapps.course_blocks.api import get_course_blocks from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory from openedx.core.djangolib.testing.utils import get_mock_request @@ -22,7 +19,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from .utils import answer_problem -from ..module_grades import get_module_score from ..new.course_grade import CourseGradeFactory from ..new.subsection_grade import SubsectionGradeFactory @@ -334,195 +330,3 @@ 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() - with cls.store.bulk_operations(cls.course.id): - 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_mock_request(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/mobile_api/tests/test_milestones.py b/lms/djangoapps/mobile_api/tests/test_milestones.py index dba2faa5c9..bd200bb97d 100644 --- a/lms/djangoapps/mobile_api/tests/test_milestones.py +++ b/lms/djangoapps/mobile_api/tests/test_milestones.py @@ -11,7 +11,6 @@ from util.milestones_helpers import ( add_prerequisite_course, fulfill_course_milestone, ) -from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory @@ -85,28 +84,36 @@ class MobileAPIMilestonesMixin(object): def _add_entrance_exam(self): """ Sets up entrance exam """ - self.course.entrance_exam_enabled = True + with self.store.bulk_operations(self.course.id): + self.course.entrance_exam_enabled = True - self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init - parent=self.course, - category="chapter", - display_name="Entrance Exam Chapter", - is_entrance_exam=True, - in_entrance_exam=True - ) - self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init - parent=self.entrance_exam, - category='problem', - display_name="The Only Exam Problem", - graded=True, - in_entrance_exam=True - ) + self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=self.course, + category="chapter", + display_name="Entrance Exam Chapter", + is_entrance_exam=True, + in_entrance_exam=True, + ) + self.subsection_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=self.entrance_exam, + category='sequential', + display_name="The Only Exam Sequential", + graded=True, + in_entrance_exam=True, + ) + self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init + parent=self.subsection_1, + category='problem', + display_name="The Only Exam Problem", + graded=True, + in_entrance_exam=True, + ) - add_entrance_exam_milestone(self.course, self.entrance_exam) + add_entrance_exam_milestone(self.course, self.entrance_exam) - self.course.entrance_exam_minimum_score_pct = 0.50 - self.course.entrance_exam_id = unicode(self.entrance_exam.location) - modulestore().update_item(self.course, self.user.id) + self.course.entrance_exam_minimum_score_pct = 0.50 + self.course.entrance_exam_id = unicode(self.entrance_exam.location) + self.store.update_item(self.course, self.user.id) def _add_prerequisite_course(self): """ Helper method to set up the prerequisite course """