Merge pull request #17338 from edx/sstudent/EDUCATOR-2189

progress indicators on courseware ribbon
This commit is contained in:
Simon Chen
2018-02-12 16:37:09 -05:00
committed by GitHub
11 changed files with 279 additions and 16 deletions

View File

@@ -120,7 +120,7 @@ $seq-nav-height: 50px;
overflow: visible; // for tooltip - IE11 uses 'hidden' by default if width/height is specified
.icon {
display: block;
display: inline-block;
line-height: 100%; // This matches the height of the <a> its within (the parent) to get vertical centering.
font-size: 110%;
color: $seq-nav-icon-color-muted;

View File

@@ -15,6 +15,7 @@
<button role="tab" tabindex="0" aria-selected="true" aria-expanded="true" aria-controls="seq_content" class="seq_problem nav-item tab active" data-index="0" data-id="block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9050" data-element="1" data-page-title="Unit 101" data-path="Example Week 2: Get Interactive > Homework - Part 1 > Unit 101" id="tab_0">
<span class="icon fa seq_problem" aria-hidden="true"></span>
<span class="fa fa-fw fa-bookmark bookmark-icon is-hidden" aria-hidden="true"></span>
<span class="fa fa-check-circle check-circle is-hidden" style="color:green" aria-hidden="true">
<div class="sequence-tooltip sr"><span class="sr">problem</span>Unit 101<span class="sr bookmark-icon-sr"></span></div>
</button>
</li>
@@ -22,6 +23,7 @@
<button role="tab" tabindex="-1" aria-selected="true" aria-expanded="true" aria-controls="seq_content" class="seq_problem inactive nav-item tab" data-index="1" data-id="block-v1:edX+DemoX+Demo_Course+type@vertical+block@fb79dcbad35b466a8c6364f8ffee9051" data-element="2" data-page-title="Unit 102" data-path="Example Week 2: Get Interactive > Homework - Part 1 > Unit 102" id="tab_1">
<span class="icon fa seq_problem" aria-hidden="true"></span>
<span class="fa fa-fw fa-bookmark bookmark-icon is-hidden" aria-hidden="true"></span>
<span class="fa fa-check-circle check-circle is-hidden" style="color:green" aria-hidden="true">
<div class="sequence-tooltip sr"><span class="sr">problem</span>Unit 102<span class="sr bookmark-icon-sr"></span></div>
</button>
</li>

View File

@@ -14,6 +14,7 @@
beforeEach(function() {
loadFixtures('sequence.html');
local.XBlock = window.XBlock = jasmine.createSpyObj('XBlock', ['initializeBlocks']);
this.sequence = new Sequence($('.xblock-student_view-sequential'));
});
afterEach(function() {
@@ -29,7 +30,6 @@
describe('Navbar', function() {
it('works with keyboard navigation LEFT and ENTER', function() {
this.sequence = new Sequence($('.xblock-student_view-sequential'));
this.sequence.$('.nav-item[data-index=0]').focus();
keydownHandler(keys.LEFT);
keydownHandler(keys.ENTER);
@@ -47,7 +47,6 @@
});
it('works with keyboard navigation RIGHT and ENTER', function() {
this.sequence = new Sequence($('.xblock-student_view-sequential'));
this.sequence.$('.nav-item[data-index=0]').focus();
keydownHandler(keys.RIGHT);
keydownHandler(keys.ENTER);
@@ -63,6 +62,64 @@
tabindex: '0'
});
});
it('Completion Indicator missing', function() {
this.sequence.$('.nav-item[data-index=0]').children('.check-circle').remove();
spyOn($, 'postWithPrefix').and.callFake(function(url, data, callback) {
callback({
complete: true
});
});
this.sequence.update_completion(1);
expect($.postWithPrefix).not.toHaveBeenCalled();
});
describe('Completion', function() {
beforeEach(function() {
expect(
this.sequence.$('.nav-item[data-index=0]').children('.check-circle').first()
.hasClass('is-hidden')
).toBe(true);
expect(
this.sequence.$('.nav-item[data-index=1]').children('.check-circle').first()
.hasClass('is-hidden')
).toBe(true);
});
afterEach(function() {
expect($.postWithPrefix).toHaveBeenCalled();
expect(
this.sequence.$('.nav-item[data-index=1]').children('.check-circle').first()
.hasClass('is-hidden')
).toBe(true);
});
it('API check returned true', function() {
spyOn($, 'postWithPrefix').and.callFake(function(url, data, callback) {
callback({
complete: true
});
});
this.sequence.update_completion(1);
expect(
this.sequence.$('.nav-item[data-index=0]').children('.check-circle').first()
.hasClass('is-hidden')
).toBe(false);
});
it('API check returned false', function() {
spyOn($, 'postWithPrefix').and.callFake(function(url, data, callback) {
callback({
complete: false
});
});
this.sequence.update_completion(1);
expect(
this.sequence.$('.nav-item[data-index=0]').children('.check-circle').first()
.hasClass('is-hidden')
).toBe(true);
});
});
});
});
}).call(this);

