Fix grading for Gated Subsections
TNL-5955
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'))
|
||||
|
||||
@@ -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)
|
||||
|
||||
192
lms/djangoapps/gating/tests/test_integration.py
Normal file
192
lms/djangoapps/gating/tests/test_integration.py
Normal file
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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 """
|
||||
|
||||
Reference in New Issue
Block a user