Add complete on view support to render_xblock view.
This commit is contained in:
committed by
Alex Dusenbery
parent
9ef11139d6
commit
8b1aad080a
@@ -10,6 +10,7 @@ from HTMLParser import HTMLParser
|
||||
from urllib import quote, urlencode
|
||||
from uuid import uuid4
|
||||
|
||||
from completion.test_utils import CompletionWaffleTestMixin
|
||||
import ddt
|
||||
from django.conf import settings
|
||||
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 courseware.access_utils import check_course_open_for_learner
|
||||
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.testutils import RenderXBlockTestMixin
|
||||
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.djangolib.testing.utils import get_mock_request
|
||||
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.enterprise_support.tests.mixins.enterprise import EnterpriseTestConsentRequired
|
||||
from student.models import CourseEnrollment
|
||||
@@ -2350,6 +2352,143 @@ class TestIndexView(ModuleStoreTestCase):
|
||||
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)
|
||||
@ddt.ddt
|
||||
class TestIndexViewWithVerticalPositions(ModuleStoreTestCase):
|
||||
@@ -2471,7 +2610,7 @@ class TestIndexViewWithGating(ModuleStoreTestCase, MilestonesTestCaseMixin):
|
||||
|
||||
|
||||
@attr(shard=5)
|
||||
class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
|
||||
class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase, CompletionWaffleTestMixin):
|
||||
"""
|
||||
Tests for the courseware.render_xblock endpoint.
|
||||
This class overrides the get_response method, which is used by
|
||||
@@ -2498,6 +2637,57 @@ class TestRenderXBlock(RenderXBlockTestMixin, ModuleStoreTestCase):
|
||||
url += '?' + url_encoded_params
|
||||
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):
|
||||
"""
|
||||
|
||||
@@ -105,6 +105,11 @@ class RenderXBlockTestMixin(object):
|
||||
category='html',
|
||||
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)
|
||||
|
||||
# block_name_to_be_tested can be `html_block` or `vertical_block`.
|
||||
@@ -150,9 +155,9 @@ class RenderXBlockTestMixin(object):
|
||||
return response
|
||||
|
||||
@ddt.data(
|
||||
('vertical_block', ModuleStoreEnum.Type.mongo, 10),
|
||||
('vertical_block', ModuleStoreEnum.Type.mongo, 11),
|
||||
('vertical_block', ModuleStoreEnum.Type.split, 6),
|
||||
('html_block', ModuleStoreEnum.Type.mongo, 11),
|
||||
('html_block', ModuleStoreEnum.Type.mongo, 12),
|
||||
('html_block', ModuleStoreEnum.Type.split, 6),
|
||||
)
|
||||
@ddt.unpack
|
||||
|
||||
@@ -1472,6 +1472,15 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
|
||||
student_view_context = request.GET.dict()
|
||||
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 = {
|
||||
'fragment': block.render('student_view', context=student_view_context),
|
||||
'course': course,
|
||||
@@ -1480,6 +1489,7 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True):
|
||||
'disable_header': True,
|
||||
'disable_footer': True,
|
||||
'disable_window_wrap': True,
|
||||
'enable_completion_on_view_service': enable_completion_on_view_service,
|
||||
'staff_access': bool(has_access(request.user, 'staff', course)),
|
||||
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
|
||||
}
|
||||
|
||||
@@ -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 lazy import lazy
|
||||
from xblock.core import XBlock, XBlockMixin
|
||||
from xblock.exceptions import JsonHandlerError
|
||||
from xblock.fields import Boolean, Dict, Scope, String
|
||||
from xblock.validation import ValidationMessage
|
||||
|
||||
@@ -44,6 +45,7 @@ class GroupAccessDict(Dict):
|
||||
|
||||
@XBlock.needs('partitions')
|
||||
@XBlock.needs('i18n')
|
||||
@XBlock.wants('completion')
|
||||
class LmsBlockMixin(XBlockMixin):
|
||||
"""
|
||||
Mixin that defines fields common to all blocks used in the LMS
|
||||
@@ -241,3 +243,18 @@ class LmsBlockMixin(XBlockMixin):
|
||||
)
|
||||
|
||||
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'}
|
||||
|
||||
@@ -239,6 +239,10 @@ if RELEASE_LINE == "master":
|
||||
|
||||
WAFFLE_OVERRIDE = True
|
||||
|
||||
############## Settings for Completion API #########################
|
||||
|
||||
COMPLETION_BY_VIEWING_DELAY_MS = 1000
|
||||
|
||||
#####################################################################
|
||||
# Lastly, see if the developer has any local overrides.
|
||||
try:
|
||||
|
||||
42
lms/static/completion/js/CompletionOnViewService.js
Normal file
42
lms/static/completion/js/CompletionOnViewService.js
Normal 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;
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -107,24 +107,24 @@ export class ViewedEventTracker {
|
||||
* * hasBeenViewed (bool): true if all the conditions for being
|
||||
* considered "viewed" have been met.
|
||||
*/
|
||||
constructor(elements, viewedAfterMs) {
|
||||
this.viewedAfterMs = viewedAfterMs;
|
||||
constructor() {
|
||||
this.elementViewings = new Set();
|
||||
this.handlers = [];
|
||||
|
||||
this.interval = undefined;
|
||||
elements.forEach((el) => {
|
||||
this.elementViewings.add(
|
||||
new ElementViewing(
|
||||
el,
|
||||
viewedAfterMs,
|
||||
(element, event) => this.callHandlers(element, event),
|
||||
),
|
||||
);
|
||||
});
|
||||
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. */
|
||||
addHandler(handler) {
|
||||
this.handlers.push(handler);
|
||||
|
||||
@@ -13,7 +13,10 @@ describe('ViewedTracker', () => {
|
||||
|
||||
it('calls the handlers when an element is viewed', () => {
|
||||
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');
|
||||
tracker.addHandler(handlerSpy);
|
||||
const elvIter = tracker.elementViewings.values();
|
||||
|
||||
@@ -14,7 +14,15 @@
|
||||
};
|
||||
|
||||
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() {
|
||||
var error, histg, id;
|
||||
id = $(this).attr('id').replace(/histogram_/, '');
|
||||
|
||||
@@ -70,7 +70,13 @@ ${HTML(fragment.foot_html())}
|
||||
</%block>
|
||||
|
||||
<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">
|
||||
${HTML(fragment.body_html())}
|
||||
</main>
|
||||
|
||||
@@ -8,17 +8,9 @@
|
||||
<%include file='bookmark_button.html' args="bookmark_id=bookmark_id, is_bookmarked=bookmarked"/>
|
||||
% endif
|
||||
|
||||
<div class="vert-mod" \
|
||||
% if completion_delay_ms is not None:
|
||||
data-completion-delay-ms="${completion_delay_ms}" \
|
||||
% endif
|
||||
>
|
||||
<div class="vert-mod">
|
||||
% for idx, item in enumerate(items):
|
||||
<div class="vert vert-${idx}" data-id="${item['id']}" \
|
||||
% if item['id'] in watched_completable_blocks:
|
||||
data-completable-by-viewing=True \
|
||||
% endif
|
||||
>
|
||||
<div class="vert vert-${idx}" data-id="${item['id']}">
|
||||
${HTML(item['content'])}
|
||||
</div>
|
||||
% endfor
|
||||
|
||||
Reference in New Issue
Block a user