View File

@@ -230,6 +230,7 @@
if (this.position !== newPosition) {
if (this.position) {
this.mark_visited(this.position);
this.update_completion(this.position);
modxFullUrl = '' + this.ajaxUrl + '/goto_position';
$.postWithPrefix(modxFullUrl, {
position: newPosition
@@ -400,6 +401,22 @@
.addClass('visited');
};
Sequence.prototype.update_completion = function(position) {
var element = this.link_for(position);
var completionUrl = this.ajaxUrl + '/get_completion';
var usageKey = element[0].attributes['data-id'].value;
var completionIndicators = element.find('.check-circle');
if (completionIndicators.length) {
$.postWithPrefix(completionUrl, {
usage_key: usageKey
}, function(data) {
if (data.complete === true) {
completionIndicators.removeClass('is-hidden');
}
});
}
};
Sequence.prototype.mark_active = function(position) {
// Don't overwrite class attribute to avoid changing Progress class
var element = this.link_for(position);

View File

@@ -9,6 +9,7 @@ import logging
from datetime import datetime
from lxml import etree
from opaque_keys.edx.keys import UsageKey
from pkg_resources import resource_string
from pytz import UTC
from six import text_type
@@ -161,6 +162,7 @@ class ProctoringFields(object):
@XBlock.wants('verification')
@XBlock.wants('gating')
@XBlock.wants('credit')
@XBlock.wants('completion')
@XBlock.needs('user')
@XBlock.needs('bookmarks')
class SequenceModule(SequenceFields, ProctoringFields, XModule):
@@ -206,6 +208,20 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
self.position = 1
return json.dumps({'success': True})
if dispatch == 'get_completion':
completion_service = self.runtime.service(self, 'completion')
if not completion_service.visual_progress_enabled():
return None
usage_key = data.get('usage_key', None)
item = self.get_child(UsageKey.from_string(usage_key))
if not item:
return None
complete = completion_service.vertical_is_complete(item)
return json.dumps({
'complete': complete
})
raise NotFoundError('Unexpected dispatch type')
@classmethod
@@ -414,6 +430,7 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
"""
is_user_authenticated = self.is_user_authenticated(context)
bookmarks_service = self.runtime.service(self, 'bookmarks')
completion_service = self.runtime.service(self, 'completion')
context['username'] = self.runtime.service(self, 'user').get_current_user().opt_attrs.get(
'edx-platform.username')
display_names = [
@@ -455,6 +472,9 @@ class SequenceModule(SequenceFields, ProctoringFields, XModule):
'path': " > ".join(display_names + [item.display_name_with_default]),
}
if is_user_authenticated and completion_service.visual_progress_enabled():
iteminfo['complete'] = completion_service.vertical_is_complete(item)
contents.append(iteminfo)
return contents

View File

@@ -2,6 +2,8 @@
Tests for sequence module.
"""
# pylint: disable=no-member
import json
from datetime import timedelta
from django.utils.timezone import now
from freezegun import freeze_time
@@ -72,6 +74,9 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
self._set_up_module_system(block)
block.xmodule_runtime._services['bookmarks'] = Mock() # pylint: disable=protected-access
block.xmodule_runtime._services['completion'] = Mock( # pylint: disable=protected-access
return_value=Mock(vertical_is_complete=Mock(return_value=True))
)
block.xmodule_runtime._services['user'] = StubUserService() # pylint: disable=protected-access
block.xmodule_runtime.xmodule_instance = getattr(block, '_xmodule', None) # pylint: disable=protected-access
block.parent = parent.location
@@ -121,6 +126,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
self.assertIn("'gated': False", html)
self.assertIn("'next_url': 'NextSequential'", html)
self.assertIn("'prev_url': 'PrevSequential'", html)
self.assertNotIn("fa fa-check-circle check-circle is-hidden", html)
def test_student_view_first_child(self):
html = self._get_rendered_student_view(self.sequence_3_1, requested_child='first')
@@ -273,3 +279,34 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
# assert content shown as normal
self._assert_ungated(html, self.sequence_1_2)
def test_handle_ajax_get_completion_disabled(self):
"""
Test when completion service is turned off by waffle, the ajax call returns correct
None value
"""
completion_waffle_mock = Mock()
completion_waffle_mock.return_value.visual_progress_enabled.return_value = False
self.sequence_3_1.xmodule_runtime._services['completion'] = completion_waffle_mock # pylint: disable=protected-access
for child in self.sequence_3_1.get_children():
usage_key = unicode(child.location)
completion_return = self.sequence_3_1.handle_ajax(
'get_completion',
{'usage_key': usage_key}
)
self.assertIs(completion_return, None)
def test_handle_ajax_get_completion_success(self):
"""
Test that the completion data is returned successfully on
targeted vertical through ajax call
"""
for child in self.sequence_3_1.get_children():
usage_key = unicode(child.location)
completion_return = json.loads(self.sequence_3_1.handle_ajax(
'get_completion',
{'usage_key': usage_key}
))
self.assertIsNot(completion_return, None)
self.assertTrue('complete' in completion_return)
self.assertEqual(completion_return['complete'], True)

View File

@@ -6,7 +6,7 @@ from __future__ import absolute_import, division, print_function, unicode_litera
from django.contrib.auth.models import User
from django.core.exceptions import ValidationError
from django.db import models, transaction, connection
from django.db import models, transaction
from django.utils.translation import ugettext as _
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField
@@ -161,12 +161,26 @@ class BlockCompletion(TimeStampedModel, models.Model):
id = BigAutoField(primary_key=True) # pylint: disable=invalid-name
user = models.ForeignKey(User)
course_key = CourseKeyField(max_length=255)
# note: this usage key may not have the run filled in for
# old mongo courses. Use the full_block_key property
# instead when you want to use/compare the usage_key.
block_key = UsageKeyField(max_length=255)
block_type = models.CharField(max_length=64)
completion = models.FloatField(validators=[validate_percent])
objects = BlockCompletionManager()
@property
def full_block_key(self):
"""
Returns the "correct" usage key value with the run filled in.
"""
if self.block_key.run is None:
return self.block_key.replace(course_key=self.course_key) # pylint: disable=unexpected-keyword-arg, no-value-for-parameter
else:
return self.block_key
@classmethod
def get_course_completions(cls, user, course_key):
"""

View File

@@ -13,7 +13,9 @@ class CompletionService(object):
Exposes
* self.completion_tracking_enabled() -> bool
* self.visual_progress_enabled() -> bool
* self.get_completions(candidates)
* self.vertical_is_complete(vertical_item)
Constructor takes a user object and course_key as arguments.
"""
@@ -31,6 +33,16 @@ class CompletionService(object):
"""
return waffle.waffle().is_enabled(waffle.ENABLE_COMPLETION_TRACKING)
def visual_progress_enabled(self):
"""
Exposes VISUAL_PROGRESS_ENABLED waffle switch to XModule runtime
Return value:
bool -> True if VISUAL_PROGRESS flag is enabled.
"""
return waffle.visual_progress_enabled(self._course_key)
def get_completions(self, candidates):
"""
Given an iterable collection of block_keys in the course, returns a
@@ -54,8 +66,34 @@ class CompletionService(object):
course_key=self._course_key,
block_key__in=candidates,
)
completions = {block.block_key: block.completion for block in completion_queryset}
completions = {
block.full_block_key: block.completion for block in completion_queryset # pylint: disable=not-an-iterable
}
for candidate in candidates:
if candidate not in completions:
completions[candidate] = 0.0
return completions
def vertical_is_complete(self, item):
"""
Calculates and returns whether a particular vertical is complete.
The logic in this method is temporary, and will go away once the
completion API is able to store a first-order notion of completeness
for parent blocks (right now it just stores completion for leaves-
problems, HTML, video, etc.).
"""
if item.location.block_type != 'vertical':
raise ValueError('The passed in xblock is not a vertical type!')
if not self.completion_tracking_enabled():
return None
# this is temporary local logic and will be removed when the whole course tree is included in completion
child_locations = [
child.location for child in item.get_children() if child.location.block_type != 'discussion'
]
completions = self.get_completions(child_locations)
for child_location in child_locations:
if completions[child_location] < 1.0:
return False
return True

View File

@@ -2,9 +2,10 @@
Tests of completion xblock runtime services
"""
import ddt
from django.test import TestCase
from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.keys import CourseKey
from student.tests.factories import UserFactory
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
from ..models import BlockCompletion
from ..services import CompletionService
@@ -12,20 +13,58 @@ from ..test_utils import CompletionWaffleTestMixin
@ddt.ddt
class CompletionServiceTestCase(CompletionWaffleTestMixin, TestCase):
class CompletionServiceTestCase(CompletionWaffleTestMixin, SharedModuleStoreTestCase):
"""
Test the data returned by the CompletionService.
"""
@classmethod
def setUpClass(cls):
super(CompletionServiceTestCase, cls).setUpClass()
cls.course = CourseFactory.create()
with cls.store.bulk_operations(cls.course.id):
cls.chapter = ItemFactory.create(
parent=cls.course,
category="chapter",
)
cls.sequence = ItemFactory.create(
parent=cls.chapter,
category='sequential',
)
cls.vertical = ItemFactory.create(
parent=cls.sequence,
category='vertical',
)
cls.problem = ItemFactory.create(
parent=cls.vertical,
category="problem",
)
cls.problem2 = ItemFactory.create(
parent=cls.vertical,
category="problem",
)
cls.problem3 = ItemFactory.create(
parent=cls.vertical,
category="problem",
)
cls.problem4 = ItemFactory.create(
parent=cls.vertical,
category="problem",
)
cls.problem5 = ItemFactory.create(
parent=cls.vertical,
category="problem",
)
cls.store.update_item(cls.course, UserFactory().id)
cls.problems = [cls.problem, cls.problem2, cls.problem3, cls.problem4, cls.problem5]
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.course_key = self.course.id
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.block_keys = [problem.location for problem in self.problems]
self.completion_service = CompletionService(self.user, self.course_key)
# Proper completions for the given runtime
@@ -72,3 +111,38 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, TestCase):
def test_enabled_honors_waffle_switch(self, enabled):
self.override_waffle_switch(enabled)
self.assertEqual(self.completion_service.completion_tracking_enabled(), enabled)
def test_vertical_completion(self):
self.assertEqual(
self.completion_service.vertical_is_complete(self.vertical),
False,
)
for block_key in self.block_keys:
BlockCompletion.objects.submit_completion(
user=self.user,
course_key=self.course_key,
block_key=block_key,
completion=1.0
)
self.assertEqual(
self.completion_service.vertical_is_complete(self.vertical),
True,
)
def test_vertical_partial_completion(self):
block_keys_count = len(self.block_keys)
for i in range(block_keys_count - 1):
# Mark all the child blocks completed except the last one
BlockCompletion.objects.submit_completion(
user=self.user,
course_key=self.course_key,
block_key=self.block_keys[i],
completion=1.0
)
self.assertEqual(
self.completion_service.vertical_is_complete(self.vertical),
False,
)

View File

@@ -16,9 +16,6 @@ class BlockCompletionTransformer(BlockStructureTransformer):
WRITE_VERSION = 1
COMPLETION = 'completion'
def __init__(self):
super(BlockCompletionTransformer, self).__init__()
@classmethod
def name(cls):
return "blocks_api:completion"

View File

@@ -13,6 +13,7 @@
</div>
</div>
% endif
<div class="sequence-nav">
<button class="sequence-nav-button button-previous">
<span class="icon fa fa-chevron-prev" aria-hidden="true"></span>
@@ -29,7 +30,7 @@
<button class="active nav-item tab" title="${_('Content Locked')}" id="tab_0" role="tab" tabindex="-1" aria-selected="true" aria-expanded="true" aria-controls="content_locked" disabled>
<span class="icon fa fa-lock" aria-hidden="true"></span>
</button>
</li>
</li>
% else:
% for idx, item in enumerate(items):
<li role="presentation">
@@ -47,6 +48,13 @@
id="tab_${idx}"
${"disabled=disabled" if disable_navigation else ""}>
<span class="icon fa seq_${item['type']}" aria-hidden="true"></span>
% if 'complete' in item:
<span
class="fa fa-check-circle check-circle ${"is-hidden" if not item['complete'] else ""}"
style="color:green"
aria-hidden="true"
></span>
% endif
<span class="fa fa-fw fa-bookmark bookmark-icon ${"is-hidden" if not item['bookmarked'] else "bookmarked"}" aria-hidden="true"></span>
<div class="sequence-tooltip sr"><span class="sr">${item['type']}&nbsp;</span>${item['page_title']}<span class="sr bookmark-icon-sr">&nbsp;${_("Bookmarked") if item['bookmarked'] else ""}</span></div>
</button>
@@ -56,7 +64,6 @@
</ol>
</nav>
</div>
% if gated_content['gated']:
<%include file="_gated_content.html" args="prereq_url=gated_content['prereq_url'], prereq_section_name=gated_content['prereq_section_name'], gated_section_name=gated_content['gated_section_name']"/>
% else: