485 lines
21 KiB
Python
485 lines
21 KiB
Python
# lint-amnesty, pylint: disable=django-not-configured
|
|
"""
|
|
Tests of the LMS XBlock Mixin
|
|
"""
|
|
|
|
import ddt
|
|
from xblock.validation import ValidationMessage
|
|
|
|
from lms.djangoapps.lms_xblock.mixin import (
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
|
|
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
|
|
INVALID_USER_PARTITION_VALIDATION_UNIT,
|
|
NONSENSICAL_ACCESS_RESTRICTION
|
|
)
|
|
from xmodule.modulestore import ModuleStoreEnum
|
|
from xmodule.modulestore.tests.django_utils import TEST_DATA_MIXED_MODULESTORE, ModuleStoreTestCase
|
|
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory, ToyCourseFactory
|
|
from xmodule.partitions.partitions import Group, UserPartition
|
|
|
|
|
|
class LmsXBlockMixinTestCase(ModuleStoreTestCase):
|
|
"""
|
|
Base class for XBlock mixin tests cases. A simple course with a single user partition is created
|
|
in setUp for all subclasses to use.
|
|
"""
|
|
def build_course(self):
|
|
"""
|
|
Build up a course tree with a UserPartition.
|
|
"""
|
|
# pylint: disable=attribute-defined-outside-init
|
|
self.user_partition = UserPartition(
|
|
0,
|
|
'first_partition',
|
|
'First Partition',
|
|
[
|
|
Group(0, 'alpha'),
|
|
Group(1, 'beta')
|
|
]
|
|
)
|
|
self.group1 = self.user_partition.groups[0]
|
|
self.group2 = self.user_partition.groups[1]
|
|
self.course = CourseFactory.create(user_partitions=[self.user_partition])
|
|
section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
|
|
subsection = ItemFactory.create(parent=section, category='sequential', display_name='Test Subsection')
|
|
vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='Test Unit')
|
|
video = ItemFactory.create(parent=vertical, category='video', display_name='Test Video 1')
|
|
split_test = ItemFactory.create(parent=vertical, category='split_test', display_name='Test Content Experiment')
|
|
child_vertical = ItemFactory.create(parent=split_test, category='vertical')
|
|
child_html_module = ItemFactory.create(parent=child_vertical, category='html')
|
|
self.section_location = section.location
|
|
self.subsection_location = subsection.location
|
|
self.vertical_location = vertical.location
|
|
self.video_location = video.location
|
|
self.split_test_location = split_test.location
|
|
self.child_vertical_location = child_vertical.location
|
|
self.child_html_module_location = child_html_module.location
|
|
|
|
def set_group_access(self, block_location, access_dict):
|
|
"""
|
|
Sets the group_access dict on the block referenced by block_location.
|
|
"""
|
|
block = self.store.get_item(block_location)
|
|
block.group_access = access_dict
|
|
self.store.update_item(block, 1)
|
|
|
|
|
|
class XBlockValidationTest(LmsXBlockMixinTestCase):
|
|
"""
|
|
Unit tests for XBlock validation
|
|
"""
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.build_course()
|
|
|
|
def verify_validation_message(self, message, expected_message, expected_message_type):
|
|
"""
|
|
Verify that the validation message has the expected validation message and type.
|
|
"""
|
|
assert message.text == expected_message
|
|
assert message.type == expected_message_type
|
|
|
|
def test_validate_full_group_access(self):
|
|
"""
|
|
Test the validation messages produced for an xblock with full group access.
|
|
"""
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 0
|
|
|
|
def test_validate_restricted_group_access(self):
|
|
"""
|
|
Test the validation messages produced for an xblock with a valid group access restriction
|
|
"""
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, self.group2.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 0
|
|
|
|
def test_validate_invalid_user_partitions(self):
|
|
"""
|
|
Test the validation messages produced for a component referring to non-existent user partitions.
|
|
"""
|
|
self.set_group_access(self.video_location, {999: [self.group1.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Now add a second invalid user partition and validate again.
|
|
# Note that even though there are two invalid configurations,
|
|
# only a single error message will be returned.
|
|
self.set_group_access(self.video_location, {998: [self.group2.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_VALIDATION_COMPONENT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_validate_invalid_user_partitions_unit(self):
|
|
"""
|
|
Test the validation messages produced for a unit referring to non-existent user partitions.
|
|
"""
|
|
self.set_group_access(self.vertical_location, {999: [self.group1.id]})
|
|
validation = self.store.get_item(self.vertical_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_VALIDATION_UNIT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Now add a second invalid user partition and validate again.
|
|
# Note that even though there are two invalid configurations,
|
|
# only a single error message will be returned.
|
|
self.set_group_access(self.vertical_location, {998: [self.group2.id]})
|
|
validation = self.store.get_item(self.vertical_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_VALIDATION_UNIT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_validate_invalid_groups(self):
|
|
"""
|
|
Test the validation messages produced for an xblock referring to non-existent groups.
|
|
"""
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, 999]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Now try again with two invalid group ids
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, 998, 999]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_validate_nonsensical_access_for_split_test_children(self):
|
|
"""
|
|
Test the validation messages produced for components within
|
|
a content group experiment (also known as a split_test).
|
|
Ensures that children of split_test xblocks only validate
|
|
their access settings off the parent, rather than any
|
|
grandparent.
|
|
"""
|
|
# Test that no validation message is displayed on split_test child when child agrees with parent
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.split_test_location, {self.user_partition.id: [self.group2.id]})
|
|
self.set_group_access(self.child_vertical_location, {self.user_partition.id: [self.group2.id]})
|
|
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group2.id]})
|
|
validation = self.store.get_item(self.child_html_module_location).validate()
|
|
assert len(validation.messages) == 0
|
|
|
|
# Test that a validation message is displayed on split_test child when the child contradicts the parent,
|
|
# even though the child agrees with the grandparent unit.
|
|
self.set_group_access(self.child_html_module_location, {self.user_partition.id: [self.group1.id]})
|
|
validation = self.store.get_item(self.child_html_module_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_validate_invalid_groups_for_unit(self):
|
|
"""
|
|
Test the validation messages produced for a unit-level xblock referring to non-existent groups.
|
|
"""
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id, 999]})
|
|
validation = self.store.get_item(self.vertical_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_UNIT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_validate_nonsensical_access_restriction(self):
|
|
"""
|
|
Test the validation messages produced for a component whose
|
|
access settings contradict the unit level access.
|
|
"""
|
|
# Test that there is no validation message for non-contradicting access restrictions
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 0
|
|
|
|
# Now try again with opposing access restrictions
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Now try again when the component restricts access to additional groups that the unit does not
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group1.id, self.group2.id]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
# Now try again when the component tries to allow access to all learners and staff
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.video_location, {})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 1
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
def test_nonsensical_access_restriction_does_not_override(self):
|
|
"""
|
|
Test that the validation message produced for a component
|
|
whose access settings contradict the unit level access don't
|
|
override other messages but add on to them.
|
|
"""
|
|
self.set_group_access(self.vertical_location, {self.user_partition.id: [self.group1.id]})
|
|
self.set_group_access(self.video_location, {self.user_partition.id: [self.group2.id, 999]})
|
|
validation = self.store.get_item(self.video_location).validate()
|
|
assert len(validation.messages) == 2
|
|
self.verify_validation_message(
|
|
validation.messages[0],
|
|
INVALID_USER_PARTITION_GROUP_VALIDATION_COMPONENT,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
self.verify_validation_message(
|
|
validation.messages[1],
|
|
NONSENSICAL_ACCESS_RESTRICTION,
|
|
ValidationMessage.ERROR,
|
|
)
|
|
|
|
|
|
class OpenAssessmentBlockMixinTestCase(ModuleStoreTestCase):
|
|
"""
|
|
Tests for OpenAssessmentBlock mixin.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.course = CourseFactory.create()
|
|
self.section = ItemFactory.create(parent=self.course, category='chapter', display_name='Test Section')
|
|
self.open_assessment = ItemFactory.create(
|
|
parent=self.section,
|
|
category="openassessment",
|
|
display_name="untitled",
|
|
)
|
|
|
|
def test_has_score(self):
|
|
"""
|
|
Test has_score is true for ora2 problems.
|
|
"""
|
|
assert self.open_assessment.has_score
|
|
|
|
|
|
@ddt.ddt
|
|
class XBlockGetParentTest(LmsXBlockMixinTestCase):
|
|
"""
|
|
Test that XBlock.get_parent returns correct results with each modulestore
|
|
backend.
|
|
"""
|
|
MODULESTORE = TEST_DATA_MIXED_MODULESTORE
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
|
def test_parents(self, modulestore_type):
|
|
with self.store.default_store(modulestore_type):
|
|
|
|
# setting up our own local course tree here, since it needs to be
|
|
# created with the correct modulestore type.
|
|
|
|
course_key = ToyCourseFactory.create().id
|
|
course = self.store.get_course(course_key)
|
|
assert course.get_parent() is None
|
|
|
|
def recurse(parent):
|
|
"""
|
|
Descend the course tree and ensure the result of get_parent()
|
|
is the expected one.
|
|
"""
|
|
visited = []
|
|
for child in parent.get_children():
|
|
assert parent.location == child.get_parent().location
|
|
visited.append(child)
|
|
visited += recurse(child)
|
|
return visited
|
|
|
|
visited = recurse(course)
|
|
assert len(visited) == 28
|
|
|
|
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
|
|
def test_parents_draft_content(self, modulestore_type):
|
|
# move the video to the new vertical
|
|
with self.store.default_store(modulestore_type):
|
|
self.build_course()
|
|
subsection = self.store.get_item(self.subsection_location)
|
|
new_vertical = ItemFactory.create(parent=subsection, category='vertical', display_name='New Test Unit')
|
|
child_to_move_location = self.video_location.for_branch(None)
|
|
new_parent_location = new_vertical.location.for_branch(None)
|
|
old_parent_location = self.vertical_location.for_branch(None)
|
|
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.draft_preferred):
|
|
assert self.course.get_parent() is None
|
|
|
|
with self.store.bulk_operations(self.course.id):
|
|
user_id = ModuleStoreEnum.UserID.test
|
|
|
|
old_parent = self.store.get_item(old_parent_location)
|
|
old_parent.children.remove(child_to_move_location)
|
|
self.store.update_item(old_parent, user_id)
|
|
|
|
new_parent = self.store.get_item(new_parent_location)
|
|
new_parent.children.append(child_to_move_location)
|
|
self.store.update_item(new_parent, user_id)
|
|
|
|
# re-fetch video from draft store
|
|
video = self.store.get_item(child_to_move_location)
|
|
|
|
assert new_parent_location == video.get_parent().location
|
|
with self.store.branch_setting(ModuleStoreEnum.Branch.published_only):
|
|
# re-fetch video from published store
|
|
video = self.store.get_item(child_to_move_location)
|
|
assert old_parent_location == video.get_parent().location.for_branch(None)
|
|
|
|
|
|
class RenamedTuple(tuple):
|
|
"""
|
|
This class is only used to allow overriding __name__ on the tuples passed
|
|
through ddt, in order to have the generated test names make sense.
|
|
"""
|
|
pass # lint-amnesty, pylint: disable=unnecessary-pass
|
|
|
|
|
|
def ddt_named(parent, child):
|
|
"""
|
|
Helper to get more readable dynamically-generated test names from ddt.
|
|
"""
|
|
args = RenamedTuple([parent, child])
|
|
args.__name__ = f'parent_{parent}_child_{child}' # pylint: disable=attribute-defined-outside-init
|
|
return args
|
|
|
|
|
|
@ddt.ddt
|
|
class XBlockMergedGroupAccessTest(LmsXBlockMixinTestCase):
|
|
"""
|
|
Test that XBlock.merged_group_access is computed correctly according to
|
|
our access control rules.
|
|
"""
|
|
|
|
PARTITION_1 = 1
|
|
PARTITION_1_GROUP_1 = 11
|
|
PARTITION_1_GROUP_2 = 12
|
|
|
|
PARTITION_2 = 2
|
|
PARTITION_2_GROUP_1 = 21
|
|
PARTITION_2_GROUP_2 = 22
|
|
|
|
PARENT_CHILD_PAIRS = (
|
|
ddt_named('section_location', 'subsection_location'),
|
|
ddt_named('section_location', 'vertical_location'),
|
|
ddt_named('section_location', 'video_location'),
|
|
ddt_named('subsection_location', 'vertical_location'),
|
|
ddt_named('subsection_location', 'video_location'),
|
|
)
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.build_course()
|
|
|
|
def verify_group_access(self, block_location, expected_dict):
|
|
"""
|
|
Verify the expected value for the block's group_access.
|
|
"""
|
|
block = self.store.get_item(block_location)
|
|
assert block.merged_group_access == expected_dict
|
|
|
|
@ddt.data(*PARENT_CHILD_PAIRS)
|
|
@ddt.unpack
|
|
def test_intersecting_groups(self, parent, child):
|
|
"""
|
|
When merging group_access on a block, the resulting group IDs for each
|
|
partition is the intersection of the group IDs defined for that
|
|
partition across all ancestor blocks (including this one).
|
|
"""
|
|
parent_block = getattr(self, parent)
|
|
child_block = getattr(self, child)
|
|
|
|
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]})
|
|
self.set_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
|
|
|
|
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]})
|
|
self.verify_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
|
|
|
|
@ddt.data(*PARENT_CHILD_PAIRS)
|
|
@ddt.unpack
|
|
def test_disjoint_groups(self, parent, child):
|
|
"""
|
|
When merging group_access on a block, if the intersection of group IDs
|
|
for a partition is empty, the merged value for that partition is False.
|
|
"""
|
|
parent_block = getattr(self, parent)
|
|
child_block = getattr(self, child)
|
|
|
|
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
|
|
self.set_group_access(child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
|
|
|
|
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
|
|
self.verify_group_access(child_block, {self.PARTITION_1: False})
|
|
|
|
def test_disjoint_groups_no_override(self):
|
|
"""
|
|
Special case of the above test - ensures that `False` propagates down
|
|
to the block being queried even if blocks further down in the hierarchy
|
|
try to override it.
|
|
"""
|
|
self.set_group_access(self.section_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
|
|
self.set_group_access(self.subsection_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_2]})
|
|
self.set_group_access(
|
|
self.vertical_location, {self.PARTITION_1: [self.PARTITION_1_GROUP_1, self.PARTITION_1_GROUP_2]}
|
|
)
|
|
|
|
self.verify_group_access(self.vertical_location, {self.PARTITION_1: False})
|
|
self.verify_group_access(self.video_location, {self.PARTITION_1: False})
|
|
|
|
@ddt.data(*PARENT_CHILD_PAIRS)
|
|
@ddt.unpack
|
|
def test_union_partitions(self, parent, child):
|
|
"""
|
|
When merging group_access on a block, the result's keys (partitions)
|
|
are the union of all partitions specified across all ancestor blocks
|
|
(including this one).
|
|
"""
|
|
parent_block = getattr(self, parent)
|
|
child_block = getattr(self, child)
|
|
|
|
self.set_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
|
|
self.set_group_access(child_block, {self.PARTITION_2: [self.PARTITION_1_GROUP_2]})
|
|
|
|
self.verify_group_access(parent_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1]})
|
|
self.verify_group_access(
|
|
child_block, {self.PARTITION_1: [self.PARTITION_1_GROUP_1], self.PARTITION_2: [self.PARTITION_1_GROUP_2]}
|
|
)
|