Add complete on view support to render_xblock view.

This commit is contained in:
Usman Khalid
2018-05-27 01:26:50 +05:00
committed by Alex Dusenbery
parent 9ef11139d6
commit 8b1aad080a
25 changed files with 556 additions and 189 deletions

View File

@@ -9,7 +9,6 @@
// navigates back within a given sequential) to protect against duplicate calls // navigates back within a given sequential) to protect against duplicate calls
// to the server. // to the server.
var SEEN_COMPLETABLES = new Set(); var SEEN_COMPLETABLES = new Set();
window.VerticalStudentView = function(runtime, element) { window.VerticalStudentView = function(runtime, element) {
@@ -25,46 +24,7 @@ window.VerticalStudentView = function(runtime, element) {
apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl') apiUrl: $bookmarkButtonElement.data('bookmarksApiUrl')
}); });
}); });
RequireJS.require(['bundles/ViewedEvent'], function() { RequireJS.require(['bundles/CompletionOnViewService'], function() {
var tracker, vertical, viewedAfter; markBlocksCompletedOnViewIfNeeded(runtime, element);
var completableBlocks = [];
var vertModDivs = element.getElementsByClassName('vert-mod');
if (vertModDivs.length === 0) {
return;
}
vertical = vertModDivs[0];
$(element).find('.vert').each(function(idx, block) {
if (block.dataset.completableByViewing !== undefined) {
completableBlocks.push(block);
}
});
if (completableBlocks.length > 0) {
viewedAfter = parseInt(vertical.dataset.completionDelayMs, 10);
if (!(viewedAfter >= 0)) {
// parseInt will return NaN if it fails to parse, which is not >= 0.
viewedAfter = 5000;
}
tracker = new ViewedEventTracker(completableBlocks, viewedAfter);
tracker.addHandler(function(block, event) {
var blockKey = block.dataset.id;
if (blockKey && !SEEN_COMPLETABLES.has(blockKey)) {
if (event.elementHasBeenViewed) {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'publish_completion'),
data: JSON.stringify({
block_key: blockKey,
completion: 1.0
})
}).then(
function() {
SEEN_COMPLETABLES.add(blockKey);
}
);
}
}
});
}
}); });
}; };

View File

@@ -59,12 +59,15 @@ class StubCompletionService(object):
""" """
return {candidate: self._completion_value for candidate in candidates} return {candidate: self._completion_value for candidate in candidates}
def get_completion_by_viewing_delay_ms(self): def get_complete_on_view_delay_ms(self):
""" """
Return the completion-by-viewing delay in milliseconds. Return the completion-by-viewing delay in milliseconds.
""" """
return self.delay return self.delay
def blocks_to_mark_complete_on_view(self, blocks):
return {} if self._completion_value == 1.0 else blocks
class BaseVerticalBlockTest(XModuleXmlImportTest): class BaseVerticalBlockTest(XModuleXmlImportTest):
""" """
@@ -136,39 +139,31 @@ class VerticalBlockTestCase(BaseVerticalBlockTest):
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)
self.assert_bookmark_info_in(html) self.assert_bookmark_info_in(html)
self.assertIn(six.text_type(COMPLETION_DELAY), 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.unpack
@ddt.data( @ddt.data(
(True, 0.9, 'assertIn'), (True, 0.9, True),
(False, 0.9, 'assertNotIn'), (False, 0.9, False),
(True, 1.0, 'assertNotIn'), (True, 1.0, False),
) )
def test_completion_data_attrs(self, completion_enabled, completion_value, assertion_method): def test_mark_completed_on_view_after_delay_in_context(
self, completion_enabled, completion_value, mark_completed_enabled
):
""" """
Test that data-completable-by-viewing attributes are included only when Test that mark-completed-on-view-after-delay is only set for relevant child Xblocks.
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): with patch.object(self.html1block, 'render') as mock_student_view:
self.module_system._services['completion'] = StubCompletionService( self.module_system._services['completion'] = StubCompletionService(
enabled=completion_enabled, enabled=completion_enabled,
completion_value=completion_value, completion_value=completion_value,
) )
response = self.module_system.render(self.vertical, STUDENT_VIEW, self.default_context) self.module_system.render(self.vertical, STUDENT_VIEW, self.default_context)
getattr(self, assertion_method)(six.text_type(self.html1block.location), response.content) if (mark_completed_enabled):
getattr(self, assertion_method)(six.text_type(self.html2block.location), response.content) self.assertEqual(
mock_student_view.call_args[0][1]['wrap_xblock_data']['mark-completed-on-view-after-delay'], 9876
)
else:
self.assertNotIn('wrap_xblock_data', mock_student_view.call_args[0][1])
def test_render_studio_view(self): def test_render_studio_view(self):
""" """
@@ -191,14 +186,3 @@ 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})

View File

