diff --git a/lms/djangoapps/course_api/blocks/api.py b/lms/djangoapps/course_api/blocks/api.py index 233f618301..ff3a163dd6 100644 --- a/lms/djangoapps/course_api/blocks/api.py +++ b/lms/djangoapps/course_api/blocks/api.py @@ -8,6 +8,7 @@ from openedx.core.djangoapps.content.block_structure.transformers import BlockSt from .serializers import BlockDictSerializer, BlockSerializer from .transformers.blocks_api import BlocksAPITransformer +from .transformers.block_completion import BlockCompletionTransformer from .transformers.milestones import MilestonesAndSpecialExamsTransformer @@ -51,9 +52,11 @@ def get_blocks( """ # create ordered list of transformers, adding BlocksAPITransformer at end. transformers = BlockStructureTransformers() - include_special_exams = False - if requested_fields is not None and 'special_exam_info' in requested_fields: - include_special_exams = True + if requested_fields is None: + requested_fields = [] + include_completion = 'completion' in requested_fields + include_special_exams = 'special_exam_info' in requested_fields + if user is not None: transformers += COURSE_BLOCK_ACCESS_TRANSFORMERS transformers += [MilestonesAndSpecialExamsTransformer(include_special_exams), HiddenContentTransformer()] @@ -66,6 +69,9 @@ def get_blocks( ) ] + if include_completion: + transformers += [BlockCompletionTransformer()] + # transform blocks = get_course_blocks(user, usage_key, transformers) diff --git a/lms/djangoapps/course_api/blocks/transformers/__init__.py b/lms/djangoapps/course_api/blocks/transformers/__init__.py index aa8f2bdb95..c5b393a24f 100644 --- a/lms/djangoapps/course_api/blocks/transformers/__init__.py +++ b/lms/djangoapps/course_api/blocks/transformers/__init__.py @@ -4,6 +4,7 @@ Course API Block Transformers from lms.djangoapps.course_blocks.transformers.visibility import VisibilityTransformer from .student_view import StudentViewTransformer +from .block_completion import BlockCompletionTransformer from .block_counts import BlockCountsTransformer from .navigation import BlockNavigationTransformer from .milestones import MilestonesAndSpecialExamsTransformer @@ -63,5 +64,10 @@ SUPPORTED_FIELDS = [ 'merged_visible_to_staff_only', VisibilityTransformer, requested_field_name='visible_to_staff_only', + ), + SupportedFieldType( + BlockCompletionTransformer.COMPLETION, + BlockCompletionTransformer, + 'completion' ) ] diff --git a/lms/djangoapps/course_api/blocks/transformers/block_completion.py b/lms/djangoapps/course_api/blocks/transformers/block_completion.py new file mode 100644 index 0000000000..b7f964f89b --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/block_completion.py @@ -0,0 +1,86 @@ +""" +Block Completion Transformer +""" + +from xblock.completable import XBlockCompletionMode as CompletionMode + +from lms.djangoapps.completion.models import BlockCompletion +from openedx.core.djangoapps.content.block_structure.transformer import BlockStructureTransformer + + +class BlockCompletionTransformer(BlockStructureTransformer): + """ + Keep track of the completion of each block within the block structure. + """ + READ_VERSION = 0 + WRITE_VERSION = 1 + COMPLETION = 'completion' + + def __init__(self): + super(BlockCompletionTransformer, self).__init__() + + @classmethod + def name(cls): + return "blocks_api:completion" + + @classmethod + def get_block_completion(cls, block_structure, block_key): + """ + Return the precalculated completion of a block within the block_structure: + + Arguments: + block_structure: a BlockStructure instance + block_key: the key of the block whose completion we want to know + + Returns: + block_completion: float or None + """ + return block_structure.get_transformer_block_field( + block_key, + cls, + cls.COMPLETION, + ) + + @classmethod + def collect(cls, block_structure): + block_structure.request_xblock_fields('completion_mode') + + def transform(self, usage_info, block_structure): + """ + Mutates block_structure adding extra field which contains block's completion. + """ + def _is_block_an_aggregator_or_excluded(block_key): + """ + Checks whether block's completion method + is of `AGGREGATOR` or `EXCLUDED` type. + """ + completion_mode = block_structure.get_xblock_field( + block_key, 'completion_mode' + ) + + return completion_mode in (CompletionMode.AGGREGATOR, CompletionMode.EXCLUDED) + + completions = BlockCompletion.objects.filter( + user=usage_info.user, + course_key=usage_info.course_key, + ).values_list( + 'block_key', + 'completion', + ) + + completions_dict = { + block.map_into_course(usage_info.course_key): completion + for block, completion in completions + } + + for block_key in block_structure.topological_traversal(): + if _is_block_an_aggregator_or_excluded(block_key): + completion_value = None + elif block_key in completions_dict: + completion_value = completions_dict[block_key] + else: + completion_value = 0.0 + + block_structure.set_transformer_block_field( + block_key, self, self.COMPLETION, completion_value + ) diff --git a/lms/djangoapps/course_api/blocks/transformers/tests/test_block_completion.py b/lms/djangoapps/course_api/blocks/transformers/tests/test_block_completion.py new file mode 100644 index 0000000000..bf546c7eff --- /dev/null +++ b/lms/djangoapps/course_api/blocks/transformers/tests/test_block_completion.py @@ -0,0 +1,118 @@ +""" +Tests for BlockCompletionTransformer. +""" +from xblock.core import XBlock +from xblock.completable import CompletableXBlockMixin, XBlockCompletionMode + +from lms.djangoapps.completion.models import BlockCompletion +from lms.djangoapps.completion.test_utils import CompletionWaffleTestMixin +from lms.djangoapps.course_api.blocks.transformers.block_completion import BlockCompletionTransformer +from lms.djangoapps.course_blocks.transformers.tests.helpers import ModuleStoreTestCase, TransformerRegistryTestMixin +from student.tests.factories import UserFactory +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + + +from ...api import get_course_blocks + + +class StubAggregatorXBlock(XBlock): + """ + XBlock to test behaviour of BlockCompletionTransformer + when transforming aggregator XBlock. + """ + completion_mode = XBlockCompletionMode.AGGREGATOR + + +class StubExcludedXBlock(XBlock): + """ + XBlock to test behaviour of BlockCompletionTransformer + when transforming excluded XBlock. + """ + completion_mode = XBlockCompletionMode.EXCLUDED + + +class StubCompletableXBlock(XBlock, CompletableXBlockMixin): + """ + XBlock to test behaviour of BlockCompletionTransformer + when transforming completable XBlock. + """ + pass + + +class BlockCompletionTransformerTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin): + """ + Tests behaviour of BlockCompletionTransformer + """ + TRANSFORMER_CLASS_TO_TEST = BlockCompletionTransformer + COMPLETION_TEST_VALUE = 0.4 + + def setUp(self): + super(BlockCompletionTransformerTestCase, self).setUp() + self.user = UserFactory.create(password='test') + self.override_waffle_switch(True) + + @XBlock.register_temp_plugin(StubAggregatorXBlock, identifier='aggregator') + def test_transform_gives_none_for_aggregator(self): + course = CourseFactory.create() + block = ItemFactory.create(category='aggregator', parent=course) + block_structure = get_course_blocks( + self.user, course.location, self.transformers + ) + + self._assert_block_has_proper_completion_value( + block_structure, block.location, None + ) + + @XBlock.register_temp_plugin(StubExcludedXBlock, identifier='excluded') + def test_transform_gives_none_for_excluded(self): + course = CourseFactory.create() + block = ItemFactory.create(category='excluded', parent=course) + block_structure = get_course_blocks( + self.user, course.location, self.transformers + ) + + self._assert_block_has_proper_completion_value( + block_structure, block.location, None + ) + + @XBlock.register_temp_plugin(StubCompletableXBlock, identifier='comp') + def test_transform_gives_value_for_completable(self): + course = CourseFactory.create() + block = ItemFactory.create(category='comp', parent=course) + BlockCompletion.objects.submit_completion( + user=self.user, + course_key=block.location.course_key, + block_key=block.location, + completion=self.COMPLETION_TEST_VALUE, + ) + block_structure = get_course_blocks( + self.user, course.location, self.transformers + ) + + self._assert_block_has_proper_completion_value( + block_structure, block.location, self.COMPLETION_TEST_VALUE + ) + + def test_transform_gives_zero_for_ordinary_block(self): + course = CourseFactory.create() + block = ItemFactory.create(category='html', parent=course) + block_structure = get_course_blocks( + self.user, course.location, self.transformers + ) + + self._assert_block_has_proper_completion_value( + block_structure, block.location, 0.0 + ) + + def _assert_block_has_proper_completion_value( + self, block_structure, block_key, expected_value + ): + """ + Checks whether block's completion has expected value. + """ + block_data = block_structure.get_transformer_block_data( + block_key, self.TRANSFORMER_CLASS_TO_TEST + ) + completion_value = block_data.fields['completion'] + + self.assertEqual(completion_value, expected_value) diff --git a/lms/djangoapps/course_api/blocks/views.py b/lms/djangoapps/course_api/blocks/views.py index 2b9f939d4c..05e80f7ceb 100644 --- a/lms/djangoapps/course_api/blocks/views.py +++ b/lms/djangoapps/course_api/blocks/views.py @@ -128,6 +128,11 @@ class BlocksView(DeveloperErrorViewMixin, ListAPIView): the child blocks. Returned only if "children" is included in the "requested_fields" parameter. + * completion: (float or None) The level of completion of the block. + Its value can vary between 0.0 and 1.0 or be equal to None + if block is not completable. Returned only if "completion" + is included in the "requested_fields" parameter. + * block_counts: (dict) For each block type specified in the block_counts parameter to the endpoint, the aggregate number of blocks of that type for this block and all of its descendants. diff --git a/openedx/core/djangoapps/content/block_structure/block_structure.py b/openedx/core/djangoapps/content/block_structure/block_structure.py index ff14e62f4e..bd92216ac6 100644 --- a/openedx/core/djangoapps/content/block_structure/block_structure.py +++ b/openedx/core/djangoapps/content/block_structure/block_structure.py @@ -730,7 +730,7 @@ class BlockStructureBlockData(BlockStructure): Adds the given transformer to the block structure by recording its current version number. """ - if transformer.READ_VERSION == 0 or transformer.WRITE_VERSION == 0: + if transformer.WRITE_VERSION == 0: raise TransformerException('Version attributes are not set on transformer {0}.', transformer.name()) self.set_transformer_data(transformer, TRANSFORMER_VERSION_KEY, transformer.WRITE_VERSION) diff --git a/setup.py b/setup.py index d5d37a7a42..c625903af8 100644 --- a/setup.py +++ b/setup.py @@ -58,6 +58,7 @@ setup( "course_blocks_api = lms.djangoapps.course_api.blocks.transformers.blocks_api:BlocksAPITransformer", "milestones = lms.djangoapps.course_api.blocks.transformers.milestones:MilestonesAndSpecialExamsTransformer", "grades = lms.djangoapps.grades.transformer:GradesTransformer", + "completion = lms.djangoapps.course_api.blocks.transformers.block_completion:BlockCompletionTransformer" ], "openedx.ace.policy": [ "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout"