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()
|
||||
|
||||
@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):
|
||||
index_together = [
|
||||
('course_key', 'block_type', 'user'),
|
||||
@@ -176,6 +208,7 @@ class BlockCompletion(TimeStampedModel, models.Model):
|
||||
unique_together = [
|
||||
('course_key', 'block_key', 'user')
|
||||
]
|
||||
get_latest_by = 'modified'
|
||||
|
||||
def __unicode__(self):
|
||||
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.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 waffle
|
||||
from .. import models, waffle
|
||||
|
||||
|
||||
class PercentValidatorTestCase(TestCase):
|
||||
@@ -187,3 +186,64 @@ class SubmitBatchCompletionTestCase(TestCase):
|
||||
self.assertEqual(models.BlockCompletion.objects.count(), 1)
|
||||
model = models.BlockCompletion.objects.first()
|
||||
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[2]: 0.6,
|
||||
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 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
|
||||
WAFFLE_NAMESPACE = 'completion'
|
||||
WAFFLE_FLAG_NAMESPACE = WaffleFlagNamespace(name='completion')
|
||||
|
||||
# Switches
|
||||
|
||||
# Full name: completion.enable_completion_tracking
|
||||
# Indicates whether or not to track completion of individual blocks. Keeping
|
||||
# this disabled will prevent creation of BlockCompletion objects in the
|
||||
@@ -18,9 +20,74 @@ WAFFLE_NAMESPACE = 'completion'
|
||||
# xblocks.
|
||||
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():
|
||||
"""
|
||||
Returns the namespaced, cached, audited Waffle class for 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">
|
||||
% for subsection in section.get('children', []):
|
||||
<li
|
||||
class="subsection ${ 'current' if subsection['last_accessed'] else '' }"
|
||||
class="subsection ${ 'current' if subsection['resume_block'] else '' }"
|
||||
>
|
||||
<a
|
||||
class="outline-item focusable"
|
||||
@@ -125,7 +125,7 @@ from openedx.core.djangolib.markup import HTML, Text
|
||||
</div> <!-- /subsection-text -->
|
||||
<div class="subsection-actions">
|
||||
## Resume button (if last visited section)
|
||||
% if subsection['last_accessed']:
|
||||
% if subsection['resume_block']:
|
||||
<span class="resume-right">
|
||||
<b>${ _("Resume Course") }</b>
|
||||
<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 json
|
||||
|
||||
from django.contrib.sites.models import Site
|
||||
from django.core.urlresolvers import reverse
|
||||
from pyquery import PyQuery as pq
|
||||
from mock import Mock, patch
|
||||
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 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 pyquery import PyQuery as pq
|
||||
from student.models import CourseEnrollment
|
||||
from student.tests.factories import UserFactory
|
||||
from waffle.testutils import override_switch
|
||||
from xmodule.modulestore import ModuleStoreEnum
|
||||
from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase
|
||||
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
|
||||
|
||||
@@ -145,11 +152,28 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
|
||||
course.enable_subsection_gating = True
|
||||
course_blocks = {}
|
||||
with cls.store.bulk_operations(course.id):
|
||||
course_blocks['chapter'] = ItemFactory.create(category='chapter', parent_location=course.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_blocks['chapter'] = ItemFactory.create(
|
||||
category='chapter',
|
||||
parent_location=course.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_blocks['chapter'].children = [course_blocks['prerequisite'], course_blocks['gated_content']]
|
||||
course_blocks['prerequisite'].children = [course_blocks['prerequisite_vertical']]
|
||||
@@ -245,7 +269,7 @@ class TestCourseOutlinePageWithPrerequisites(SharedModuleStoreTestCase, Mileston
|
||||
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.
|
||||
|
||||
@@ -268,6 +292,12 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
"""Set up and enroll our fake user in the course."""
|
||||
cls.user = UserFactory(password=TEST_PASSWORD)
|
||||
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
|
||||
def create_test_course(cls):
|
||||
@@ -295,6 +325,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
"""
|
||||
super(TestCourseOutlineResumeCourse, self).setUp()
|
||||
self.client.login(username=self.user.username, password=TEST_PASSWORD)
|
||||
self.override_waffle_switch(False)
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
"""
|
||||
Tests that the start course button appears when the course has never been accessed.
|
||||
@@ -320,13 +366,9 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
"""
|
||||
course = self.course
|
||||
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, 'Start Course', count=1)
|
||||
self.assertContains(response, 'Resume Course', count=0)
|
||||
|
||||
response = self.visit_course_home(course, start_count=1, resume_count=0)
|
||||
content = pq(response.content)
|
||||
|
||||
self.assertTrue(content('.action-resume-course').attr('href').endswith('/course/' + course.url_name))
|
||||
|
||||
def test_resume_course(self):
|
||||
@@ -338,17 +380,75 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
# first navigate to a sequential to make it the last accessed
|
||||
chapter = course.children[0]
|
||||
sequential = chapter.children[0]
|
||||
vertical = sequential.children[0]
|
||||
self.visit_sequential(course, chapter, sequential)
|
||||
|
||||
# check resume course buttons
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, 'Start Course', count=0)
|
||||
self.assertContains(response, 'Resume Course', count=2)
|
||||
|
||||
response = self.visit_course_home(course, resume_count=2)
|
||||
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):
|
||||
"""
|
||||
@@ -370,11 +470,7 @@ class TestCourseOutlineResumeCourse(SharedModuleStoreTestCase):
|
||||
self.store.delete_item(sequential.location, self.user.id)
|
||||
|
||||
# check resume course buttons
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, 'Start Course', count=0)
|
||||
self.assertContains(response, 'Resume Course', count=2)
|
||||
response = self.visit_course_home(course, resume_count=2)
|
||||
|
||||
content = pq(response.content)
|
||||
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)
|
||||
|
||||
# check resume course buttons
|
||||
response = self.client.get(course_home_url(course))
|
||||
self.assertEqual(response.status_code, 200)
|
||||
|
||||
self.assertContains(response, 'Start Course', count=0)
|
||||
self.assertContains(response, 'Resume Course', count=1)
|
||||
self.visit_course_home(course, resume_count=1)
|
||||
|
||||
|
||||
class TestCourseOutlinePreview(SharedModuleStoreTestCase):
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
"""
|
||||
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_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 xmodule.modulestore.django import modulestore
|
||||
|
||||
@@ -35,29 +37,84 @@ def get_course_outline_block_tree(request, course_id):
|
||||
|
||||
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', []):
|
||||
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):
|
||||
"""
|
||||
Recursively marks the branch to the last accessed block.
|
||||
"""
|
||||
block_key = block.serializer.instance
|
||||
student_module_dict = get_student_module_as_dict(user, course_key, block_key)
|
||||
|
||||
last_accessed_child_position = student_module_dict.get('position')
|
||||
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']):
|
||||
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)
|
||||
else:
|
||||
# We should be using an id in place of position for last accessed. However, while using position, if
|
||||
# the child block is no longer accessible we'll use the last child.
|
||||
block['children'][-1]['last_accessed'] = True
|
||||
# We should be using an id in place of position for last accessed.
|
||||
# However, while using position, if the child block is no longer accessible
|
||||
# we'll use the last child.
|
||||
block['children'][-1]['resume_block'] = True
|
||||
|
||||
course_key = CourseKey.from_string(course_id)
|
||||
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,
|
||||
user=request.user,
|
||||
nav_depth=3,
|
||||
requested_fields=['children', 'display_name', 'type', 'due', 'graded', 'special_exam_info', 'show_gated_sections', 'format'],
|
||||
block_types_filter=['course', 'chapter', 'sequential']
|
||||
requested_fields=[
|
||||
'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)
|
||||
if course_outline_root_block:
|
||||
populate_children(course_outline_root_block, all_blocks['blocks'])
|
||||
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
|
||||
|
||||
@@ -8,7 +8,7 @@ from django.template.loader import render_to_string
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_control
|
||||
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 course_modes.models import get_cosmetic_verified_display_price
|
||||
@@ -37,6 +37,7 @@ from .course_sock import CourseSockFragmentView
|
||||
from .latest_update import LatestUpdateFragmentView
|
||||
from .welcome_message import WelcomeMessageFragmentView
|
||||
|
||||
|
||||
EMPTY_HANDOUTS_HTML = u'<ol></ol>'
|
||||
|
||||
|
||||
@@ -76,30 +77,32 @@ class CourseHomeFragmentView(EdxFragmentView):
|
||||
|
||||
Returns a tuple: (has_visited_course, resume_course_url)
|
||||
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.
|
||||
|
||||
"""
|
||||
|
||||
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
|
||||
if not block.get('children'):
|
||||
return block
|
||||
|
||||
for child in block['children']:
|
||||
last_accessed_block = get_last_accessed_block(child)
|
||||
if last_accessed_block:
|
||||
return last_accessed_block
|
||||
resume_block = get_resume_block(child)
|
||||
if resume_block:
|
||||
return resume_block
|
||||
return block
|
||||
|
||||
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
|
||||
has_visited_course = bool(last_accessed_block)
|
||||
if last_accessed_block:
|
||||
resume_course_url = last_accessed_block['lms_web_url']
|
||||
resume_block = get_resume_block(course_outline_root_block) if course_outline_root_block else None
|
||||
has_visited_course = bool(resume_block)
|
||||
if resume_block:
|
||||
resume_course_url = resume_block['lms_web_url']
|
||||
else:
|
||||
resume_course_url = course_outline_root_block['lms_web_url'] if course_outline_root_block else None
|
||||
|
||||
|
||||
Reference in New Issue
Block a user