@@ -8,13 +8,9 @@ from copy import copy
import logging import logging
from lxml import etree from lxml import etree
from opaque_keys.edx.keys import UsageKey
import six import six
from web_fragments.fragment import Fragment from web_fragments.fragment import Fragment
from xblock.completable import XBlockCompletionMode
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xmodule.mako_module import MakoTemplateBlockBase from xmodule.mako_module import MakoTemplateBlockBase
from xmodule.progress import Progress from xmodule.progress import Progress
@@ -31,19 +27,6 @@ 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') @XBlock.wants('completion')
class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock): class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParserMixin, MakoTemplateBlockBase, XBlock):
@@ -60,34 +43,6 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
show_in_read_only_mode = True show_in_read_only_mode = True
def get_completable_by_viewing(self, completion_service):
"""
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.
"""
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 get_completion_delay_ms(self, completion_service):
"""
Do not mark blocks as complete until they have been visible to the user
for the returned amount of time (in milliseconds).
"""
if completion_service is None:
return 0
return completion_service.get_completion_by_viewing_delay_ms()
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.
@@ -107,14 +62,25 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
user_service = self.runtime.service(self, 'user') user_service = self.runtime.service(self, 'user')
child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username'] child_context['username'] = user_service.get_current_user().opt_attrs['edx-platform.username']
child_blocks = self.get_display_items()
child_blocks_to_complete_on_view = set()
completion_service = self.runtime.service(self, 'completion') completion_service = self.runtime.service(self, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
child_blocks_to_complete_on_view = completion_service.blocks_to_mark_complete_on_view(child_blocks)
complete_on_view_delay = completion_service.get_complete_on_view_delay_ms()
child_context['child_of_vertical'] = True child_context['child_of_vertical'] = True
is_child_of_vertical = context.get('child_of_vertical', False) is_child_of_vertical = context.get('child_of_vertical', False)
# pylint: disable=no-member # pylint: disable=no-member
for child in self.get_display_items(): for child in child_blocks:
rendered_child = child.render(STUDENT_VIEW, child_context) child_block_context = copy(child_context)
if child in child_blocks_to_complete_on_view:
child_block_context['wrap_xblock_data'] = {
'mark-completed-on-view-after-delay': complete_on_view_delay
}
rendered_child = child.render(STUDENT_VIEW, child_block_context)
fragment.add_fragment_resources(rendered_child) fragment.add_fragment_resources(rendered_child)
contents.append({ contents.append({
@@ -129,8 +95,6 @@ class VerticalBlock(SequenceFields, XModuleFields, StudioEditableBlock, XmlParse
'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)), # pylint: disable=no-member 'bookmark_id': u"{},{}".format(child_context['username'], unicode(self.location)), # pylint: disable=no-member
'watched_completable_blocks': self.get_completable_by_viewing(completion_service),
'completion_delay_ms': self.get_completion_delay_ms(completion_service),
})) }))
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'))
@@ -231,29 +195,3 @@ 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'}

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
"""
Mixins for completion.
"""
class CompletionOnViewMixin(object):
"""
Methods for testing completion on view.
"""
def xblock_components_mark_completed_on_view_value(self):
"""
Return the xblock components data-mark-completed-on-view-after-delay value.
"""
return self.q(css=self.xblock_component_selector).attrs('data-mark-completed-on-view-after-delay')
def wait_for_xblock_component_to_be_marked_completed_on_view(self, index=0):
"""
Wait for xblock component to be marked completed on view.
Arguments
index (int): index of block to wait on. (default is 0)
"""
self.wait_for(lambda: (self.xblock_components_mark_completed_on_view_value()[index] == '0'),
'Waiting for xblock to be marked completed on view.')

View File

@@ -8,11 +8,13 @@ from bok_choy.page_object import PageObject, unguarded
from bok_choy.promise import EmptyPromise from bok_choy.promise import EmptyPromise
from selenium.webdriver.common.action_chains import ActionChains from selenium.webdriver.common.action_chains import ActionChains
from common.test.acceptance.pages.lms import BASE_URL
from common.test.acceptance.pages.lms.bookmarks import BookmarksPage from common.test.acceptance.pages.lms.bookmarks import BookmarksPage
from common.test.acceptance.pages.lms.completion import CompletionOnViewMixin
from common.test.acceptance.pages.lms.course_page import CoursePage from common.test.acceptance.pages.lms.course_page import CoursePage
class CoursewarePage(CoursePage): class CoursewarePage(CoursePage, CompletionOnViewMixin):
""" """
Course info. Course info.
""" """
@@ -587,3 +589,25 @@ class CourseNavPage(PageObject):
# reload the same page with the course_outline_page flag # reload the same page with the course_outline_page flag
self.browser.get(self.browser.current_url + "&course_experience.course_outline_page=1") self.browser.get(self.browser.current_url + "&course_experience.course_outline_page=1")
self.wait_for_page() self.wait_for_page()
class RenderXBlockPage(PageObject, CompletionOnViewMixin):
"""
render_xblock page.
"""
xblock_component_selector = '.xblock'
def __init__(self, browser, block_id):
super(RenderXBlockPage, self).__init__(browser)
self.block_id = block_id
@property
def url(self):
"""
Construct a URL to the page within the course.
"""
return BASE_URL + "/xblock/" + self.block_id
def is_browser_on_page(self):
return self.q(css='.course-content').present

