Commit cfb032a5 by Nimisha Asthagiri

Fix grading for Gated Subsections

TNL-5955
parent 173e5922
...@@ -52,7 +52,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM ...@@ -52,7 +52,8 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
'course', 'A', 'B', 'C', 'ProctoredExam', 'D', 'E', 'PracticeExam', 'F', 'G', 'H', 'I', 'TimedExam', 'J', 'K' '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') ALL_BLOCKS_EXCEPT_SPECIAL = ('course', 'A', 'B', 'C', 'H', 'I')
def get_course_hierarchy(self): def get_course_hierarchy(self):
...@@ -133,18 +134,16 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM ...@@ -133,18 +134,16 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
( (
'H', 'H',
'A', 'A',
'B',
('course', 'A', 'B', 'C',) ('course', 'A', 'B', 'C',)
), ),
( (
'H', 'H',
'ProctoredExam', 'ProctoredExam',
'D',
('course', 'A', 'B', 'C'), ('course', 'A', 'B', 'C'),
), ),
) )
@ddt.unpack @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 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. attempt has been made to complete the gating block.
...@@ -164,17 +163,15 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM ...@@ -164,17 +163,15 @@ class MilestonesTransformerTestCase(CourseStructureTestCase, MilestonesTestCaseM
# clear the request cache to simulate a new request # clear the request cache to simulate a new request
self.clear_caches() 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%) # this call triggers reevaluation of prerequisites fulfilled by the gating block.
with patch('gating.api.get_module_score', Mock(return_value=1)): with patch('gating.api._get_subsection_percentage', Mock(return_value=100)):
# 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( lms_gating_api.evaluate_prerequisite(
self.course, self.course,
self.blocks[gating_block_child], Mock(location=self.blocks[gating_block_ref].location),
self.user.id, self.user,
) )
with self.assertNumQueries(5):
with self.assertNumQueries(6):
self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL) self.get_blocks_and_check_against_expected(self.user, self.ALL_BLOCKS_EXCEPT_SPECIAL)
def test_staff_access(self): def test_staff_access(self):
......
...@@ -2,105 +2,87 @@ ...@@ -2,105 +2,87 @@
API for the gating djangoapp API for the gating djangoapp
""" """
from collections import defaultdict from collections import defaultdict
from django.contrib.auth.models import User
from django.test.client import RequestFactory from django.test.client import RequestFactory
import json import json
import logging import logging
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score
from openedx.core.lib.gating import api as gating_api from openedx.core.lib.gating import api as gating_api
from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.keys import UsageKey
from lms.djangoapps.courseware.entrance_exams import get_entrance_exam_score from xmodule.modulestore.django import modulestore
from lms.djangoapps.grades.module_grades import get_module_score
from util import milestones_helpers from util import milestones_helpers
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
def _get_xblock_parent(xblock, category=None): @gating_api.gating_enabled(default=False)
def evaluate_prerequisite(course, subsection_grade, user):
""" """
Returns the parent of the given XBlock. If an optional category is supplied, Evaluates any gating milestone relationships attached to the given
traverses the ancestors of the XBlock and returns the first with the subsection. If the subsection_grade meets the minimum score required
given category. by dependent subsections, the related milestone will be marked
fulfilled for the user.
Arguments:
xblock (XBlock): Get the parent of this XBlock
category (str): Find an ancestor with this category (e.g. sequential)
""" """
parent = xblock.get_parent() prereq_milestone = gating_api.get_gating_milestone(course.id, subsection_grade.location, 'fulfills')
if parent and category: if prereq_milestone:
if parent.category == category: gated_content_milestones = defaultdict(list)
return parent for milestone in gating_api.find_gating_milestones(course.id, content_key=None, relationship='requires'):
else: gated_content_milestones[milestone['id']].append(milestone)
return _get_xblock_parent(parent, category)
return parent
@gating_api.gating_enabled(default=False) gated_content = gated_content_milestones.get(prereq_milestone['id'])
def evaluate_prerequisite(course, block, user_id): if gated_content:
""" for milestone in gated_content:
Finds the parent subsection of the content in the course and evaluates min_percentage = _get_minimum_required_percentage(milestone)
any milestone relationships attached to that subsection. If the calculated subsection_percentage = _get_subsection_percentage(subsection_grade)
grade of the prerequisite subsection meets the minimum score required by if subsection_percentage >= min_percentage:
dependent subsections, the related milestone will be fulfilled for the user. milestones_helpers.add_user_milestone({'id': user.id}, prereq_milestone)
else:
milestones_helpers.remove_user_milestone({'id': user.id}, prereq_milestone)
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: def _get_minimum_required_percentage(milestone):
None """
Returns the minimum percentage requirement for the given milestone.
""" """
sequential = _get_xblock_parent(block, 'sequential') # Default minimum score to 100
if sequential: min_score = 100
prereq_milestone = gating_api.get_gating_milestone( requirements = milestone.get('requirements')
course.id, if requirements:
sequential.location.for_branch(None), try:
'fulfills' min_score = int(requirements.get('min_score'))
) except (ValueError, TypeError):
if prereq_milestone: log.warning(
gated_content_milestones = defaultdict(list) 'Failed to find minimum score for gating milestone %s, defaulting to 100',
for milestone in gating_api.find_gating_milestones(course.id, None, 'requires'): json.dumps(milestone)
gated_content_milestones[milestone['id']].append(milestone) )
return min_score
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: def _get_subsection_percentage(subsection_grade):
milestones_helpers.add_user_milestone({'id': user_id}, prereq_milestone) """
else: Returns the percentage value of the given subsection_grade.
milestones_helpers.remove_user_milestone({'id': user_id}, prereq_milestone) """
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, block, user_id): def evaluate_entrance_exam(course, subsection_grade, user):
""" """
Update milestone fulfillments for the specified content module 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.
""" """
# Fulfillment Use Case: Entrance Exam if milestones_helpers.is_entrance_exams_enabled() and getattr(course, 'entrance_exam_enabled', False):
# If this module is part of an entrance exam, we'll need to see if the student subsection = modulestore().get_item(subsection_grade.location)
# has reached the point at which they can collect the associated milestone in_entrance_exam = getattr(subsection, 'in_entrance_exam', False)
if milestones_helpers.is_entrance_exams_enabled(): if in_entrance_exam:
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:
# We don't have access to the true request object in this context, but we can use a mock # We don't have access to the true request object in this context, but we can use a mock
request = RequestFactory().request() request = RequestFactory().request()
request.user = User.objects.get(id=user_id) request.user = user
exam_pct = get_entrance_exam_score(request, course) exam_pct = get_entrance_exam_score(request, course)
if exam_pct >= course.entrance_exam_minimum_score_pct: if exam_pct >= course.entrance_exam_minimum_score_pct:
exam_key = UsageKey.from_string(course.entrance_exam_id) exam_key = UsageKey.from_string(course.entrance_exam_id)
...@@ -110,7 +92,6 @@ def evaluate_entrance_exam(course, block, user_id): ...@@ -110,7 +92,6 @@ def evaluate_entrance_exam(course, block, user_id):
exam_key, exam_key,
relationship=relationship_types['FULFILLS'] relationship=relationship_types['FULFILLS']
) )
# Add each milestone to the user's set... # Mark each milestone dependent on the entrance exam as fulfilled by the user.
user = {'id': request.user.id}
for milestone in content_milestones: for milestone in content_milestones:
milestones_helpers.add_user_milestone(user, milestone) milestones_helpers.add_user_milestone({'id': request.user.id}, milestone)
...@@ -4,25 +4,21 @@ Signal handlers for the gating djangoapp ...@@ -4,25 +4,21 @@ Signal handlers for the gating djangoapp
from django.dispatch import receiver from django.dispatch import receiver
from gating import api as gating_api from gating import api as gating_api
from lms.djangoapps.grades.signals.signals import PROBLEM_WEIGHTED_SCORE_CHANGED from lms.djangoapps.grades.signals.signals import SUBSECTION_SCORE_CHANGED
from opaque_keys.edx.keys import CourseKey, UsageKey
from xmodule.modulestore.django import modulestore
@receiver(PROBLEM_WEIGHTED_SCORE_CHANGED) @receiver(SUBSECTION_SCORE_CHANGED)
def handle_score_changed(**kwargs): def evaluate_subsection_gated_milestones(**kwargs):
""" """
Receives the PROBLEM_WEIGHTED_SCORE_CHANGED signal sent by LMS when a student's score has changed Receives the SUBSECTION_SCORE_CHANGED signal and triggers the
for a given component and triggers the evaluation of any milestone relationships evaluation of any milestone relationships which are attached
which are attached to the updated content. to the subsection.
Arguments: Arguments:
kwargs (dict): Contains user ID, course key, and content usage key kwargs (dict): Contains user, course, course_structure, subsection_grade
Returns: Returns:
None None
""" """
course = modulestore().get_course(CourseKey.from_string(kwargs.get('course_id'))) subsection_grade = kwargs['subsection_grade']
block = modulestore().get_item(UsageKey.from_string(kwargs.get('usage_id'))) gating_api.evaluate_prerequisite(kwargs['course'], subsection_grade, kwargs.get('user'))
gating_api.evaluate_prerequisite(course, block, kwargs.get('user_id')) gating_api.evaluate_entrance_exam(kwargs['course'], subsection_grade, kwargs.get('user'))
gating_api.evaluate_entrance_exam(course, block, kwargs.get('user_id'))
""" """
Unit tests for gating.signals module Unit tests for gating.signals module
""" """
from mock import patch from mock import patch, Mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from ddt import ddt, data, unpack from ddt import ddt, data, unpack
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -11,7 +11,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase ...@@ -11,7 +11,7 @@ from courseware.tests.helpers import LoginEnrollmentTestCase
from milestones import api as milestones_api from milestones import api as milestones_api
from milestones.tests.utils import MilestonesTestCaseMixin from milestones.tests.utils import MilestonesTestCaseMixin
from openedx.core.lib.gating import api as gating_api 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): class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
...@@ -48,60 +48,14 @@ class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): ...@@ -48,60 +48,14 @@ class GatingTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase):
self.seq1 = ItemFactory.create( self.seq1 = ItemFactory.create(
parent_location=self.chapter1.location, parent_location=self.chapter1.location,
category='sequential', category='sequential',
display_name='untitled sequential 1' display_name='gating sequential'
) )
self.seq2 = ItemFactory.create( self.seq2 = ItemFactory.create(
parent_location=self.chapter1.location, parent_location=self.chapter1.location,
category='sequential', 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) @attr(shard=3)
@ddt @ddt
...@@ -114,62 +68,46 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin): ...@@ -114,62 +68,46 @@ class TestEvaluatePrerequisite(GatingTestCase, MilestonesTestCaseMixin):
super(TestEvaluatePrerequisite, self).setUp() super(TestEvaluatePrerequisite, self).setUp()
self.user_dict = {'id': self.user.id} self.user_dict = {'id': self.user.id}
self.prereq_milestone = None self.prereq_milestone = None
self.subsection_grade = Mock(location=self.seq1.location)
def _setup_gating_milestone(self, min_score): def _setup_gating_milestone(self, min_score):
""" """
Setup a gating milestone for testing Setup a gating milestone for testing
""" """
gating_api.add_prerequisite(self.course.id, self.seq1.location) 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) 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') self.prereq_milestone = gating_api.get_gating_milestone(self.course.id, self.seq1.location, 'fulfills')
@patch('gating.api.get_module_score') @patch('gating.api._get_subsection_percentage')
@data((.5, True), (1, True), (0, False)) @data((50, True), (100, True), (0, False))
@unpack @unpack
def test_min_score_achieved(self, module_score, result, mock_module_score): def test_min_score_achieved(self, module_score, result, mock_score):
""" Test test_min_score_achieved """
self._setup_gating_milestone(50) self._setup_gating_milestone(50)
mock_score.return_value = module_score
mock_module_score.return_value = module_score evaluate_prerequisite(self.course, self.subsection_grade, self.user)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result) self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
@patch('gating.api.log.warning') @patch('gating.api.log.warning')
@patch('gating.api.get_module_score') @patch('gating.api._get_subsection_percentage')
@data((.5, False), (1, True)) @data((50, False), (100, True))
@unpack @unpack
def test_invalid_min_score(self, module_score, result, mock_module_score, mock_log): def test_invalid_min_score(self, module_score, result, mock_score, mock_log):
""" Test test_invalid_min_score """
self._setup_gating_milestone(None) self._setup_gating_milestone(None)
mock_score.return_value = module_score
mock_module_score.return_value = module_score evaluate_prerequisite(self.course, self.subsection_grade, self.user)
evaluate_prerequisite(self.course, self.prob1, self.user.id)
self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result) self.assertEqual(milestones_api.user_has_milestone(self.user_dict, self.prereq_milestone), result)
self.assertTrue(mock_log.called) self.assertTrue(mock_log.called)
@patch('gating.api.get_module_score') @patch('gating.api._get_subsection_percentage')
def test_orphaned_xblock(self, mock_module_score): def test_no_prerequisites(self, mock_score):
""" Test test_orphaned_xblock """ 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) gating_api.add_prerequisite(self.course.id, self.seq1.location)
evaluate_prerequisite(self.course, self.prob1, self.user.id) evaluate_prerequisite(self.course, self.subsection_grade, self.user)
self.assertFalse(mock_module_score.called) self.assertFalse(mock_score.called)
"""
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)
""" """
Unit tests for gating.signals module Unit tests for gating.signals module
""" """
from mock import patch from mock import patch, Mock
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from gating.signals import handle_score_changed from gating.signals import evaluate_subsection_gated_milestones
class TestHandleScoreChanged(ModuleStoreTestCase): class TestHandleScoreChanged(ModuleStoreTestCase):
...@@ -19,32 +19,26 @@ class TestHandleScoreChanged(ModuleStoreTestCase): ...@@ -19,32 +19,26 @@ class TestHandleScoreChanged(ModuleStoreTestCase):
super(TestHandleScoreChanged, self).setUp() super(TestHandleScoreChanged, self).setUp()
self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1') self.course = CourseFactory.create(org='TestX', number='TS01', run='2016_Q1')
self.user = UserFactory.create() self.user = UserFactory.create()
self.test_usage_key = self.course.location self.subsection_grade = Mock()
@patch('gating.signals.gating_api.evaluate_prerequisite') @patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone')
def test_gating_enabled(self, mock_evaluate): def test_gating_enabled(self, mock_gating_milestone):
""" Test evaluate_prerequisite is called when course.enable_subsection_gating is True """
self.course.enable_subsection_gating = True self.course.enable_subsection_gating = True
modulestore().update_item(self.course, 0) modulestore().update_item(self.course, 0)
handle_score_changed( evaluate_subsection_gated_milestones(
sender=None, sender=None,
points_possible=1, user=self.user,
points_earned=1, course=self.course,
user_id=self.user.id, subsection_grade=self.subsection_grade,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
) )
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') @patch('lms.djangoapps.gating.api.gating_api.get_gating_milestone')
def test_gating_disabled(self, mock_evaluate): def test_gating_disabled(self, mock_gating_milestone):
""" Test evaluate_prerequisite is not called when course.enable_subsection_gating is False """ evaluate_subsection_gated_milestones(
handle_score_changed(
sender=None, sender=None,
points_possible=1, user=self.user,
points_earned=1, course=self.course,
user_id=self.user.id, subsection_grade=self.subsection_grade,
course_id=unicode(self.course.id),
usage_id=unicode(self.test_usage_key)
) )
mock_evaluate.assert_not_called() self.assertFalse(mock_gating_milestone.called)
"""
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)
...@@ -8,9 +8,6 @@ from mock import patch ...@@ -8,9 +8,6 @@ from mock import patch
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from capa.tests.response_xml_factory import MultipleChoiceResponseXMLFactory 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 lms.djangoapps.course_blocks.api import get_course_blocks
from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory from openedx.core.djangoapps.content.block_structure.factory import BlockStructureFactory
from openedx.core.djangolib.testing.utils import get_mock_request from openedx.core.djangolib.testing.utils import get_mock_request
...@@ -22,7 +19,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory ...@@ -22,7 +19,6 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from .utils import answer_problem from .utils import answer_problem
from ..module_grades import get_module_score
from ..new.course_grade import CourseGradeFactory from ..new.course_grade import CourseGradeFactory
from ..new.subsection_grade import SubsectionGradeFactory from ..new.subsection_grade import SubsectionGradeFactory
...@@ -334,195 +330,3 @@ class TestScoreForModule(SharedModuleStoreTestCase): ...@@ -334,195 +330,3 @@ class TestScoreForModule(SharedModuleStoreTestCase):
earned, possible = self.course_grade.score_for_module(self.m.location) earned, possible = self.course_grade.score_for_module(self.m.location)
self.assertEqual(earned, 0) self.assertEqual(earned, 0)
self.assertEqual(possible, 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)
...@@ -11,7 +11,6 @@ from util.milestones_helpers import ( ...@@ -11,7 +11,6 @@ from util.milestones_helpers import (
add_prerequisite_course, add_prerequisite_course,
fulfill_course_milestone, fulfill_course_milestone,
) )
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
...@@ -85,28 +84,36 @@ class MobileAPIMilestonesMixin(object): ...@@ -85,28 +84,36 @@ class MobileAPIMilestonesMixin(object):
def _add_entrance_exam(self): def _add_entrance_exam(self):
""" Sets up entrance exam """ """ 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, self.entrance_exam = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
category="chapter", parent=self.course,
display_name="Entrance Exam Chapter", category="chapter",
is_entrance_exam=True, display_name="Entrance Exam Chapter",
in_entrance_exam=True is_entrance_exam=True,
) in_entrance_exam=True,
self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init )
parent=self.entrance_exam, self.subsection_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
category='problem', parent=self.entrance_exam,
display_name="The Only Exam Problem", category='sequential',
graded=True, display_name="The Only Exam Sequential",
in_entrance_exam=True graded=True,
) in_entrance_exam=True,
)
add_entrance_exam_milestone(self.course, self.entrance_exam) self.problem_1 = ItemFactory.create( # pylint: disable=attribute-defined-outside-init
parent=self.subsection_1,
self.course.entrance_exam_minimum_score_pct = 0.50 category='problem',
self.course.entrance_exam_id = unicode(self.entrance_exam.location) display_name="The Only Exam Problem",
modulestore().update_item(self.course, self.user.id) graded=True,
in_entrance_exam=True,
)
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)
self.store.update_item(self.course, self.user.id)
def _add_prerequisite_course(self): def _add_prerequisite_course(self):
""" Helper method to set up the prerequisite course """ """ Helper method to set up the prerequisite course """
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment