Update resume button functionality
This commit is contained in:
@@ -0,0 +1,18 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from __future__ import unicode_literals
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('completion', '0001_initial'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='blockcompletion',
|
||||||
|
options={'get_latest_by': 'modified'},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -167,6 +167,38 @@ class BlockCompletion(TimeStampedModel, models.Model):
|
|||||||
|
|
||||||
objects = BlockCompletionManager()
|
objects = BlockCompletionManager()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_course_completions(cls, user, course_key):
|
||||||
|
"""
|
||||||
|
query all completions for course/user pair
|
||||||
|
|
||||||
|
Return value:
|
||||||
|
dict[BlockKey] = float
|
||||||
|
"""
|
||||||
|
course_block_completions = cls.objects.filter(
|
||||||
|
user=user,
|
||||||
|
course_key=course_key,
|
||||||
|
)
|
||||||
|
# will not return if <= 0.0
|
||||||
|
return {completion.block_key: completion.completion for completion in course_block_completions}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_latest_block_completed(cls, user, course_key):
|
||||||
|
"""
|
||||||
|
query latest completion for course/user pair
|
||||||
|
|
||||||
|
Return value:
|
||||||
|
obj: block completion
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
latest_modified_block_completion = cls.objects.filter(
|
||||||
|
user=user,
|
||||||
|
course_key=course_key,
|
||||||
|
).latest()
|
||||||
|
except cls.DoesNotExist:
|
||||||
|
return
|
||||||
|
return latest_modified_block_completion
|
||||||
|
|
||||||
class Meta(object):
|
class Meta(object):
|
||||||
index_together = [
|
index_together = [
|
||||||
('course_key', 'block_type', 'user'),
|
('course_key', 'block_type', 'user'),
|
||||||
@@ -176,6 +208,7 @@ class BlockCompletion(TimeStampedModel, models.Model):
|
|||||||
unique_together = [
|
unique_together = [
|
||||||
('course_key', 'block_key', 'user')
|
('course_key', 'block_key', 'user')
|
||||||
]
|
]
|
||||||
|
get_latest_by = 'modified'
|
||||||
|
|
||||||
def __unicode__(self):
|
def __unicode__(self):
|
||||||
return 'BlockCompletion: {username}, {course_key}, {block_key}: {completion}'.format(
|
return 'BlockCompletion: {username}, {course_key}, {block_key}: {completion}'.format(
|
||||||
|
|||||||
@@ -6,12 +6,11 @@ from __future__ import absolute_import, division, print_function, unicode_litera
|
|||||||
|
|
||||||
from django.core.exceptions import ValidationError
|
from django.core.exceptions import ValidationError
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
from opaque_keys.edx.keys import UsageKey, CourseKey
|
|
||||||
|
|
||||||
from student.tests.factories import UserFactory, CourseEnrollmentFactory
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
|
from student.tests.factories import CourseEnrollmentFactory, UserFactory
|
||||||
|
|
||||||
from .. import models
|
from .. import models, waffle
|
||||||
from .. import waffle
|
|
||||||
|
|
||||||
|
|
||||||
class PercentValidatorTestCase(TestCase):
|
class PercentValidatorTestCase(TestCase):
|
||||||
@@ -187,3 +186,64 @@ class SubmitBatchCompletionTestCase(TestCase):
|
|||||||
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
||||||
model = models.BlockCompletion.objects.first()
|
model = models.BlockCompletion.objects.first()
|
||||||
self.assertEqual(model.completion, 1.0)
|
self.assertEqual(model.completion, 1.0)
|
||||||
|
|
||||||
|
|
||||||
|
class BatchCompletionMethodTests(TestCase):
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super(BatchCompletionMethodTests, self).setUp()
|
||||||
|
_overrider = waffle.waffle().override(waffle.ENABLE_COMPLETION_TRACKING, True)
|
||||||
|
_overrider.__enter__()
|
||||||
|
self.addCleanup(_overrider.__exit__, None, None, None)
|
||||||
|
|
||||||
|
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.submit_faux_completions()
|
||||||
|
|
||||||
|
def submit_faux_completions(self):
|
||||||
|
# Proper completions for the given runtime
|
||||||
|
for idx, block_key in enumerate(self.block_keys[0:3]):
|
||||||
|
models.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:]):
|
||||||
|
models.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
|
||||||
|
models.BlockCompletion.objects.submit_completion(
|
||||||
|
user=self.user,
|
||||||
|
course_key=self.other_course_key,
|
||||||
|
block_key=self.block_keys[4],
|
||||||
|
completion=0.75,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_course_completions(self):
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
models.BlockCompletion.get_course_completions(self.user, self.course_key),
|
||||||
|
{
|
||||||
|
self.block_keys[0]: 1.0,
|
||||||
|
self.block_keys[1]: 0.8,
|
||||||
|
self.block_keys[2]: 0.6,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_get_latest_block_completed(self):
|
||||||
|
self.assertEqual(
|
||||||
|
models.BlockCompletion.get_latest_block_completed(self.user, self.course_key).block_key,
|
||||||
|
self.block_keys[2]
|
||||||
|
)
|
||||||
|
|||||||
@@ -64,7 +64,7 @@ class CompletionServiceTestCase(CompletionWaffleTestMixin, TestCase):
|
|||||||
self.block_keys[1]: 0.8,
|
self.block_keys[1]: 0.8,
|
||||||
self.block_keys[2]: 0.6,
|
self.block_keys[2]: 0.6,
|
||||||
self.block_keys[3]: 0.0,
|
self.block_keys[3]: 0.0,
|
||||||
self.block_keys[4]: 0.0,
|
self.block_keys[4]: 0.0
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -4,13 +4,15 @@ waffle switches for the completion app.
|
|||||||
"""
|
"""
|
||||||
from __future__ import absolute_import, division, print_function, unicode_literals
|
from __future__ import absolute_import, division, print_function, unicode_literals
|
||||||
|
|
||||||
from openedx.core.djangoapps.waffle_utils import WaffleSwitchNamespace
|
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||||
|
from openedx.core.djangoapps.theming.helpers import get_current_site
|
||||||
|
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag, WaffleFlagNamespace, WaffleSwitchNamespace
|
||||||
|
|
||||||
# Namespace
|
# Namespace
|
||||||
WAFFLE_NAMESPACE = 'completion'
|
WAFFLE_NAMESPACE = 'completion'
|
||||||
|
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='completion')
|
||||||
|
|
||||||
# Switches
|
# Switches
|
||||||
|
|
||||||
# Full name: completion.enable_completion_tracking
|
# Full name: completion.enable_completion_tracking
|
||||||
# Indicates whether or not to track completion of individual blocks. Keeping
|
# Indicates whether or not to track completion of individual blocks. Keeping
|
||||||
# this disabled will prevent creation of BlockCompletion objects in the
|
# this disabled will prevent creation of BlockCompletion objects in the
|
||||||
@@ -18,9 +20,74 @@ WAFFLE_NAMESPACE = 'completion'
|
|||||||
# xblocks.
|
# xblocks.
|
||||||
ENABLE_COMPLETION_TRACKING = 'enable_completion_tracking'
|
ENABLE_COMPLETION_TRACKING = 'enable_completion_tracking'
|
||||||
|
|
||||||
|
# Full name completion.enable_visual_progress
|
||||||
|
# Overrides completion.enable_course_visual_progress
|
||||||
|
# Acts as a global override -- enable visual progress indicators
|
||||||
|
# sitewide.
|
||||||
|
ENABLE_VISUAL_PROGRESS = 'enable_visual_progress'
|
||||||
|
|
||||||
|
# Full name completion.enable_course_visual_progress
|
||||||
|
# Acts as a course-by-course enabling of visual progress
|
||||||
|
# indicators, e.g. updated 'resume button' functionality
|
||||||
|
ENABLE_COURSE_VISUAL_PROGRESS = 'enable_course_visual_progress'
|
||||||
|
|
||||||
|
# SiteConfiguration visual progress enablement
|
||||||
|
ENABLE_SITE_VISUAL_PROGRESS = 'enable_site_visual_progress'
|
||||||
|
|
||||||
|
|
||||||
def waffle():
|
def waffle():
|
||||||
"""
|
"""
|
||||||
Returns the namespaced, cached, audited Waffle class for completion.
|
Returns the namespaced, cached, audited Waffle class for completion.
|
||||||
"""
|
"""
|
||||||
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix='completion: ')
|
return WaffleSwitchNamespace(name=WAFFLE_NAMESPACE, log_prefix='completion: ')
|
||||||
|
|
||||||
|
|
||||||
|
def waffle_flag():
|
||||||
|
"""
|
||||||
|
Returns the namespaced, cached, audited Waffle flags dictionary for Completion.
|
||||||
|
"""
|
||||||
|
namespace = WaffleFlagNamespace(name=WAFFLE_NAMESPACE, log_prefix=u'completion: ')
|
||||||
|
return {
|
||||||
|
# By default, disable visual progress. Can be enabled on a course-by-course basis.
|
||||||
|
# And overridden site-globally by ENABLE_VISUAL_PROGRESS
|
||||||
|
ENABLE_COURSE_VISUAL_PROGRESS: CourseWaffleFlag(
|
||||||
|
namespace,
|
||||||
|
ENABLE_COURSE_VISUAL_PROGRESS,
|
||||||
|
flag_undefined_default=False
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def visual_progress_enabled(course_key):
|
||||||
|
"""
|
||||||
|
Exposes varia of visual progress feature.
|
||||||
|
ENABLE_COMPLETION_TRACKING, current_site.configuration, AND
|
||||||
|
enable_course_visual_progress OR enable_visual_progress
|
||||||
|
|
||||||
|
:return:
|
||||||
|
|
||||||
|
bool -> True if site/course/global enabled for visual progress tracking
|
||||||
|
"""
|
||||||
|
if not waffle().is_enabled(ENABLE_COMPLETION_TRACKING):
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
current_site = get_current_site()
|
||||||
|
if not current_site.configuration.get_value(ENABLE_SITE_VISUAL_PROGRESS, False):
|
||||||
|
return
|
||||||
|
except SiteConfiguration.DoesNotExist:
|
||||||
|
return
|
||||||
|
|
||||||
|
# Site-aware global override
|
||||||
|
if not waffle().is_enabled(ENABLE_VISUAL_PROGRESS):
|
||||||
|
# Course enabled
|
||||||
|
return waffle_flag()[ENABLE_COURSE_VISUAL_PROGRESS].is_enabled(course_key)
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def site_configuration_enabled():
|
||||||
|
"""
|
||||||
|
Helper function to return site-aware feature switch
|
||||||
|
:return: bool -> True if site enabled for visual progress tracking
|
||||||
|
"""
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
|||||||
<ol class="outline-item focusable">
|
<ol class="outline-item focusable">
|
||||||
% for subsection in section.get('children', []):
|
% for subsection in section.get('children', []):
|
||||||
<li
|
<li
|
||||||
class="subsection ${ 'current' if subsection['last_accessed'] else '' }"
|
class="subsection ${ 'current' if subsection['resume_block'] else '' }"
|
||||||
>
|
>
|
||||||
<a
|
<a
|
||||||
class="outline-item focusable"
|
class="outline-item focusable"
|
||||||
@@ -125,7 +125,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
|||||||
</div> <!-- /subsection-text -->
|
</div> <!-- /subsection-text -->
|
||||||
<div class="subsection-actions">
|
<div class="subsection-actions">
|
||||||
## Resume button (if last visited section)
|
## Resume button (if last visited section)
|
||||||
% if subsection['last_accessed']:
|
% if subsection['resume_block']:
|
||||||
<span class="resume-right">
|
<span class="resume-right">
|
||||||
<b>${ _("Resume Course") }</b>
|
<b>${ _("Resume Course") }</b>
|
||||||
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
|
<span class="icon fa fa-arrow-circle-right" aria-hidden="true"></span>
|
||||||
|
|||||||
@@ -4,21 +4,28 @@ Tests for the Course Outline view and supporting views.
|
|||||||
import datetime
|
import datetime
|
||||||
import json
|
import json
|
||||||
|
|
||||||
|
from django.contrib.sites.models import Site
|
||||||
from django.core.urlresolvers import reverse
|
from django.core.urlresolvers import reverse
|
||||||
from pyquery import PyQuery as pq
|
from mock import Mock, patch
|
||||||
from six import text_type
|
from six import text_type
|
||||||
from gating import api as lms_gating_api
|
|
||||||
from mock import patch, Mock
|
|
||||||
|
|
||||||
from courseware.tests.factories import StaffFactory
|
from courseware.tests.factories import StaffFactory
|
||||||
|
from gating import api as lms_gating_api
|
||||||
|
from lms.djangoapps.completion import waffle
|
||||||
|
from lms.djangoapps.completion.models import BlockCompletion
|
||||||
|
from lms.djangoapps.completion.test_utils import CompletionWaffleTestMixin
|
||||||
|
from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer
|
||||||
|
from milestones.tests.utils import MilestonesTestCaseMixin
|
||||||
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
|
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
|
||||||
from openedx.core.lib.gating import api as gating_api
|
from openedx.core.lib.gating import api as gating_api
|
||||||
|
from pyquery import PyQuery as pq
|
||||||
from student.models import CourseEnrollment
|
from student.models import CourseEnrollment
|
||||||
from student.tests.factories import UserFactory
|
from student.tests.factories import UserFactory
|
||||||
|
from waffle.testutils import override_switch
|
||||||
from xmodule.modulestore import ModuleStoreEnum
|
from xmodule.modulestore import ModuleStoreEnum
|
||||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory
|
||||||
from milestones.tests.utils import MilestonesTestCaseMixin
|
|
||||||
from lms.djangoapps.course_api.blocks.transformers.milestones import MilestonesAndSpecialExamsTransformer
|
|
||||||
|
|
||||||
from .test_course_home import course_home_url
|
from .test_course_home import course_home_url
|
||||||
|
|
||||||
@@ -145,11 +152,28 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
|
|||||||
course.enable_subsection_gating = True
|
course.enable_subsection_gating = True
|
||||||
course_blocks = {}
|
course_blocks = {}
|
||||||
with cls.store.bulk_operations(course.id):
|
with cls.store.bulk_operations(course.id):
|
||||||
course_blocks['chapter'] = ItemFactory.create(category='chapter', parent_location=course.location)
|
course_blocks['chapter'] = ItemFactory.create(
|
||||||
course_blocks['prerequisite'] = ItemFactory.create(category='sequential', parent_location=course_blocks['chapter'].location, display_name='Prerequisite Exam')
|
category='chapter',
|
||||||
course_blocks['gated_content'] = ItemFactory.create(category='sequential', parent_location=course_blocks['chapter'].location, display_name='Gated Content')
|
parent_location=course.location
|
||||||
course_blocks['prerequisite_vertical'] = ItemFactory.create(category='vertical', parent_location=course_blocks['prerequisite'].location)
|
)
|
||||||
course_blocks['gated_content_vertical'] = ItemFactory.create(category='vertical', parent_location=course_blocks['gated_content'].location)
|
course_blocks['prerequisite'] = ItemFactory.create(
|
||||||
|
category='sequential',
|
||||||
|
parent_location=course_blocks['chapter'].location,
|
||||||
|
display_name='Prerequisite Exam'
|
||||||
|
)
|
||||||
|
course_blocks['gated_content'] = ItemFactory.create(
|
||||||
|
category='sequential',
|
||||||
|
parent_location=course_blocks['chapter'].location,
|
||||||
|
display_name='Gated Content'
|
||||||
|
)
|
||||||
|
course_blocks['prerequisite_vertical'] = ItemFactory.create(
|
||||||
|
category='vertical',
|
||||||
|
parent_location=course_blocks['prerequisite'].location
|
||||||
|
)
|
||||||
|
course_blocks['gated_content_vertical'] = ItemFactory.create(
|
||||||
|
category='vertical',
|
||||||
|
parent_location=course_blocks['gated_content'].location
|
||||||
|
)
|
||||||
course.children = [course_blocks['chapter']]
|
course.children = [course_blocks['chapter']]
|
||||||
course_blocks['chapter'].children = [course_blocks['prerequisite'], course_blocks['gated_content']]
|
course_blocks['chapter'].children = [course_blocks['prerequisite'], course_blocks['gated_content']]
|
||||||
course_blocks['prerequisite'].children = [course_blocks['prerequisite_vertical']]
|
course_blocks['prerequisite'].children = [course_blocks['prerequisite_vertical']]
|
||||||
@@ -245,7 +269,7 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
|
|||||||
self.assertIn(self.UNLOCKED, subsection.children('.sr').html())
|
self.assertIn(self.UNLOCKED, subsection.children('.sr').html())
|
||||||
|
|
||||||
|
|
||||||
class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase, CompletionWaffleTestMixin):
|
||||||
"""
|
"""
|
||||||
Test start course and resume course for the course outline view.
|
Test start course and resume course for the course outline view.
|
||||||
|
|
||||||
@@ -268,6 +292,12 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
"""Set up and enroll our fake user in the course."""
|
"""Set up and enroll our fake user in the course."""
|
||||||
cls.user = UserFactory(password=TEST_PASSWORD)
|
cls.user = UserFactory(password=TEST_PASSWORD)
|
||||||
CourseEnrollment.enroll(cls.user, cls.course.id)
|
CourseEnrollment.enroll(cls.user, cls.course.id)
|
||||||
|
cls.site = Site.objects.get_current()
|
||||||
|
SiteConfiguration.objects.get_or_create(
|
||||||
|
site=cls.site,
|
||||||
|
enabled=True,
|
||||||
|
values={waffle.ENABLE_SITE_VISUAL_PROGRESS: True}
|
||||||
|
)
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def create_test_course(cls):
|
def create_test_course(cls):
|
||||||
@@ -295,6 +325,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
"""
|
"""
|
||||||
super(TestCourseOutlineResumeCourse, self).setUp()
|
super(TestCourseOutlineResumeCourse, self).setUp()
|
||||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||||
|
self.override_waffle_switch(False)
|
||||||
|
|
||||||
def visit_sequential(self, course, chapter, sequential):
|
def visit_sequential(self, course, chapter, sequential):
|
||||||
"""
|
"""
|
||||||
@@ -310,6 +341,21 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(200, self.client.get(last_accessed_url).status_code)
|
self.assertEqual(200, self.client.get(last_accessed_url).status_code)
|
||||||
|
|
||||||
|
def visit_course_home(self, course, start_count=0, resume_count=0):
|
||||||
|
"""
|
||||||
|
Helper function to navigates to course home page, test for resume buttons
|
||||||
|
|
||||||
|
:param course: course factory object
|
||||||
|
:param start_count: number of times 'Start Course' should appear
|
||||||
|
:param resume_count: number of times 'Resume Course' should appear
|
||||||
|
:return: response object
|
||||||
|
"""
|
||||||
|
response = self.client.get(course_home_url(course))
|
||||||
|
self.assertEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, 'Start Course', count=start_count)
|
||||||
|
self.assertContains(response, 'Resume Course', count=resume_count)
|
||||||
|
return response
|
||||||
|
|
||||||
def test_start_course(self):
|
def test_start_course(self):
|
||||||
"""
|
"""
|
||||||
Tests that the start course button appears when the course has never been accessed.
|
Tests that the start course button appears when the course has never been accessed.
|
||||||
@@ -320,13 +366,9 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
"""
|
"""
|
||||||
course = self.course
|
course = self.course
|
||||||
|
|
||||||
response = self.client.get(course_home_url(course))
|
response = self.visit_course_home(course, start_count=1, resume_count=0)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertContains(response, 'Start Course', count=1)
|
|
||||||
self.assertContains(response, 'Resume Course', count=0)
|
|
||||||
|
|
||||||
content = pq(response.content)
|
content = pq(response.content)
|
||||||
|
|
||||||
self.assertTrue(content('.action-resume-course').attr('href').endswith('/course/' + course.url_name))
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/course/' + course.url_name))
|
||||||
|
|
||||||
def test_resume_course(self):
|
def test_resume_course(self):
|
||||||
@@ -338,17 +380,75 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
# first navigate to a sequential to make it the last accessed
|
# first navigate to a sequential to make it the last accessed
|
||||||
chapter = course.children[0]
|
chapter = course.children[0]
|
||||||
sequential = chapter.children[0]
|
sequential = chapter.children[0]
|
||||||
|
vertical = sequential.children[0]
|
||||||
self.visit_sequential(course, chapter, sequential)
|
self.visit_sequential(course, chapter, sequential)
|
||||||
|
|
||||||
# check resume course buttons
|
# check resume course buttons
|
||||||
response = self.client.get(course_home_url(course))
|
response = self.visit_course_home(course, resume_count=2)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertContains(response, 'Start Course', count=0)
|
|
||||||
self.assertContains(response, 'Resume Course', count=2)
|
|
||||||
|
|
||||||
content = pq(response.content)
|
content = pq(response.content)
|
||||||
self.assertTrue(content('.action-resume-course').attr('href').endswith('/sequential/' + sequential.url_name))
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/vertical/' + vertical.url_name))
|
||||||
|
|
||||||
|
@override_switch(
|
||||||
|
'{}.{}'.format(
|
||||||
|
waffle.WAFFLE_NAMESPACE, waffle.ENABLE_VISUAL_PROGRESS
|
||||||
|
),
|
||||||
|
active=True
|
||||||
|
)
|
||||||
|
# @patch('lms.djangoapps.completion.waffle.site_configuration_enabled')
|
||||||
|
@patch('lms.djangoapps.completion.waffle.get_current_site')
|
||||||
|
def test_resume_course_with_completion_api(self, get_patched_current_site):
|
||||||
|
"""
|
||||||
|
Tests completion API resume button functionality
|
||||||
|
"""
|
||||||
|
self.override_waffle_switch(True)
|
||||||
|
get_patched_current_site.return_value = self.site
|
||||||
|
|
||||||
|
# Course tree
|
||||||
|
course = self.course
|
||||||
|
course_key = CourseKey.from_string(str(course.id))
|
||||||
|
vertical1 = course.children[0].children[0].children[0]
|
||||||
|
vertical2 = course.children[0].children[1].children[0]
|
||||||
|
|
||||||
|
# Fake a visit to sequence1/vertical1
|
||||||
|
block_key = UsageKey.from_string(unicode(vertical1.location))
|
||||||
|
completion = 1.0
|
||||||
|
BlockCompletion.objects.submit_completion(
|
||||||
|
user=self.user,
|
||||||
|
course_key=course_key,
|
||||||
|
block_key=block_key,
|
||||||
|
completion=completion
|
||||||
|
)
|
||||||
|
|
||||||
|
# Test for 'resume' link
|
||||||
|
response = self.visit_course_home(course, resume_count=2)
|
||||||
|
|
||||||
|
# Test for 'resume' link URL - should be vertical 1
|
||||||
|
content = pq(response.content)
|
||||||
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/vertical/' + vertical1.url_name))
|
||||||
|
|
||||||
|
# Fake a visit to sequence2/vertical2
|
||||||
|
block_key = UsageKey.from_string(unicode(vertical2.location))
|
||||||
|
completion = 1.0
|
||||||
|
BlockCompletion.objects.submit_completion(
|
||||||
|
user=self.user,
|
||||||
|
course_key=course_key,
|
||||||
|
block_key=block_key,
|
||||||
|
completion=completion
|
||||||
|
)
|
||||||
|
response = self.visit_course_home(course, resume_count=2)
|
||||||
|
|
||||||
|
# Test for 'resume' link URL - should be vertical 2
|
||||||
|
content = pq(response.content)
|
||||||
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/vertical/' + vertical2.url_name))
|
||||||
|
|
||||||
|
# visit sequential 1, make sure 'Resume Course' URL is robust against 'Last Visited'
|
||||||
|
# (even though I visited seq1/vert1, 'Resume Course' still points to seq2/vert2)
|
||||||
|
self.visit_sequential(course, course.children[0], course.children[0].children[0])
|
||||||
|
|
||||||
|
# Test for 'resume' link URL - should be vertical 2 (last completed block, NOT last visited)
|
||||||
|
response = self.visit_course_home(course, resume_count=2)
|
||||||
|
content = pq(response.content)
|
||||||
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/vertical/' + vertical2.url_name))
|
||||||
|
|
||||||
def test_resume_course_deleted_sequential(self):
|
def test_resume_course_deleted_sequential(self):
|
||||||
"""
|
"""
|
||||||
@@ -370,11 +470,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
self.store.delete_item(sequential.location, self.user.id)
|
self.store.delete_item(sequential.location, self.user.id)
|
||||||
|
|
||||||
# check resume course buttons
|
# check resume course buttons
|
||||||
response = self.client.get(course_home_url(course))
|
response = self.visit_course_home(course, resume_count=2)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertContains(response, 'Start Course', count=0)
|
|
||||||
self.assertContains(response, 'Resume Course', count=2)
|
|
||||||
|
|
||||||
content = pq(response.content)
|
content = pq(response.content)
|
||||||
self.assertTrue(content('.action-resume-course').attr('href').endswith('/sequential/' + sequential2.url_name))
|
self.assertTrue(content('.action-resume-course').attr('href').endswith('/sequential/' + sequential2.url_name))
|
||||||
@@ -399,11 +495,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
|||||||
self.store.delete_item(sequential.location, self.user.id)
|
self.store.delete_item(sequential.location, self.user.id)
|
||||||
|
|
||||||
# check resume course buttons
|
# check resume course buttons
|
||||||
response = self.client.get(course_home_url(course))
|
self.visit_course_home(course, resume_count=1)
|
||||||
self.assertEqual(response.status_code, 200)
|
|
||||||
|
|
||||||
self.assertContains(response, 'Start Course', count=0)
|
|
||||||
self.assertContains(response, 'Resume Course', count=1)
|
|
||||||
|
|
||||||
|
|
||||||
class TestCourseOutlinePreview(SharedModuleStoreTestCase):
|
class TestCourseOutlinePreview(SharedModuleStoreTestCase):
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
"""
|
"""
|
||||||
Common utilities for the course experience, including course outline.
|
Common utilities for the course experience, including course outline.
|
||||||
"""
|
"""
|
||||||
from opaque_keys.edx.keys import CourseKey
|
from lms.djangoapps.completion.models import BlockCompletion
|
||||||
|
from lms.djangoapps.completion.waffle import visual_progress_enabled
|
||||||
from lms.djangoapps.course_api.blocks.api import get_blocks
|
from lms.djangoapps.course_api.blocks.api import get_blocks
|
||||||
from lms.djangoapps.course_blocks.utils import get_student_module_as_dict
|
from lms.djangoapps.course_blocks.utils import get_student_module_as_dict
|
||||||
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
|
from opaque_keys.edx.locator import BlockUsageLocator
|
||||||
from openedx.core.djangoapps.request_cache.middleware import request_cached
|
from openedx.core.djangoapps.request_cache.middleware import request_cached
|
||||||
from xmodule.modulestore.django import modulestore
|
from xmodule.modulestore.django import modulestore
|
||||||
|
|
||||||
@@ -35,29 +37,84 @@ def get_course_outline_block_tree(request, course_id):
|
|||||||
|
|
||||||
def set_last_accessed_default(block):
|
def set_last_accessed_default(block):
|
||||||
"""
|
"""
|
||||||
Set default of False for last_accessed on all blocks.
|
Set default of False for resume_block on all blocks.
|
||||||
"""
|
"""
|
||||||
block['last_accessed'] = False
|
block['resume_block'] = False
|
||||||
|
block['complete'] = False
|
||||||
for child in block.get('children', []):
|
for child in block.get('children', []):
|
||||||
set_last_accessed_default(child)
|
set_last_accessed_default(child)
|
||||||
|
|
||||||
|
def mark_blocks_completed(block, user, course_key):
|
||||||
|
"""
|
||||||
|
Walk course tree, marking block completion.
|
||||||
|
Mark 'most recent completed block as 'resume_block'
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
last_completed_child_position = BlockCompletion.get_latest_block_completed(user, course_key)
|
||||||
|
|
||||||
|
if last_completed_child_position:
|
||||||
|
# Mutex w/ NOT 'course_block_completions'
|
||||||
|
recurse_mark_complete(
|
||||||
|
course_block_completions=BlockCompletion.get_course_completions(user, course_key),
|
||||||
|
latest_completion=last_completed_child_position,
|
||||||
|
block=block
|
||||||
|
)
|
||||||
|
|
||||||
|
def recurse_mark_complete(course_block_completions, latest_completion, block):
|
||||||
|
"""
|
||||||
|
Helper function to walk course tree dict,
|
||||||
|
marking blocks as 'complete' and 'last_complete'
|
||||||
|
|
||||||
|
If all blocks are complete, mark parent block complete
|
||||||
|
mark parent blocks of 'last_complete' as 'last_complete'
|
||||||
|
|
||||||
|
:param course_block_completions: dict[course_completion_object] = completion_value
|
||||||
|
:param latest_completion: course_completion_object
|
||||||
|
:param block: course_outline_root_block block object or child block
|
||||||
|
|
||||||
|
:return:
|
||||||
|
block: course_outline_root_block block object or child block
|
||||||
|
"""
|
||||||
|
locatable_block_string = BlockUsageLocator.from_string(block['id'])
|
||||||
|
|
||||||
|
if course_block_completions.get(locatable_block_string):
|
||||||
|
block['complete'] = True
|
||||||
|
if locatable_block_string == latest_completion.block_key:
|
||||||
|
block['resume_block'] = True
|
||||||
|
|
||||||
|
if block.get('children'):
|
||||||
|
for idx in range(len(block['children'])):
|
||||||
|
recurse_mark_complete(
|
||||||
|
course_block_completions,
|
||||||
|
latest_completion,
|
||||||
|
block=block['children'][idx]
|
||||||
|
)
|
||||||
|
if block['children'][idx]['resume_block'] is True:
|
||||||
|
block['resume_block'] = True
|
||||||
|
|
||||||
|
if len([child['complete'] for child in block['children'] if child['complete']]) == len(block['children']):
|
||||||
|
block['complete'] = True
|
||||||
|
|
||||||
def mark_last_accessed(user, course_key, block):
|
def mark_last_accessed(user, course_key, block):
|
||||||
"""
|
"""
|
||||||
Recursively marks the branch to the last accessed block.
|
Recursively marks the branch to the last accessed block.
|
||||||
"""
|
"""
|
||||||
block_key = block.serializer.instance
|
block_key = block.serializer.instance
|
||||||
student_module_dict = get_student_module_as_dict(user, course_key, block_key)
|
student_module_dict = get_student_module_as_dict(user, course_key, block_key)
|
||||||
|
|
||||||
last_accessed_child_position = student_module_dict.get('position')
|
last_accessed_child_position = student_module_dict.get('position')
|
||||||
if last_accessed_child_position and block.get('children'):
|
if last_accessed_child_position and block.get('children'):
|
||||||
block['last_accessed'] = True
|
block['resume_block'] = True
|
||||||
if last_accessed_child_position <= len(block['children']):
|
if last_accessed_child_position <= len(block['children']):
|
||||||
last_accessed_child_block = block['children'][last_accessed_child_position - 1]
|
last_accessed_child_block = block['children'][last_accessed_child_position - 1]
|
||||||
last_accessed_child_block['last_accessed'] = True
|
last_accessed_child_block['resume_block'] = True
|
||||||
mark_last_accessed(user, course_key, last_accessed_child_block)
|
mark_last_accessed(user, course_key, last_accessed_child_block)
|
||||||
else:
|
else:
|
||||||
# We should be using an id in place of position for last accessed. However, while using position, if
|
# We should be using an id in place of position for last accessed.
|
||||||
# the child block is no longer accessible we'll use the last child.
|
# However, while using position, if the child block is no longer accessible
|
||||||
block['children'][-1]['last_accessed'] = True
|
# we'll use the last child.
|
||||||
|
block['children'][-1]['resume_block'] = True
|
||||||
|
|
||||||
course_key = CourseKey.from_string(course_id)
|
course_key = CourseKey.from_string(course_id)
|
||||||
course_usage_key = modulestore().make_course_usage_key(course_key)
|
course_usage_key = modulestore().make_course_usage_key(course_key)
|
||||||
@@ -67,13 +124,39 @@ def get_course_outline_block_tree(request, course_id):
|
|||||||
course_usage_key,
|
course_usage_key,
|
||||||
user=request.user,
|
user=request.user,
|
||||||
nav_depth=3,
|
nav_depth=3,
|
||||||
requested_fields=['children', 'display_name', 'type', 'due', 'graded', 'special_exam_info', 'show_gated_sections', 'format'],
|
requested_fields=[
|
||||||
block_types_filter=['course', 'chapter', 'sequential']
|
'children',
|
||||||
|
'display_name',
|
||||||
|
'type',
|
||||||
|
'due',
|
||||||
|
'graded',
|
||||||
|
'special_exam_info',
|
||||||
|
'show_gated_sections',
|
||||||
|
'format'
|
||||||
|
],
|
||||||
|
block_types_filter=[
|
||||||
|
'course',
|
||||||
|
'chapter',
|
||||||
|
'sequential',
|
||||||
|
'vertical',
|
||||||
|
'html',
|
||||||
|
'problem',
|
||||||
|
'video',
|
||||||
|
'discussion'
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
course_outline_root_block = all_blocks['blocks'].get(all_blocks['root'], None)
|
course_outline_root_block = all_blocks['blocks'].get(all_blocks['root'], None)
|
||||||
if course_outline_root_block:
|
if course_outline_root_block:
|
||||||
populate_children(course_outline_root_block, all_blocks['blocks'])
|
populate_children(course_outline_root_block, all_blocks['blocks'])
|
||||||
set_last_accessed_default(course_outline_root_block)
|
set_last_accessed_default(course_outline_root_block)
|
||||||
mark_last_accessed(request.user, course_key, course_outline_root_block)
|
|
||||||
|
if visual_progress_enabled(course_key=course_key):
|
||||||
|
mark_blocks_completed(
|
||||||
|
block=course_outline_root_block,
|
||||||
|
user=request.user,
|
||||||
|
course_key=course_key
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
mark_last_accessed(request.user, course_key, course_outline_root_block)
|
||||||
return course_outline_root_block
|
return course_outline_root_block
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ from django.template.loader import render_to_string
|
|||||||
from django.utils.decorators import method_decorator
|
from django.utils.decorators import method_decorator
|
||||||
from django.views.decorators.cache import cache_control
|
from django.views.decorators.cache import cache_control
|
||||||
from django.views.decorators.csrf import ensure_csrf_cookie
|
from django.views.decorators.csrf import ensure_csrf_cookie
|
||||||
from opaque_keys.edx.keys import CourseKey
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
from web_fragments.fragment import Fragment
|
from web_fragments.fragment import Fragment
|
||||||
|
|
||||||
from course_modes.models import get_cosmetic_verified_display_price
|
from course_modes.models import get_cosmetic_verified_display_price
|
||||||
@@ -37,6 +37,7 @@ from .course_sock import CourseSockFragmentView
|
|||||||
from .latest_update import LatestUpdateFragmentView
|
from .latest_update import LatestUpdateFragmentView
|
||||||
from .welcome_message import WelcomeMessageFragmentView
|
from .welcome_message import WelcomeMessageFragmentView
|
||||||
|
|
||||||
|
|
||||||
EMPTY_HANDOUTS_HTML = u'<ol></ol>'
|
EMPTY_HANDOUTS_HTML = u'<ol></ol>'
|
||||||
|
|
||||||
|
|
||||||
@@ -76,30 +77,32 @@ class CourseHomeFragmentView(EdxFragmentView):
|
|||||||
|
|
||||||
Returns a tuple: (has_visited_course, resume_course_url)
|
Returns a tuple: (has_visited_course, resume_course_url)
|
||||||
has_visited_course: True if the user has ever visted the course, False otherwise.
|
has_visited_course: True if the user has ever visted the course, False otherwise.
|
||||||
resume_course_url: The URL of the last accessed block if the user has visited the course,
|
resume_course_url: The URL of the 'resume course' block if the user has visited the course,
|
||||||
otherwise the URL of the course root.
|
otherwise the URL of the course root.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def get_last_accessed_block(block):
|
def get_resume_block(block):
|
||||||
"""
|
"""
|
||||||
Gets the deepest block marked as 'last_accessed'.
|
Gets the deepest block marked as 'resume_block'.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
if not block['last_accessed']:
|
if not block['resume_block']:
|
||||||
return None
|
return None
|
||||||
if not block.get('children'):
|
if not block.get('children'):
|
||||||
return block
|
return block
|
||||||
|
|
||||||
for child in block['children']:
|
for child in block['children']:
|
||||||
last_accessed_block = get_last_accessed_block(child)
|
resume_block = get_resume_block(child)
|
||||||
if last_accessed_block:
|
if resume_block:
|
||||||
return last_accessed_block
|
return resume_block
|
||||||
return block
|
return block
|
||||||
|
|
||||||
course_outline_root_block = get_course_outline_block_tree(request, course_id)
|
course_outline_root_block = get_course_outline_block_tree(request, course_id)
|
||||||
last_accessed_block = get_last_accessed_block(course_outline_root_block) if course_outline_root_block else None
|
resume_block = get_resume_block(course_outline_root_block) if course_outline_root_block else None
|
||||||
has_visited_course = bool(last_accessed_block)
|
has_visited_course = bool(resume_block)
|
||||||
if last_accessed_block:
|
if resume_block:
|
||||||
resume_course_url = last_accessed_block['lms_web_url']
|
resume_course_url = resume_block['lms_web_url']
|
||||||
else:
|
else:
|
||||||
resume_course_url = course_outline_root_block['lms_web_url'] if course_outline_root_block else None
|
resume_course_url = course_outline_root_block['lms_web_url'] if course_outline_root_block else None
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user