Commit b8202e40 by J. Cliff Dyer

Handle default complete-by-viewing completion method.

* Vertical marks blocks completed when viewed.
* Functionality is hidden behind a waffle switch
* Submissions from front-end are limited to known-incomplete blocks
* Upgrades xblock to version 1.1.1
* Related future requirements listed in TODO tagged with EDUCATOR-1778
  and relevant opencraft OC-* ticket IDs.

OC-3088
parent 64dd633f
/* JavaScript for Vertical Student View. */ /* JavaScript for Vertical Student View. */
/* global Set:false */ // false means do not assign to Set
// The vertical marks blocks complete if they are completable by viewing. The
// global variable SEEN_COMPLETABLES tracks blocks between separate loads of
// the same vertical (when a learner goes from one tab to the next, and then
// navigates back within a given sequential) to protect against duplicate calls
// to the server.
var SEEN_COMPLETABLES = new Set();
window.VerticalStudentView = function(runtime, element) { window.VerticalStudentView = function(runtime, element) {
'use strict'; 'use strict';
RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) { RequireJS.require(['course_bookmarks/js/views/bookmark_button'], function(BookmarkButton) {
...@@ -13,4 +24,32 @@ window.VerticalStudentView = function(runtime, element) { ...@@ -13,4 +24,32 @@ window.VerticalStudentView = function(runtime, element) {
apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl') apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl')
}); });
}); });
$(element).find('.vert').each(
function(idx, block) {
var blockKey = block.dataset.id;
if (block.dataset.completableByViewing === undefined) {
return;
}
// TODO: EDUCATOR-1778
// * Check if blocks are in the browser's view window or in focus
// before marking complete. This will include a configurable
// delay so that blocks must be seen for a few seconds before
// being marked complete, to prevent completion via rapid
// scrolling. (OC-3358)
// * Limit network traffic by batching and throttling calls.
// (OC-3090)
if (blockKey && !SEEN_COMPLETABLES.has(blockKey)) {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'publish_completion'),
data: JSON.stringify({
block_key: blockKey,
completion: 1.0
})
});
SEEN_COMPLETABLES.add(blockKey);
}
}
);
}; };
...@@ -313,6 +313,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule): ...@@ -313,6 +313,7 @@ class LibraryContentModule(LibraryContentFields, XModule, StudioEditableModule):
'items': contents, 'items': contents,
'xblock_context': context, 'xblock_context': context,
'show_bookmark_button': False, 'show_bookmark_button': False,
'watched_completable_blocks': set(),
})) }))
return fragment return fragment
......
...@@ -11,6 +11,7 @@ from datetime import datetime ...@@ -11,6 +11,7 @@ from datetime import datetime
from lxml import etree from lxml import etree
from pkg_resources import resource_string from pkg_resources import resource_string
from pytz import UTC from pytz import UTC
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock from xblock.core import XBlock
from xblock.fields import Boolean, Integer, List, Scope, String from xblock.fields import Boolean, Integer, List, Scope, String
from xblock.fragment import Fragment from xblock.fragment import Fragment
...@@ -40,6 +41,7 @@ _ = lambda text: text ...@@ -40,6 +41,7 @@ _ = lambda text: text
class SequenceFields(object): class SequenceFields(object):
has_children = True has_children = True
completion_mode = XBlockCompletionMode.AGGREGATOR
# NOTE: Position is 1-indexed. This is silly, but there are now student # NOTE: Position is 1-indexed. This is silly, but there are now student
# positions saved on prod, so it's not easy to fix. # positions saved on prod, so it's not easy to fix.
......
""" """
Tests for vertical module. Tests for vertical module.
""" """
# pylint: disable=protected-access
from __future__ import absolute_import, division, print_function, unicode_literals
from collections import namedtuple
import json
import ddt import ddt
from mock import Mock
from fs.memoryfs import MemoryFS from fs.memoryfs import MemoryFS
from xmodule.tests import get_test_system from mock import Mock, patch
from xmodule.tests.helpers import StubUserService import six
from xmodule.tests.xml import XModuleXmlImportTest
from xmodule.tests.xml import factories as xml from . import get_test_system
from xmodule.x_module import STUDENT_VIEW, AUTHOR_VIEW from .helpers import StubUserService
from .xml import XModuleXmlImportTest
from .xml import factories as xml
from ..x_module import STUDENT_VIEW, AUTHOR_VIEW
JsonRequest = namedtuple('JsonRequest', ['method', 'body'])
def get_json_request(data):
"""
Given a data dictionary, return an appropriate JSON request.
"""
return JsonRequest(
method='POST',
body=json.dumps(data),
)
class StubCompletionService(object):
"""
A stub implementation of the CompletionService for testing without access to django
"""
def __init__(self, enabled, completion_value):
self._enabled = enabled
self._completion_value = completion_value
def completion_tracking_enabled(self):
"""
Turn on or off completion tracking for clients of the
StubCompletionService.
"""
return self._enabled
def get_completions(self, candidates):
"""
Return the (dummy) completion values for each specified candidate
block.
"""
return {candidate: self._completion_value for candidate in candidates}
class BaseVerticalBlockTest(XModuleXmlImportTest): class BaseVerticalBlockTest(XModuleXmlImportTest):
...@@ -33,12 +79,15 @@ class BaseVerticalBlockTest(XModuleXmlImportTest): ...@@ -33,12 +79,15 @@ class BaseVerticalBlockTest(XModuleXmlImportTest):
course_seq = self.course.get_children()[0] course_seq = self.course.get_children()[0]
self.module_system = get_test_system() self.module_system = get_test_system()
self.module_system.descriptor_runtime = self.course._runtime # pylint: disable=protected-access self.module_system.descriptor_runtime = self.course._runtime
self.course.runtime.export_fs = MemoryFS() self.course.runtime.export_fs = MemoryFS()
self.vertical = course_seq.get_children()[0] self.vertical = course_seq.get_children()[0]
self.vertical.xmodule_runtime = self.module_system self.vertical.xmodule_runtime = self.module_system
self.html1block = self.vertical.get_children()[0]
self.html2block = self.vertical.get_children()[1]
self.username = "bilbo" self.username = "bilbo"
self.default_context = {"bookmarked": False, "username": self.username} self.default_context = {"bookmarked": False, "username": self.username}
...@@ -66,8 +115,8 @@ class VerticalBlockTestCase(BaseVerticalBlockTest): ...@@ -66,8 +115,8 @@ class VerticalBlockTestCase(BaseVerticalBlockTest):
""" """
Test the rendering of the student view. Test the rendering of the student view.
""" """
self.module_system._services['bookmarks'] = Mock() # pylint: disable=protected-access self.module_system._services['bookmarks'] = Mock()
self.module_system._services['user'] = StubUserService() # pylint: disable=protected-access self.module_system._services['user'] = StubUserService()
html = self.module_system.render( html = self.module_system.render(
self.vertical, STUDENT_VIEW, self.default_context if context is None else context self.vertical, STUDENT_VIEW, self.default_context if context is None else context
...@@ -76,6 +125,38 @@ class VerticalBlockTestCase(BaseVerticalBlockTest): ...@@ -76,6 +125,38 @@ class VerticalBlockTestCase(BaseVerticalBlockTest):
self.assertIn(self.test_html_2, html) self.assertIn(self.test_html_2, html)
self.assert_bookmark_info_in(html) self.assert_bookmark_info_in(html)
@staticmethod
def _render_completable_blocks(template, context): # pylint: disable=unused-argument
"""
A custom template rendering function that displays the
watched_completable_blocks of the template.
This is used because the default test renderer is haphazardly
formatted, and is difficult to make assertions about.
"""
return u'|'.join(context['watched_completable_blocks'])
@ddt.unpack
@ddt.data(
(True, 0.9, 'assertIn'),
(False, 0.9, 'assertNotIn'),
(True, 1.0, 'assertNotIn'),
)
def test_completion_data_attrs(self, completion_enabled, completion_value, assertion_method):
"""
Test that data-completable-by-viewing attributes are included only when
the completion service is enabled, and only for blocks with a
completion value less than 1.0.
"""
with patch.object(self.module_system, 'render_template', new=self._render_completable_blocks):
self.module_system._services['completion'] = StubCompletionService(
enabled=completion_enabled,
completion_value=completion_value,
)
response = self.module_system.render(self.vertical, STUDENT_VIEW, self.default_context)
getattr(self, assertion_method)(six.text_type(self.html1block.location), response.content)
getattr(self, assertion_method)(six.text_type(self.html2block.location), response.content)
def test_render_studio_view(self): def test_render_studio_view(self):
""" """
Test the rendering of the Studio author view Test the rendering of the Studio author view
...@@ -97,3 +178,14 @@ class VerticalBlockTestCase(BaseVerticalBlockTest): ...@@ -97,3 +178,14 @@ class VerticalBlockTestCase(BaseVerticalBlockTest):
html = self.module_system.render(self.vertical, AUTHOR_VIEW, context).content html = self.module_system.render(self.vertical, AUTHOR_VIEW, context).content
self.assertIn(self.test_html_1, html) self.assertIn(self.test_html_1, html)
self.assertIn(self.test_html_2, html) self.assertIn(self.test_html_2, html)
def test_publish_completion(self):
request = get_json_request({"block_key": six.text_type(self.html1block.location), "completion": 1.0})
with patch.object(self.vertical.runtime, 'publish') as mock_publisher:
response = self.vertical.publish_completion(request)
self.assertEqual(
response.status_code,
200,
"Expected 200, got {}: {}".format(response.status_code, response.body),
)
mock_publisher.assert_called_with(self.html1block, "completion", {"completion": 1.0})
""" """
VerticalBlock - an XBlock which renders its children in a column. VerticalBlock - an XBlock which renders its children in a column.
""" """
import logging
from __future__ import absolute_import, division, print_function, unicode_literals
from copy import copy from copy import copy
import logging
from lxml import etree from lxml import etree
from opaque_keys.edx.keys import UsageKey
import six
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xmodule.mako_module import MakoTemplateBlockBase from xmodule.mako_module import MakoTemplateBlockBase
from xmodule.progress import Progress from xmodule.progress import Progress
from xmodule.seq_module import SequenceFields from xmodule.seq_module import SequenceFields
...@@ -15,6 +23,7 @@ from xmodule.studio_editable import StudioEditableBlock ...@@ -15,6 +23,7 @@ from xmodule.studio_editable import StudioEditableBlock
from xmodule.x_module import STUDENT_VIEW, XModuleFields from xmodule.x_module import STUDENT_VIEW, XModuleFields
from xmodule.xml_module import XmlParserMixin from xmodule.xml_module import XmlParserMixin
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
# HACK: This shouldn't be hard-coded to two types # HACK: This shouldn't be hard-coded to two types
...@@ -22,7 +31,21 @@ log = logging.getLogger(__name__) ...@@ -22,7 +31,21 @@ log = logging.getLogger(__name__)
CLASS_PRIORITY = ['video', 'problem'] CLASS_PRIORITY = ['video', 'problem']
def is_completable_by_viewing(block):
"""
Returns True if the block can by completed by viewing it.
This is true of any non-customized, non-scorable, completable block.
"""
return (
getattr(block, 'completion_mode', XBlockCompletionMode.COMPLETABLE) == XBlockCompletionMode.COMPLETABLE
and not getattr(block, 'has_custom_completion', False)
and not block.has_score
)
@XBlock.needs('user', 'bookmarks') @XBlock.needs('user', 'bookmarks')
@XBlock.wants('completion')
class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock): class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock):
""" """
Layout XBlock for rendering subblocks vertically. Layout XBlock for rendering subblocks vertically.
...@@ -37,6 +60,26 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse ...@@ -37,6 +60,26 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
show_in_read_only_mode = True show_in_read_only_mode = True
def get_completable_by_viewing(self):
"""
Return a set of descendent blocks that this vertical still needs to
mark complete upon viewing.
Completed blocks are excluded to reduce network traffic from clients.
"""
completion_service = self.runtime.service(self, 'completion')
if completion_service is None:
return set()
if not completion_service.completion_tracking_enabled():
return set()
# pylint: disable=no-member
blocks = {block.location for block in self.get_display_items() if is_completable_by_viewing(block)}
# pylint: enable=no-member
# Exclude completed blocks to reduce traffic from client.
completions = completion_service.get_completions(blocks)
return {six.text_type(block_key) for block_key in blocks if completions[block_key] < 1.0}
def student_view(self, context): def student_view(self, context):
""" """
Renders the student view of the block in the LMS. Renders the student view of the block in the LMS.
...@@ -66,7 +109,7 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse ...@@ -66,7 +109,7 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
fragment.add_frag_resources(rendered_child) fragment.add_frag_resources(rendered_child)
contents.append({ contents.append({
'id': child.location.to_deprecated_string(), 'id': six.text_type(child.location),
'content': rendered_child.content 'content': rendered_child.content
}) })
...@@ -76,7 +119,8 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse ...@@ -76,7 +119,8 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
'unit_title': self.display_name_with_default if not is_child_of_vertical else None, 'unit_title': self.display_name_with_default if not is_child_of_vertical else None,
'show_bookmark_button': child_context.get('show_bookmark_button', not is_child_of_vertical), 'show_bookmark_button': child_context.get('show_bookmark_button', not is_child_of_vertical),
'bookmarked': child_context['bookmarked'], 'bookmarked': child_context['bookmarked'],
'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location)) 'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location)), # pylint: disable=no-member
'watched_completable_blocks': self.get_completable_by_viewing(),
})) }))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js')) fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vertical_student_view.js'))
...@@ -177,3 +221,29 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse ...@@ -177,3 +221,29 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
xblock_body["content_type"] = "Sequence" xblock_body["content_type"] = "Sequence"
return xblock_body return xblock_body
def find_descendent(self, block_key):
"""
Return the descendent block with the given block key if it exists.
Otherwise return None.
"""
for block in self.get_display_items(): # pylint: disable=no-member
if block.location == block_key:
return block
@XBlock.json_handler
def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument
"""
Publish data from the front end.
"""
block_key = UsageKey.from_string(data.pop('block_key')).map_into_course(self.course_id)
block = self.find_descendent(block_key)
if block is None:
message = "Invalid block: {} not found in {}"
raise JsonHandlerError(400, message.format(block_key, self.location)) # pylint: disable=no-member
elif not is_completable_by_viewing(block):
message = "Invalid block type: {} in block {} not configured for completion by viewing"
raise JsonHandlerError(400, message.format(type(block), block_key))
self.runtime.publish(block, "completion", data)
return {'result': 'ok'}
"""
Runtime service for communicating completion information to the xblock system.
"""
from .models import BlockCompletion
from . import waffle
class CompletionService(object):
"""
Service for handling completions for a user within a course.
Exposes
* self.completion_tracking_enabled() -> bool
* self.get_completions(candidates)
Constructor takes a user object and course_key as arguments.
"""
def __init__(self, user, course_key):
self._user = user
self._course_key = course_key
def completion_tracking_enabled(self):
"""
Exposes ENABLE_COMPLETION_TRACKING waffle switch to XModule runtime
Return value:
bool -> True if completion tracking is enabled.
"""
return waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING)
def get_completions(self, candidates):
"""
Given an iterable collection of block_keys in the course, returns a
mapping of the block_keys to the present completion values of their
associated blocks.
If a completion is not found for a given block in the current course,
0.0 is returned. The service does not attempt to verify that the block
exists within the course.
Parameters:
candidates: collection of BlockKeys within the current course.
Return value:
dict[BlockKey] -> float: Mapping blocks to their completion value.
"""
completion_queryset = BlockCompletion.objects.filter(
user=self._user,
course_key=self._course_key,
block_key__in=candidates,
)
completions = {block.block_key: block.completion for block in completion_queryset}
for candidate in candidates:
if candidate not in completions:
completions[candidate] = 0.0
return completions
"""
Common functionality to support writing tests around completion.
"""
from . import waffle
class CompletionWaffleTestMixin(object):
"""
Common functionality for completion waffle tests.
"""
def override_waffle_switch(self, override):
"""
Override the setting of the ENABLE_COMPLETION_TRACKING waffle switch
for the course of the test.
Parameters:
override (bool): True if tracking should be enabled.
"""
_waffle_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, override)
_waffle_overrider.__enter__()
self.addCleanup(_waffle_overrider.__exit__, None, None, None)
...@@ -16,28 +16,11 @@ from student.tests.factories import UserFactory ...@@ -16,28 +16,11 @@ from student.tests.factories import UserFactory
from .. import handlers from .. import handlers
from ..models import BlockCompletion from ..models import BlockCompletion
from .. import waffle from ..test_utils import CompletionWaffleTestMixin
class CompletionHandlerMixin(object):
"""
Common functionality for completion handler tests.
"""
def override_waffle_switch(self, override):
"""
Override the setting of the ENABLE_COMPLETION_TRACKING waffle switch
for the course of the test.
Parameters:
override (bool): True if tracking should be enabled.
"""
_waffle_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, override)
_waffle_overrider.__enter__()
self.addCleanup(_waffle_overrider.__exit__, None, None, None)
@ddt.ddt @ddt.ddt
class ScorableCompletionHandlerTestCase(CompletionHandlerMixin, TestCase): class ScorableCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
""" """
Test the signal handler Test the signal handler
""" """
...@@ -89,7 +72,7 @@ class ScorableCompletionHandlerTestCase(CompletionHandlerMixin, TestCase): ...@@ -89,7 +72,7 @@ class ScorableCompletionHandlerTestCase(CompletionHandlerMixin, TestCase):
mock_handler.assert_called() mock_handler.assert_called()
class DisabledCompletionHandlerTestCase(CompletionHandlerMixin, TestCase): class DisabledCompletionHandlerTestCase(CompletionWaffleTestMixin, TestCase):
""" """
Test that disabling the ENABLE_COMPLETION_TRACKING waffle switch prevents Test that disabling the ENABLE_COMPLETION_TRACKING waffle switch prevents
the signal handler from submitting a completion. the signal handler from submitting a completion.
......
"""
Tests of completion xblock runtime services
"""
import ddt
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey, UsageKey
from student.tests.factories import UserFactory
from ..models import BlockCompletion
from ..services import CompletionService
from ..test_utils import CompletionWaffleTestMixin
@ddt.ddt
class CompletionServiceTestCase(CompletionWaffleTestMixin, TestCase):
"""
Test the data returned by the CompletionService.
"""
def setUp(self):
super(CompletionServiceTestCase, self).setUp()
self.override_waffle_switch(True)
self.user = UserFactory.create()
self.other_user = UserFactory.create()
self.course_key = CourseKey.from_string("edX/MOOC101/2049_T2")
self.other_course_key = CourseKey.from_string("course-v1:ReedX+Hum110+1904")
self.block_keys = [UsageKey.from_string("i4x://edX/MOOC101/video/{}".format(number)) for number in xrange(5)]
self.completion_service = CompletionService(self.user, self.course_key)
# Proper completions for the given runtime
for idx, block_key in enumerate(self.block_keys[0:3]):
BlockCompletion.objects.submit_completion(
user=self.user,
course_key=self.course_key,
block_key=block_key,
completion=1.0 - (0.2 * idx),
)
# Wrong user
for idx, block_key in enumerate(self.block_keys[2:]):
BlockCompletion.objects.submit_completion(
user=self.other_user,
course_key=self.course_key,
block_key=block_key,
completion=0.9 - (0.2 * idx),
)
# Wrong course
BlockCompletion.objects.submit_completion(
user=self.user,
course_key=self.other_course_key,
block_key=self.block_keys[4],
completion=0.75,
)
def test_completion_service(self):
# Only the completions for the user and course specified for the CompletionService
# are returned. Values are returned for all keys provided.
self.assertEqual(
self.completion_service.get_completions(self.block_keys),
{
self.block_keys[0]: 1.0,
self.block_keys[1]: 0.8,
self.block_keys[2]: 0.6,
self.block_keys[3]: 0.0,
self.block_keys[4]: 0.0,
},
)
@ddt.data(True, False)
def test_enabled_honors_waffle_switch(self, enabled):
self.override_waffle_switch(enabled)
self.assertEqual(self.completion_service.completion_tracking_enabled(), enabled)
...@@ -10,6 +10,12 @@ from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace ...@@ -10,6 +10,12 @@ from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
WAFFLE_NAMESPACE = 'completion' WAFFLE_NAMESPACE = 'completion'
# Switches # Switches
# Full name: completion.enable_completion_tracking
# Indicates whether or not to track completion of individual blocks. Keeping
# this disabled will prevent creation of BlockCompletion objects in the
# database, as well as preventing completion-related network access by certain
# xblocks.
ENABLE_COMPLETION_TRACKING = 'enable_completion_tracking' ENABLE_COMPLETION_TRACKING = 'enable_completion_tracking'
......
...@@ -8,6 +8,7 @@ from django.core.urlresolvers import reverse ...@@ -8,6 +8,7 @@ from django.core.urlresolvers import reverse
from badges.service import BadgingService from badges.service import BadgingService
from badges.utils import badges_enabled from badges.utils import badges_enabled
from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig from lms.djangoapps.lms_xblock.models import XBlockAsidesConfig
from lms.djangoapps.completion.services import CompletionService
from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api from openedx.core.djangoapps.user_api.course_tag import api as user_course_tag_api
from openedx.core.lib.url_utils import quote_slashes from openedx.core.lib.url_utils import quote_slashes
from openedx.core.lib.xblock_utils import xblock_local_resource_url from openedx.core.lib.xblock_utils import xblock_local_resource_url
...@@ -133,15 +134,17 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method ...@@ -133,15 +134,17 @@ class LmsModuleSystem(ModuleSystem): # pylint: disable=abstract-method
""" """
def __init__(self, **kwargs): def __init__(self, **kwargs):
request_cache_dict = RequestCache.get_request_cache().data request_cache_dict = RequestCache.get_request_cache().data
store = modulestore()
services = kwargs.setdefault('services', {}) services = kwargs.setdefault('services', {})
services['completion'] = CompletionService(user=kwargs.get('user'), course_key=kwargs.get('course_id'))
services['fs'] = xblock.reference.plugins.FSService() services['fs'] = xblock.reference.plugins.FSService()
services['i18n'] = ModuleI18nService services['i18n'] = ModuleI18nService
services['library_tools'] = LibraryToolsService(modulestore()) services['library_tools'] = LibraryToolsService(store)
services['partitions'] = PartitionService( services['partitions'] = PartitionService(
course_id=kwargs.get('course_id'), course_id=kwargs.get('course_id'),
cache=request_cache_dict cache=request_cache_dict
) )
store = modulestore()
services['settings'] = SettingsService() services['settings'] = SettingsService()
services['user_tags'] = UserTagsService(self) services['user_tags'] = UserTagsService(self)
if badges_enabled(): if badges_enabled():
......
...@@ -10,7 +10,11 @@ ...@@ -10,7 +10,11 @@
<div class="vert-mod"> <div class="vert-mod">
% for idx, item in enumerate(items): % for idx, item in enumerate(items):
<div class="vert vert-${idx}" data-id="${item['id']}"> <div class="vert vert-${idx}" data-id="${item['id']}" \
% if item['id'] in watched_completable_blocks:
data-completable-by-viewing \
% endif
>
${HTML(item['content'])} ${HTML(item['content'])}
</div> </div>
% endfor % endfor
......
...@@ -203,7 +203,7 @@ py2neo==3.1.2 ...@@ -203,7 +203,7 @@ py2neo==3.1.2
# Support for plugins # Support for plugins
web-fragments==0.2.2 web-fragments==0.2.2
xblock==1.0.0 XBlock==1.1.1
# Redis version # Redis version
redis==2.10.6 redis==2.10.6
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment