476 lines
19 KiB
Python
476 lines
19 KiB
Python
"""
|
|
Test the behavior of the GradesTransformer
|
|
"""
|
|
|
|
|
|
import datetime
|
|
import random
|
|
from copy import deepcopy
|
|
|
|
import ddt
|
|
import pytz
|
|
|
|
from common.djangoapps.student.tests.factories import UserFactory
|
|
from lms.djangoapps.course_blocks.api import get_course_blocks
|
|
from lms.djangoapps.course_blocks.transformers.tests.helpers import CourseStructureTestCase
|
|
from openedx.core.djangoapps.content.block_structure.api import clear_course_from_cache
|
|
from xmodule.modulestore import ModuleStoreEnum
|
|
from xmodule.modulestore.django import modulestore
|
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import check_mongo_calls
|
|
|
|
from ..transformer import GradesTransformer
|
|
|
|
|
|
@ddt.ddt
|
|
class GradesTransformerTestCase(CourseStructureTestCase):
|
|
"""
|
|
Verify behavior of the GradesTransformer
|
|
"""
|
|
|
|
TRANSFORMER_CLASS_TO_TEST = GradesTransformer
|
|
|
|
ENABLED_SIGNALS = ['course_published']
|
|
|
|
problem_metadata = {
|
|
'graded': True,
|
|
'weight': 1,
|
|
'due': datetime.datetime(2099, 3, 15, 12, 30, 0, tzinfo=pytz.utc),
|
|
}
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
password = 'test'
|
|
self.student = UserFactory.create(is_staff=False, username='test_student', password=password)
|
|
self.client.login(username=self.student.username, password=password)
|
|
|
|
def _update_course_grading_policy(self, course, grading_policy):
|
|
"""
|
|
Helper to update a course's grading policy in the modulestore.
|
|
"""
|
|
course.set_grading_policy(grading_policy)
|
|
modulestore().update_item(course, self.user.id)
|
|
|
|
def _validate_grading_policy_hash(self, course_location, grading_policy_hash):
|
|
"""
|
|
Helper to retrieve the course at the given course_location and
|
|
assert that its hashed grading policy (from the grades transformer)
|
|
is as expected.
|
|
"""
|
|
block_structure = get_course_blocks(self.student, course_location, self.transformers)
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
course_location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
grading_policy_hash=grading_policy_hash,
|
|
)
|
|
|
|
def assert_collected_xblock_fields(self, block_structure, usage_key, **expectations):
|
|
"""
|
|
Given a block structure, a block usage key, and a list of keyword
|
|
arguments representing XBlock fields, verify that the block structure
|
|
has the specified values for each XBlock field.
|
|
"""
|
|
assert len(expectations) > 0
|
|
for field in expectations:
|
|
# Append our custom message to the default assertEqual error message
|
|
self.longMessage = True # pylint: disable=invalid-name
|
|
assert expectations[field] == block_structure.get_xblock_field(usage_key, field),\
|
|
'in field {},'.format(repr(field))
|
|
assert block_structure.get_xblock_field(usage_key, 'subtree_edited_on') is not None
|
|
|
|
def assert_collected_transformer_block_fields(self, block_structure, usage_key, transformer_class, **expectations):
|
|
"""
|
|
Given a block structure, a block usage key, a transformer, and a list
|
|
of keyword arguments representing transformer block fields, verify that
|
|
the block structure has the specified values for each transformer block
|
|
field.
|
|
"""
|
|
assert len(expectations) > 0
|
|
# Append our custom message to the default assertEqual error message
|
|
self.longMessage = True
|
|
for field in expectations:
|
|
assert expectations[field] == block_structure.get_transformer_block_field(
|
|
usage_key, transformer_class, field
|
|
), 'in {} and field {}'.format(transformer_class, repr(field))
|
|
|
|
def build_course_with_problems(self, data='<problem></problem>', metadata=None):
|
|
"""
|
|
Create a test course with the requested problem `data` and `metadata` values.
|
|
|
|
Appropriate defaults are provided when either argument is omitted.
|
|
"""
|
|
metadata = metadata or self.problem_metadata
|
|
|
|
# Special structure-related keys start with '#'. The rest get passed as
|
|
# kwargs to Factory.create. See docstring at
|
|
# `CourseStructureTestCase.build_course` for details.
|
|
return self.build_course([
|
|
{
|
|
'org': 'GradesTestOrg',
|
|
'course': 'GB101',
|
|
'run': 'cannonball',
|
|
'metadata': {'format': 'homework'},
|
|
'#type': 'course',
|
|
'#ref': 'course',
|
|
'#children': [
|
|
{
|
|
'metadata': metadata,
|
|
'#type': 'problem',
|
|
'#ref': 'problem',
|
|
'data': data,
|
|
}
|
|
]
|
|
}
|
|
])
|
|
|
|
def build_complicated_hypothetical_course(self):
|
|
"""
|
|
Create a test course with a very odd structure as a stress-test for various methods.
|
|
|
|
Current design is to test containing_subsection logic in collect_unioned_set_field.
|
|
I can't reasonably draw this in ascii art (due to intentional complexities), so here's an overview:
|
|
We have 1 course, containing 1 chapter, containing 2 subsections.
|
|
|
|
From here, it starts to get hairy. Call our subsections A and B.
|
|
Subsection A contains 3 verticals (call them 1, 2, and 3), and another subsection (C)
|
|
Subsection B contains vertical 3 and subsection C
|
|
Subsection C contains 1 problem (b)
|
|
Vertical 1 contains 1 vertical (11)
|
|
Vertical 2 contains no children
|
|
Vertical 3 contains no children
|
|
Vertical 11 contains 1 problem (aa) and vertical 2
|
|
Problem b contains no children
|
|
"""
|
|
return self.build_course([
|
|
{
|
|
'org': 'GradesTestOrg',
|
|
'course': 'GB101',
|
|
'run': 'cannonball',
|
|
'metadata': {'format': 'homework'},
|
|
'#type': 'course',
|
|
'#ref': 'course',
|
|
'#children': [
|
|
{
|
|
'#type': 'chapter',
|
|
'#ref': 'chapter',
|
|
'#children': [
|
|
{
|
|
'#type': 'sequential',
|
|
'#ref': 'sub_A',
|
|
'#children': [
|
|
{
|
|
'#type': 'vertical',
|
|
'#ref': 'vert_1',
|
|
'#children': [
|
|
{
|
|
'#type': 'vertical',
|
|
'#ref': 'vert_A11',
|
|
'#children': [{'#type': 'problem', '#ref': 'prob_A1aa'}]
|
|
},
|
|
]
|
|
},
|
|
{'#type': 'vertical', '#ref': 'vert_2', '#parents': ['vert_A11']},
|
|
]
|
|
},
|
|
{
|
|
'#type': 'sequential',
|
|
'#ref': 'sub_B',
|
|
'#children': [
|
|
{'#type': 'vertical', '#ref': 'vert_3', '#parents': ['sub_A']},
|
|
{
|
|
'#type': 'sequential',
|
|
'#ref': 'sub_C',
|
|
'#parents': ['sub_A'],
|
|
'#children': [{'#type': 'problem', '#ref': 'prob_BCb'}]
|
|
},
|
|
]
|
|
},
|
|
]
|
|
}
|
|
]
|
|
}
|
|
])
|
|
|
|
def test_collect_containing_subsection(self):
|
|
expected_subsections = {
|
|
'course': set(),
|
|
'chapter': set(),
|
|
'sub_A': {'sub_A'},
|
|
'sub_B': {'sub_B'},
|
|
'sub_C': {'sub_A', 'sub_B', 'sub_C'},
|
|
'vert_1': {'sub_A'},
|
|
'vert_2': {'sub_A'},
|
|
'vert_3': {'sub_A', 'sub_B'},
|
|
'vert_A11': {'sub_A'},
|
|
'prob_A1aa': {'sub_A'},
|
|
'prob_BCb': {'sub_A', 'sub_B', 'sub_C'},
|
|
}
|
|
blocks = self.build_complicated_hypothetical_course()
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
for block_ref, expected_subsections in expected_subsections.items():
|
|
actual_subsections = block_structure.get_transformer_block_field(
|
|
blocks[block_ref].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
'subsections',
|
|
)
|
|
assert actual_subsections == {blocks[sub].location for sub in expected_subsections}
|
|
|
|
def test_unscored_block_collection(self):
|
|
blocks = self.build_course_with_problems()
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
self.assert_collected_xblock_fields(
|
|
block_structure,
|
|
blocks['course'].location,
|
|
weight=None,
|
|
graded=False,
|
|
has_score=False,
|
|
due=None,
|
|
format='homework',
|
|
)
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['course'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
max_score=None,
|
|
explicit_graded=None,
|
|
)
|
|
|
|
def test_grades_collected_basic(self):
|
|
|
|
blocks = self.build_course_with_problems()
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
|
|
self.assert_collected_xblock_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
weight=self.problem_metadata['weight'],
|
|
graded=self.problem_metadata['graded'],
|
|
has_score=True,
|
|
due=self.problem_metadata['due'],
|
|
format=None,
|
|
)
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
max_score=0,
|
|
explicit_graded=True,
|
|
)
|
|
|
|
@ddt.data(True, False, None)
|
|
def test_graded_at_problem(self, graded):
|
|
problem_metadata = {
|
|
'has_score': True,
|
|
}
|
|
if graded is not None:
|
|
problem_metadata['graded'] = graded
|
|
blocks = self.build_course_with_problems(metadata=problem_metadata)
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
explicit_graded=graded,
|
|
)
|
|
|
|
def test_collecting_staff_only_problem(self):
|
|
# Demonstrate that the problem data can by collected by the SystemUser
|
|
# even if the block has access restrictions placed on it.
|
|
problem_metadata = {
|
|
'graded': True,
|
|
'weight': 1,
|
|
'due': datetime.datetime(2016, 10, 16, 0, 4, 0, tzinfo=pytz.utc),
|
|
'visible_to_staff_only': True,
|
|
}
|
|
|
|
blocks = self.build_course_with_problems(metadata=problem_metadata)
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
|
|
self.assert_collected_xblock_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
weight=problem_metadata['weight'],
|
|
graded=problem_metadata['graded'],
|
|
has_score=True,
|
|
due=problem_metadata['due'],
|
|
format=None,
|
|
)
|
|
|
|
def test_max_score_collection(self):
|
|
problem_data = '''
|
|
<problem>
|
|
<numericalresponse answer="2">
|
|
<textline label="1+1" trailing_text="%" />
|
|
</numericalresponse>
|
|
</problem>
|
|
'''
|
|
|
|
blocks = self.build_course_with_problems(data=problem_data)
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
max_score=1,
|
|
)
|
|
|
|
def test_max_score_for_multiresponse_problem(self):
|
|
problem_data = '''
|
|
<problem>
|
|
<numericalresponse answer="27">
|
|
<textline label="3^3" />
|
|
</numericalresponse>
|
|
<numericalresponse answer="13.5">
|
|
<textline label="and then half of that?" />
|
|
</numericalresponse>
|
|
</problem>
|
|
'''
|
|
|
|
blocks = self.build_course_with_problems(problem_data)
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
max_score=2,
|
|
)
|
|
|
|
def test_max_score_for_invalid_dropdown_problem(self):
|
|
"""
|
|
Verify that for an invalid dropdown problem, the max score is set to zero.
|
|
"""
|
|
problem_data = '''
|
|
<problem>
|
|
<optionresponse>
|
|
<p>You can use this template as a guide to the simple editor markdown and OLX markup to use for dropdown
|
|
problems. Edit this component to replace this template with your own assessment.</p>
|
|
<label>Add the question text, or prompt, here. This text is required.</label>
|
|
<description>You can add an optional tip or note related to the prompt like this. </description>
|
|
<optioninput>
|
|
<option correct="False">an incorrect answer</option>
|
|
<option correct="True">the correct answer</option>
|
|
<option correct="True">an incorrect answer</option>
|
|
</optioninput>
|
|
</optionresponse>
|
|
</problem>
|
|
'''
|
|
|
|
blocks = self.build_course_with_problems(problem_data)
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
|
|
self.assert_collected_transformer_block_fields(
|
|
block_structure,
|
|
blocks['problem'].location,
|
|
self.TRANSFORMER_CLASS_TO_TEST,
|
|
max_score=0,
|
|
)
|
|
|
|
def test_course_version_not_collected_in_old_mongo(self):
|
|
blocks = self.build_course_with_problems()
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
assert block_structure.get_xblock_field(blocks['course'].location, 'course_version') is None
|
|
|
|
def test_course_version_collected_in_split(self):
|
|
with self.store.default_store(ModuleStoreEnum.Type.split):
|
|
blocks = self.build_course_with_problems()
|
|
block_structure = get_course_blocks(self.student, blocks['course'].location, self.transformers)
|
|
assert block_structure.get_xblock_field(blocks['course'].location, 'course_version') is not None
|
|
assert block_structure.get_xblock_field(
|
|
blocks['problem'].location, 'course_version'
|
|
) == block_structure.get_xblock_field(blocks['course'].location, 'course_version')
|
|
|
|
def test_grading_policy_collected(self):
|
|
# the calculated hash of the original and updated grading policies of the test course
|
|
original_grading_policy_hash = 'ChVp0lHGQGCevD0t4njna/C44zQ='
|
|
updated_grading_policy_hash = 'TsbX04qWOy1WRnC0NHy+94upPd4='
|
|
|
|
blocks = self.build_course_with_problems()
|
|
course_block = blocks['course']
|
|
self._validate_grading_policy_hash(
|
|
course_block.location,
|
|
original_grading_policy_hash
|
|
)
|
|
|
|
# make sure the hash changes when the course grading policy is edited
|
|
grading_policy_with_updates = course_block.grading_policy
|
|
original_grading_policy = deepcopy(grading_policy_with_updates)
|
|
for section in grading_policy_with_updates['GRADER']:
|
|
assert section['weight'] != 0.25
|
|
section['weight'] = 0.25
|
|
|
|
self._update_course_grading_policy(course_block, grading_policy_with_updates)
|
|
self._validate_grading_policy_hash(
|
|
course_block.location,
|
|
updated_grading_policy_hash
|
|
)
|
|
|
|
# reset the grading policy and ensure the hash matches the original
|
|
self._update_course_grading_policy(course_block, original_grading_policy)
|
|
self._validate_grading_policy_hash(
|
|
course_block.location,
|
|
original_grading_policy_hash
|
|
)
|
|
|
|
|
|
@ddt.ddt
|
|
class MultiProblemModulestoreAccessTestCase(CourseStructureTestCase, SharedModuleStoreTestCase):
|
|
"""
|
|
Test mongo usage in GradesTransformer.
|
|
"""
|
|
|
|
TRANSFORMER_CLASS_TO_TEST = GradesTransformer
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
password = 'test'
|
|
self.student = UserFactory.create(is_staff=False, username='test_student', password=password)
|
|
self.client.login(username=self.student.username, password=password)
|
|
|
|
@ddt.data(
|
|
(ModuleStoreEnum.Type.split, 3),
|
|
(ModuleStoreEnum.Type.mongo, 2),
|
|
)
|
|
@ddt.unpack
|
|
def test_modulestore_performance(self, store_type, expected_mongo_queries):
|
|
"""
|
|
Test that a constant number of mongo calls are made regardless of how
|
|
many grade-related blocks are in the course.
|
|
"""
|
|
course = [
|
|
{
|
|
'org': 'GradesTestOrg',
|
|
'course': 'GB101',
|
|
'run': 'cannonball',
|
|
'metadata': {'format': 'homework'},
|
|
'#type': 'course',
|
|
'#ref': 'course',
|
|
'#children': [],
|
|
},
|
|
]
|
|
for problem_number in range(random.randrange(10, 20)):
|
|
course[0]['#children'].append(
|
|
{
|
|
'metadata': {
|
|
'graded': True,
|
|
'weight': 1,
|
|
'due': datetime.datetime(2099, 3, 15, 12, 30, 0, tzinfo=pytz.utc),
|
|
},
|
|
'#type': 'problem',
|
|
'#ref': f'problem_{problem_number}',
|
|
'data': '''
|
|
<problem>
|
|
<numericalresponse answer="{number}">
|
|
<textline label="1*{number}" />
|
|
</numericalresponse>
|
|
</problem>'''.format(number=problem_number),
|
|
}
|
|
)
|
|
with self.store.default_store(store_type):
|
|
blocks = self.build_course(course)
|
|
clear_course_from_cache(blocks['course'].id)
|
|
with check_mongo_calls(expected_mongo_queries):
|
|
get_course_blocks(self.student, blocks['course'].location, self.transformers)
|