View File

@@ -13,7 +13,7 @@ from ...fixtures.course import CourseFixture, XBlockFixtureDesc
from ...pages.common.auto_auth import AutoAuthPage from ...pages.common.auto_auth import AutoAuthPage
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
from ...pages.lms.course_home import CourseHomePage from ...pages.lms.course_home import CourseHomePage
from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage from ...pages.lms.courseware import CoursewarePage, CoursewareSequentialTabPage, RenderXBlockPage
from ...pages.lms.create_mode import ModeCreationPage from ...pages.lms.create_mode import ModeCreationPage
from ...pages.lms.dashboard import DashboardPage from ...pages.lms.dashboard import DashboardPage
from ...pages.lms.pay_and_verify import FakePaymentPage, FakeSoftwareSecureVerificationPage, PaymentAndVerificationFlow from ...pages.lms.pay_and_verify import FakePaymentPage, FakeSoftwareSecureVerificationPage, PaymentAndVerificationFlow
@@ -847,3 +847,129 @@ class SubsectionHiddenAfterDueDateTest(UniqueCourseTest):
self.progress_page.visit() self.progress_page.visit()
self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)]) self.assertEqual(self.progress_page.scores('Test Section 1', 'Test Subsection 1'), [(0, 1)])
@attr(shard=9)
class CompletionTestCase(UniqueCourseTest, EventsTestMixin):
"""
Test the completion on view functionality.
"""
USERNAME = "STUDENT_TESTER"
EMAIL = "student101@example.com"
COMPLETION_BY_VIEWING_DELAY_MS = '1000'
def setUp(self):
super(CompletionTestCase, self).setUp()
self.studio_course_outline = StudioCourseOutlinePage(
self.browser,
self.course_info['org'],
self.course_info['number'],
self.course_info['run']
)
# Install a course with sections/problems, tabs, updates, and handouts
course_fix = CourseFixture(
self.course_info['org'], self.course_info['number'],
self.course_info['run'], self.course_info['display_name']
)
self.html_1_block = XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>")
self.problem_1_block = XBlockFixtureDesc(
'problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'
)
course_fix.add_children(
XBlockFixtureDesc('chapter', 'Test Section 1').add_children(
XBlockFixtureDesc('sequential', 'Test Subsection 1,1').add_children(
XBlockFixtureDesc('vertical', 'Test Unit 1,1,1').add_children(
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
XBlockFixtureDesc(
'html', 'html 2',
data=("<html>html 2 dummy body</html>" * 100) + "<span id='html2-end'>End</span>",
),
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
),
XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
XBlockFixtureDesc('html', 'html 1', data="<html>html 1 dummy body</html>"),
XBlockFixtureDesc('problem', 'Test Problem 1', data='<problem>problem 1 dummy body</problem>'),
),
XBlockFixtureDesc('vertical', 'Test Unit 1,1,2').add_children(
self.html_1_block,
self.problem_1_block,
),
),
),
).install()
# Auto-auth register for the course.
AutoAuthPage(self.browser, username=self.USERNAME, email=self.EMAIL,
course_id=self.course_id, staff=False).visit()
def test_courseware_publish_completion_is_sent_on_view(self):
"""
Test that when viewing courseware XBlocks are correctly marked as completed on view.
"""
courseware_page = CoursewarePage(self.browser, self.course_id)
courseware_page.visit()
courseware_page.wait_for_page()
# Initially, the first two blocks in the first vertical should be marked as needing to be completed on view.
self.assertEqual(
courseware_page.xblock_components_mark_completed_on_view_value(),
[self.COMPLETION_BY_VIEWING_DELAY_MS, self.COMPLETION_BY_VIEWING_DELAY_MS, None],
)
# Wait and verify that the first block which is completely visible is marked as completed.
courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
self.assertEqual(
courseware_page.xblock_components_mark_completed_on_view_value(),
['0', self.COMPLETION_BY_VIEWING_DELAY_MS, None],
)
# Scroll to the bottom of the second block.
courseware_page.scroll_to_element('#html2-end', 'Scroll to end of html 2 block')
# Wait and verify that the second block is also now marked as completed.
courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(1)
self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', '0', None])
# After page refresh, no blocks in the vertical should be marked as needing to be completed on view.
self.browser.refresh()
courseware_page.wait_for_page()
self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None, None])
courseware_page.go_to_sequential_position(2)
# Initially, the first block in the second vertical should be marked as needing to be completed on view.
self.assertEqual(
courseware_page.xblock_components_mark_completed_on_view_value(),
[self.COMPLETION_BY_VIEWING_DELAY_MS, None],
)
# Wait and verify that the first block which is completely visible is marked as completed.
courseware_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), ['0', None])
# After page refresh, no blocks in the vertical should be marked as needing to be completed on view.
self.browser.refresh()
courseware_page.wait_for_page()
self.assertEqual(courseware_page.xblock_components_mark_completed_on_view_value(), [None, None])
def test_render_xblock_publish_completion_is_sent_on_view(self):
"""
Test that when viewing a XBlock in render_xblock, it is correctly marked as completed on view.
"""
block_page = RenderXBlockPage(self.browser, self.html_1_block.locator)
block_page.visit()
block_page.wait_for_page()
# Initially the block should be marked as needing to be completed on view.
self.assertEqual(
block_page.xblock_components_mark_completed_on_view_value(), [self.COMPLETION_BY_VIEWING_DELAY_MS]
)
# Wait and verify that the block is marked as completed on view.
block_page.wait_for_xblock_component_to_be_marked_completed_on_view(0)
self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), ['0'])
# After page refresh, it should not be marked as needing to be completed on view.
self.browser.refresh()
block_page.wait_for_page()
self.assertEqual(block_page.xblock_components_mark_completed_on_view_value(), [None])

View File

@@ -23,6 +23,14 @@
"active": true "active": true
} }
}, },
{
"pk": 2,
"model": "waffle.switch",
"fields": {
"name": "completion.enable_completion_tracking",
"active": true
}
},
{ {
"pk": 3, "pk": 3,
"model": "waffle.flag", "model": "waffle.flag",

View File

@@ -10,6 +10,7 @@ from HTMLParser import HTMLParser
from urllib import quote, urlencode from urllib import quote, urlencode
from uuid import uuid4 from uuid import uuid4
from completion.test_utils import CompletionWaffleTestMixin
import ddt import ddt
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
@@ -37,7 +38,7 @@ from course_modes.models import CourseMode
from course_modes.tests.factories import CourseModeFactory from course_modes.tests.factories import CourseModeFactory
from courseware.access_utils import check_course_open_for_learner from courseware.access_utils import check_course_open_for_learner
from courseware.model_data import FieldDataCache, set_score from courseware.model_data import FieldDataCache, set_score
from courseware.module_render import get_module from courseware.module_render import get_module, handle_xblock_callback
from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory from courseware.tests.factories import GlobalStaffFactory, StudentModuleFactory
from courseware.testutils import RenderXBlockTestMixin from courseware.testutils import RenderXBlockTestMixin
from courseware.url_helpers import get_redirect_url from courseware.url_helpers import get_redirect_url
@@ -61,6 +62,7 @@ from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNam
from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag from openedx.core.djangoapps.waffle_utils.testutils import WAFFLE_TABLES, override_waffle_flag
from openedx.core.djangolib.testing.utils import get_mock_request from openedx.core.djangolib.testing.utils import get_mock_request
from openedx.core.lib.gating import api as gating_api from openedx.core.lib.gating import api as gating_api
from openedx.core.lib.url_utils import quote_slashes
from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, UNIFIED_COURSE_TAB_FLAG from openedx.features.course_experience import COURSE_OUTLINE_PAGE_FLAG, UNIFIED_COURSE_TAB_FLAG
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
from student.models import CourseEnrollment from student.models import CourseEnrollment
@@ -2350,6 +2352,143 @@ class TestIndexView(ModuleStoreTestCase):
assert response.status_code == 200 assert response.status_code == 200
@attr(shard=5)
@ddt.ddt
class TestIndexViewCompleteOnView(ModuleStoreTestCase, CompletionWaffleTestMixin):
"""
Tests CompleteOnView is set up correctly in CoursewareIndex.
"""
def setup_course(self, default_store):
"""
Set up course content for modulestore.
"""
# pylint:disable=attribute-defined-outside-init
self.request_factory = RequestFactory()
self.user = UserFactory()
with modulestore().default_store(default_store):
self.course = CourseFactory.create()
with self.store.bulk_operations(self.course.id):
self.chapter = ItemFactory.create(
parent_location=self.course.location, category='chapter', display_name='Week 1'
)
self.section_1 = ItemFactory.create(
parent_location=self.chapter.location, category='sequential', display_name='Lesson 1'
)
self.vertical_1 = ItemFactory.create(
parent_location=self.section_1.location, category='vertical', display_name='Subsection 1'
)
self.html_1_1 = ItemFactory.create(
parent_location=self.vertical_1.location, category='html', display_name="HTML 1_1"
)
self.problem_1 = ItemFactory.create(
parent_location=self.vertical_1.location, category='problem', display_name="Problem 1"
)
self.html_1_2 = ItemFactory.create(
parent_location=self.vertical_1.location, category='html', display_name="HTML 1_2"
)
self.section_2 = ItemFactory.create(
parent_location=self.chapter.location, category='sequential', display_name='Lesson 2'
)
self.vertical_2 = ItemFactory.create(
parent_location=self.section_2.location, category='vertical', display_name='Subsection 2'
)
self.video_2 = ItemFactory.create(
parent_location=self.vertical_2.location, category='video', display_name="Video 2"
)
self.problem_2 = ItemFactory.create(
parent_location=self.vertical_2.location, category='problem', display_name="Problem 2"
)
self.section_1_url = reverse(
'courseware_section',
kwargs={
'course_id': unicode(self.course.id),
'chapter': self.chapter.url_name,
'section': self.section_1.url_name,
}
)
self.section_2_url = reverse(
'courseware_section',
kwargs={
'course_id': unicode(self.course.id),
'chapter': self.chapter.url_name,
'section': self.section_2.url_name,
}
)
CourseOverview.load_from_module_store(self.course.id)
CourseEnrollmentFactory(user=self.user, course_id=self.course.id)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_completion_service_disabled(self, default_store):
self.setup_course(default_store)
self.assertTrue(self.client.login(username=self.user.username, password='test'))
response = self.client.get(self.section_1_url)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
response = self.client.get(self.section_2_url)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
@ddt.data(ModuleStoreEnum.Type.mongo, ModuleStoreEnum.Type.split)
def test_completion_service_enabled(self, default_store):
self.override_waffle_switch(True)
self.setup_course(default_store)
self.assertTrue(self.client.login(username=self.user.username, password='test'))
response = self.client.get(self.section_1_url)
self.assertIn('data-mark-completed-on-view-after-delay', response.content)
self.assertEquals(response.content.count("data-mark-completed-on-view-after-delay"), 2)
request = self.request_factory.post(
'/',
data=json.dumps({"completion": 1}),
content_type='application/json',
)
request.user = self.user
response = handle_xblock_callback(
request,
unicode(self.course.id),
quote_slashes(unicode(self.html_1_1.scope_ids.usage_id)),
'publish_completion',
)
self.assertEqual(json.loads(response.content), {'result': "ok"})
response = self.client.get(self.section_1_url)
self.assertIn('data-mark-completed-on-view-after-delay', response.content)
self.assertEquals(response.content.count("data-mark-completed-on-view-after-delay"), 1)
request = self.request_factory.post(
'/',
data=json.dumps({"completion": 1}),
content_type='application/json',
)
request.user = self.user
response = handle_xblock_callback(
request,
unicode(self.course.id),
quote_slashes(unicode(self.html_1_2.scope_ids.usage_id)),
'publish_completion',
)
self.assertEqual(json.loads(response.content), {'result': "ok"})
response = self.client.get(self.section_1_url)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
response = self.client.get(self.section_2_url)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
@attr(shard=5) @attr(shard=5)
@ddt.ddt @ddt.ddt
class TestIndexViewWithVerticalPositions(ModuleStoreTestCase): class TestIndexViewWithVerticalPositions(ModuleStoreTestCase):
@@ -2471,7 +2610,7 @@ class TestIndexViewWithGating(ModuleStoreTestCase, MilestonesTestCaseMixin):
@attr(shard=5) @attr(shard=5)
class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase): class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin):
""" """
Tests for the courseware.render_xblock endpoint. Tests for the courseware.render_xblock endpoint.
This class overrides the get_response method, which is used by This class overrides the get_response method, which is used by
@@ -2498,6 +2637,57 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
url += '?' + url_encoded_params url += '?' + url_encoded_params
return self.client.get(url) return self.client.get(url)
def test_render_xblock_with_completion_service_disabled(self):
"""
Test that render_xblock does not set up the CompletionOnViewService.
"""
self.setup_course(ModuleStoreEnum.Type.split)
self.setup_user(admin=True, enroll=True, login=True)
response = self.get_response(usage_key=self.html_block.location)
self.assertEqual(response.status_code, 200)
self.assertIn('data-enable-completion-on-view-service="false"', response.content)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
def test_render_xblock_with_completion_service_enabled(self):
"""
Test that render_xblock sets up the CompletionOnViewService for relevant xblocks.
"""
self.override_waffle_switch(True)
self.setup_course(ModuleStoreEnum.Type.split)
self.setup_user(admin=False, enroll=True, login=True)
response = self.get_response(usage_key=self.html_block.location)
self.assertEqual(response.status_code, 200)
self.assertIn('data-enable-completion-on-view-service="true"', response.content)
self.assertIn('data-mark-completed-on-view-after-delay', response.content)
request = RequestFactory().post(
'/',
data=json.dumps({"completion": 1}),
content_type='application/json',
)
request.user = self.user
response = handle_xblock_callback(
request,
unicode(self.course.id),
quote_slashes(unicode(self.html_block.location)),
'publish_completion',
)
self.assertEqual(response.status_code, 200)
self.assertEqual(json.loads(response.content), {'result': "ok"})
response = self.get_response(usage_key=self.html_block.location)
self.assertEqual(response.status_code, 200)
self.assertIn('data-enable-completion-on-view-service="false"', response.content)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
response = self.get_response(usage_key=self.problem_block.location)
self.assertEqual(response.status_code, 200)
self.assertIn('data-enable-completion-on-view-service="false"', response.content)
self.assertNotIn('data-mark-completed-on-view-after-delay', response.content)
class TestRenderXBlockSelfPaced(TestRenderXBlock): class TestRenderXBlockSelfPaced(TestRenderXBlock):
""" """

View File

@@ -105,6 +105,11 @@ class RenderXBlockTestMixin(object):
category='html', category='html',
data="<p>Test HTML Content<p>" data="<p>Test HTML Content<p>"
) )
self.problem_block = ItemFactory.create(
parent=self.vertical_block,
category='problem',
display_name='Problem'
)
CourseOverview.load_from_module_store(self.course.id) CourseOverview.load_from_module_store(self.course.id)
# block_name_to_be_tested can be `html_block` or `vertical_block`. # block_name_to_be_tested can be `html_block` or `vertical_block`.
@@ -150,9 +155,9 @@ class RenderXBlockTestMixin(object):
return response return response
@ddt.data( @ddt.data(
('vertical_block', ModuleStoreEnum.Type.mongo, 10), ('vertical_block', ModuleStoreEnum.Type.mongo, 11),
('vertical_block', ModuleStoreEnum.Type.split, 6), ('vertical_block', ModuleStoreEnum.Type.split, 6),
('html_block', ModuleStoreEnum.Type.mongo, 11), ('html_block', ModuleStoreEnum.Type.mongo, 12),
('html_block', ModuleStoreEnum.Type.split, 6), ('html_block', ModuleStoreEnum.Type.split, 6),
) )
@ddt.unpack @ddt.unpack

View File

@@ -1472,6 +1472,15 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
student_view_context = request.GET.dict() student_view_context = request.GET.dict()
student_view_context['show_bookmark_button'] = False student_view_context['show_bookmark_button'] = False
enable_completion_on_view_service = False
completion_service = block.runtime.service(block, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
if completion_service.blocks_to_mark_complete_on_view({block}):
enable_completion_on_view_service = True
student_view_context['wrap_xblock_data'] = {
'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
}
context = { context = {
'fragment': block.render('student_view', context=student_view_context), 'fragment': block.render('student_view', context=student_view_context),
'course': course, 'course': course,
@@ -1480,6 +1489,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
'disable_header': True, 'disable_header': True,
'disable_footer': True, 'disable_footer': True,
'disable_window_wrap': True, 'disable_window_wrap': True,
'enable_completion_on_view_service': enable_completion_on_view_service,
'staff_access': bool(has_access(request.user, 'staff', course)), 'staff_access': bool(has_access(request.user, 'staff', course)),
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'), 'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
} }

View File

@@ -5,6 +5,7 @@ Namespace that defines fields common to all blocks used in the LMS
#from django.utils.translation import ugettext_noop as _ #from django.utils.translation import ugettext_noop as _
from lazy import lazy from lazy import lazy
from xblock.core import XBlock, XBlockMixin from xblock.core import XBlock, XBlockMixin
from xblock.exceptions import JsonHandlerError
from xblock.fields import Boolean, Dict, Scope, String from xblock.fields import Boolean, Dict, Scope, String
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
@@ -44,6 +45,7 @@ class GroupAccessDict(Dict):
@XBlock.needs('partitions') @XBlock.needs('partitions')
@XBlock.needs('i18n') @XBlock.needs('i18n')
@XBlock.wants('completion')
class LmsBlockMixin(XBlockMixin): class LmsBlockMixin(XBlockMixin):
""" """
Mixin that defines fields common to all blocks used in the LMS Mixin that defines fields common to all blocks used in the LMS
@@ -241,3 +243,18 @@ class LmsBlockMixin(XBlockMixin):
) )
return validation return validation
@XBlock.json_handler
def publish_completion(self, data, suffix=''): # pylint: disable=unused-argument
"""
Publish completion data from the front end.
"""
completion_service = self.runtime.service(self, 'completion')
if completion_service is None:
raise JsonHandlerError(500, u"No completion service found")
elif not completion_service.completion_tracking_enabled():
raise JsonHandlerError(404, u"Completion tracking is not enabled and API calls are unexpected")
if not completion_service.can_mark_block_complete_on_view(self):
raise JsonHandlerError(400, u"Block not configured for completion on view.")
self.runtime.publish(self, "completion", data)
return {'result': 'ok'}

View File

@@ -239,6 +239,10 @@ if RELEASE_LINE == "master":
WAFFLE_OVERRIDE = True WAFFLE_OVERRIDE = True
############## Settings for Completion API #########################
COMPLETION_BY_VIEWING_DELAY_MS = 1000
##################################################################### #####################################################################
# Lastly, see if the developer has any local overrides. # Lastly, see if the developer has any local overrides.
try: try:

View File

@@ -0,0 +1,42 @@
import { ViewedEventTracker } from './ViewedEvent';
const completedBlocksKeys = new Set();
export function markBlocksCompletedOnViewIfNeeded(runtime, containerElement) {
const blockElements = $(containerElement).find(
'.xblock-student_view[data-mark-completed-on-view-after-delay]',
).get();
if (blockElements.length > 0) {
const tracker = new ViewedEventTracker();
blockElements.forEach((blockElement) => {
const markCompletedOnViewAfterDelay = parseInt(
blockElement.dataset.markCompletedOnViewAfterDelay, 10,
);
if (markCompletedOnViewAfterDelay >= 0) {
tracker.addElement(blockElement, markCompletedOnViewAfterDelay);
}
});
tracker.addHandler((blockElement, event) => {
const blockKey = blockElement.dataset.usageId;
if (blockKey && !completedBlocksKeys.has(blockKey)) {
if (event.elementHasBeenViewed) {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(blockElement, 'publish_completion'),
data: JSON.stringify({
completion: 1.0,
}),
}).then(
() => {
completedBlocksKeys.add(blockKey);
blockElement.dataset.markCompletedOnViewAfterDelay = 0;
},
);
}
}
});
}
}

