Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions lms/djangoapps/course_api/blocks/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

from .serializers import BlockDictSerializer, BlockSerializer
from .transformers.blocks_api import BlocksAPITransformer
from .transformers.block_completion import BlockCompletionTransformer
from .transformers.milestones import MilestonesAndSpecialExamsTransformer


Expand Down Expand Up @@ -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()]
Expand All @@ -66,6 +69,9 @@ def get_blocks(
)
]

if include_completion:
transformers += [BlockCompletionTransformer()]

# transform
blocks = get_course_blocks(user, usage_key, transformers)

Expand Down
6 changes: 6 additions & 0 deletions lms/djangoapps/course_api/blocks/transformers/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

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
Expand Down Expand Up @@ -63,5 +64,10 @@ def __init__(
'merged_visible_to_staff_only',
VisibilityTransformer,
requested_field_name='visible_to_staff_only',
),
SupportedFieldType(
BlockCompletionTransformer.COMPLETION,
BlockCompletionTransformer,
'completion'

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Future note to self (and others): It would be great if we can make this pluggable, so new Transformers don't need to manually update this list - and can be automatically extracted from registered transformers.

@jcdyer jcdyer Dec 6, 2017

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@nasthagiri I remember implementing a registry for something once, and I found two basic ways of doing it: The first is by creating a registry class with a register() decorator function:

class Registry(object):
    def __init__(self):
        self.registered = []
    def register(self, registrant):
        self.registered.append(registrant)
        return transformer

transformers = Registry()
@transformers.register
class MyTransformer(BlockTransformer):
    pass

The other uses a metaclass defined on the superclass. Then when the class gets constructed, it is automatically registered in the metaclass. Django models use this method.

The major differences are:

  1. that the decorator is visible to the programmer, while the metaclass is hidden.
  2. programmers can opt-out of the decorator by not including it, but not the metaclass (unless the metaclass explicitly provides a hook, like writing):
    class Registerable(Registry):
    register = False
    The downside of allowing programmers to opt out (or more accurately opt not to opt-in) is that you also make it possible to forget to opt-in.
  3. that decorators are easier to read and write than metaclasses.

)
]
86 changes: 86 additions & 0 deletions lms/djangoapps/course_api/blocks/transformers/block_completion.py
Original file line number Diff line number Diff line change
@@ -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
)
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 5 additions & 0 deletions lms/djangoapps/course_api/blocks/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -730,7 +730,7 @@ def _add_transformer(self, transformer):
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)

Expand Down
1 change: 1 addition & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@
"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"
Expand Down