View File

@@ -107,24 +107,24 @@ export class ViewedEventTracker {
* * hasBeenViewed (bool): true if all the conditions for being * * hasBeenViewed (bool): true if all the conditions for being
* considered "viewed" have been met. * considered "viewed" have been met.
*/ */
constructor(elements, viewedAfterMs) { constructor() {
this.viewedAfterMs = viewedAfterMs;
this.elementViewings = new Set(); this.elementViewings = new Set();
this.handlers = []; this.handlers = [];
this.interval = undefined;
elements.forEach((el) => {
this.elementViewings.add(
new ElementViewing(
el,
viewedAfterMs,
(element, event) => this.callHandlers(element, event),
),
);
});
this.registerDomHandlers(); this.registerDomHandlers();
} }
/** Add an element to track. */
addElement(element, viewedAfterMs) {
this.elementViewings.add(
new ElementViewing(
element,
viewedAfterMs,
(el, event) => this.callHandlers(el, event),
),
);
this.updateVisible();
}
/** Register a new handler to be called when an element has been viewed. */ /** Register a new handler to be called when an element has been viewed. */
addHandler(handler) { addHandler(handler) {
this.handlers.push(handler); this.handlers.push(handler);

View File

@@ -13,7 +13,10 @@ describe('ViewedTracker', () => {
it('calls the handlers when an element is viewed', () => { it('calls the handlers when an element is viewed', () => {
document.body.innerHTML = '<div id="d1"></div><div id="d2"></div><div id="d3"></div>'; document.body.innerHTML = '<div id="d1"></div><div id="d2"></div><div id="d3"></div>';
const tracker = new ViewedEventTracker(Array.from(document.getElementsByTagName('div')), 1000); const tracker = new ViewedEventTracker();
for (const element of document.getElementsByTagName('div')) {
tracker.addElement(element, 1000);
}
const handlerSpy = jasmine.createSpy('handlerSpy'); const handlerSpy = jasmine.createSpy('handlerSpy');
tracker.addHandler(handlerSpy); tracker.addHandler(handlerSpy);
const elvIter = tracker.elementViewings.values(); const elvIter = tracker.elementViewings.values();

View File

@@ -14,7 +14,15 @@
}; };
Courseware.prototype.render = function() { Courseware.prototype.render = function() {
XBlock.initializeBlocks($('.course-content')); var courseContentElement = $('.course-content')[0];
var blocks = XBlock.initializeBlocks(courseContentElement);
if (courseContentElement.dataset.enableCompletionOnViewService === 'true') {
RequireJS.require(['bundles/CompletionOnViewService'], function() {
markBlocksCompletedOnViewIfNeeded(blocks[0].runtime, courseContentElement);
});
}
return $('.course-content .histogram').each(function() { return $('.course-content .histogram').each(function() {
var error, histg, id; var error, histg, id;
id = $(this).attr('id').replace(/histogram_/, ''); id = $(this).attr('id').replace(/histogram_/, '');

View File

@@ -70,7 +70,13 @@ ${HTML(fragment.foot_html())}
</%block> </%block>
<div class="course-wrapper chromeless"> <div class="course-wrapper chromeless">
<section class="course-content" id="course-content"> <section class="course-content" id="course-content"\
% if enable_completion_on_view_service:
data-enable-completion-on-view-service="true" \
% else:
data-enable-completion-on-view-service="false" \
% endif
>
<main id="main" tabindex="-1" aria-label="Content"> <main id="main" tabindex="-1" aria-label="Content">
${HTML(fragment.body_html())} ${HTML(fragment.body_html())}
</main> </main>

View File

@@ -8,17 +8,9 @@
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/> <%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
% endif % endif
<div class="vert-mod" \ <div class="vert-mod">
% if completion_delay_ms is not None:
data-completion-delay-ms="${completion_delay_ms}" \
% endif
>
% 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=True \
% endif
>
${HTML(item['content'])} ${HTML(item['content'])}
</div> </div>
% endfor % endfor

View File

@@ -100,7 +100,7 @@ class TestXblockUtils(SharedModuleStoreTestCase):
block=course, block=course,
view='baseview', view='baseview',
frag=fragment, frag=fragment,
context=None, context={"wrap_xblock_data": {"custom-attribute": "custom-value"}},
usage_id_serializer=lambda usage_id: quote_slashes(unicode(usage_id)), usage_id_serializer=lambda usage_id: quote_slashes(unicode(usage_id)),
request_token=uuid.uuid1().get_hex() request_token=uuid.uuid1().get_hex()
) )
@@ -109,6 +109,7 @@ class TestXblockUtils(SharedModuleStoreTestCase):
self.assertIn('data-runtime-class="TestRuntime"', test_wrap_output.content) self.assertIn('data-runtime-class="TestRuntime"', test_wrap_output.content)
self.assertIn(data_usage_id, test_wrap_output.content) self.assertIn(data_usage_id, test_wrap_output.content)
self.assertIn('<h1>Test!</h1>', test_wrap_output.content) self.assertIn('<h1>Test!</h1>', test_wrap_output.content)
self.assertIn('data-custom-attribute="custom-value"', test_wrap_output.content)
self.assertEqual(test_wrap_output.resources[0].data, u'body {background-color:red;}') self.assertEqual(test_wrap_output.resources[0].data, u'body {background-color:red;}')
self.assertEqual(test_wrap_output.resources[1].data, 'alert("Hi!");') self.assertEqual(test_wrap_output.resources[1].data, 'alert("Hi!");')

View File

@@ -92,6 +92,9 @@ def wrap_xblock(
data = {} data = {}
data.update(extra_data) data.update(extra_data)
if context:
data.update(context.get('wrap_xblock_data', {}))
css_classes = [ css_classes = [
'xblock', 'xblock',
'xblock-{}'.format(markupsafe.escape(view)), 'xblock-{}'.format(markupsafe.escape(view)),

View File

@@ -36,6 +36,10 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
parent=cls.sequence, parent=cls.sequence,
category='vertical', category='vertical',
) )
cls.html = ItemFactory.create(
parent=cls.vertical,
category='html',
)
cls.problem = ItemFactory.create( cls.problem = ItemFactory.create(
parent=cls.vertical, parent=cls.vertical,
category="problem", category="problem",
@@ -70,6 +74,13 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
self.completion_service = CompletionService(self.user, self.course_key) self.completion_service = CompletionService(self.user, self.course_key)
# Proper completions for the given runtime # Proper completions for the given runtime
BlockCompletion.objects.submit_completion(
user=self.user,
course_key=self.course_key,
block_key=self.html.location,
completion=1.0,
)
for idx, block_key in enumerate(self.block_keys[0:3]): for idx, block_key in enumerate(self.block_keys[0:3]):
BlockCompletion.objects.submit_completion( BlockCompletion.objects.submit_completion(
user=self.user, user=self.user,
@@ -148,3 +159,12 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTest
self.completion_service.vertical_is_complete(self.vertical), self.completion_service.vertical_is_complete(self.vertical),
False, False,
) )
def test_can_mark_block_complete_on_view(self):
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.course), False)
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.chapter), False)
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.sequence), False)
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.vertical), False)
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.html), True)
self.assertEqual(self.completion_service.can_mark_block_complete_on_view(self.problem), False)

View File

@@ -111,7 +111,7 @@ edx-ace==0.1.8
edx-analytics-data-api-client==0.14.4 edx-analytics-data-api-client==0.14.4
edx-ccx-keys==0.2.1 edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7 edx-celeryutils==0.2.7
edx-completion==0.1.6 edx-completion==0.1.7
edx-django-oauth2-provider==1.2.5 edx-django-oauth2-provider==1.2.5
edx-django-release-util==0.3.1 edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1 edx-django-sites-extensions==2.3.1

View File

@@ -131,7 +131,7 @@ edx-ace==0.1.8
edx-analytics-data-api-client==0.14.4 edx-analytics-data-api-client==0.14.4
edx-ccx-keys==0.2.1 edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7 edx-celeryutils==0.2.7
edx-completion==0.1.6 edx-completion==0.1.7
edx-django-oauth2-provider==1.2.5 edx-django-oauth2-provider==1.2.5
edx-django-release-util==0.3.1 edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1 edx-django-sites-extensions==2.3.1

View File

@@ -126,7 +126,7 @@ edx-ace==0.1.8
edx-analytics-data-api-client==0.14.4 edx-analytics-data-api-client==0.14.4
edx-ccx-keys==0.2.1 edx-ccx-keys==0.2.1
edx-celeryutils==0.2.7 edx-celeryutils==0.2.7
edx-completion==0.1.6 edx-completion==0.1.7
edx-django-oauth2-provider==1.2.5 edx-django-oauth2-provider==1.2.5
edx-django-release-util==0.3.1 edx-django-release-util==0.3.1
edx-django-sites-extensions==2.3.1 edx-django-sites-extensions==2.3.1

View File

@@ -40,7 +40,7 @@ module.exports = {
ProgramDetailsFactory: './lms/static/js/learner_dashboard/program_details_factory.js', ProgramDetailsFactory: './lms/static/js/learner_dashboard/program_details_factory.js',
ProgramListFactory: './lms/static/js/learner_dashboard/program_list_factory.js', ProgramListFactory: './lms/static/js/learner_dashboard/program_list_factory.js',
UnenrollmentFactory: './lms/static/js/learner_dashboard/unenrollment_factory.js', UnenrollmentFactory: './lms/static/js/learner_dashboard/unenrollment_factory.js',
ViewedEvent: './lms/static/completion/js/ViewedEvent.js', CompletionOnViewService: './lms/static/completion/js/CompletionOnViewService.js',
// Features // Features
CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js', CourseGoals: './openedx/features/course_experience/static/course_experience/js/CourseGoals.js',