Activate Next and Previous Buttons across sections
MA-2152 MA-2153
This commit is contained in:
@@ -37,6 +37,7 @@ from course_modes.tests.factories import CourseModeFactory
|
||||
from courseware.model_data import set_score
|
||||
from courseware.testutils import RenderXBlockTestMixin
|
||||
from courseware.tests.factories import StudentModuleFactory
|
||||
from courseware.url_helpers import get_redirect_url
|
||||
from courseware.user_state_client import DjangoXBlockUserStateClient
|
||||
from edxmako.tests import mako_middleware_process_request
|
||||
from lms.djangoapps.commerce.utils import EcommerceService # pylint: disable=import-error
|
||||
@@ -192,9 +193,25 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
super(ViewsTestCase, self).setUp()
|
||||
self.course = CourseFactory.create(display_name=u'teꜱᴛ course')
|
||||
self.chapter = ItemFactory.create(category='chapter', parent_location=self.course.location)
|
||||
self.section = ItemFactory.create(category='sequential', parent_location=self.chapter.location, due=datetime(2013, 9, 18, 11, 30, 00))
|
||||
self.section = ItemFactory.create(
|
||||
category='sequential',
|
||||
parent_location=self.chapter.location,
|
||||
due=datetime(2013, 9, 18, 11, 30, 00),
|
||||
)
|
||||
self.vertical = ItemFactory.create(category='vertical', parent_location=self.section.location)
|
||||
self.component = ItemFactory.create(category='problem', parent_location=self.vertical.location)
|
||||
self.component = ItemFactory.create(
|
||||
category='problem',
|
||||
parent_location=self.vertical.location,
|
||||
display_name='Problem 1',
|
||||
)
|
||||
|
||||
self.section2 = ItemFactory.create(category='sequential', parent_location=self.chapter.location)
|
||||
self.vertical2 = ItemFactory.create(category='vertical', parent_location=self.section2.location)
|
||||
ItemFactory.create(
|
||||
category='problem',
|
||||
parent_location=self.vertical2.location,
|
||||
display_name='Problem 2',
|
||||
)
|
||||
|
||||
self.course_key = self.course.id
|
||||
self.password = '123456'
|
||||
@@ -210,6 +227,74 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
self.org = u"ꜱᴛᴀʀᴋ ɪɴᴅᴜꜱᴛʀɪᴇꜱ"
|
||||
self.org_html = "<p>'+Stark/Industries+'</p>"
|
||||
|
||||
def test_index_success(self):
|
||||
response = self._verify_index_response()
|
||||
self.assertIn('Problem 2', response.content)
|
||||
|
||||
# re-access to the main course page redirects to last accessed view.
|
||||
url = reverse('courseware', kwargs={'course_id': unicode(self.course_key)})
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 302)
|
||||
response = self.client.get(response.url) # pylint: disable=no-member
|
||||
self.assertNotIn('Problem 1', response.content)
|
||||
self.assertIn('Problem 2', response.content)
|
||||
|
||||
def test_index_nonexistent_chapter(self):
|
||||
self._verify_index_response(expected_response_code=404, chapter_name='non-existent')
|
||||
|
||||
def test_index_nonexistent_chapter_masquerade(self):
|
||||
with patch('courseware.views.setup_masquerade') as patch_masquerade:
|
||||
masquerade = MagicMock(role='student')
|
||||
patch_masquerade.return_value = (masquerade, self.user)
|
||||
self._verify_index_response(expected_response_code=302, chapter_name='non-existent')
|
||||
|
||||
def test_index_nonexistent_section(self):
|
||||
self._verify_index_response(expected_response_code=404, section_name='non-existent')
|
||||
|
||||
def test_index_nonexistent_section_masquerade(self):
|
||||
with patch('courseware.views.setup_masquerade') as patch_masquerade:
|
||||
masquerade = MagicMock(role='student')
|
||||
patch_masquerade.return_value = (masquerade, self.user)
|
||||
self._verify_index_response(expected_response_code=302, section_name='non-existent')
|
||||
|
||||
def _verify_index_response(self, expected_response_code=200, chapter_name=None, section_name=None):
|
||||
"""
|
||||
Verifies the response when the courseware index page is accessed with
|
||||
the given chapter and section names.
|
||||
"""
|
||||
self.client.login(username=self.user.username, password=self.password)
|
||||
url = reverse(
|
||||
'courseware_section',
|
||||
kwargs={
|
||||
'course_id': unicode(self.course_key),
|
||||
'chapter': unicode(self.chapter.location.name) if chapter_name is None else chapter_name,
|
||||
'section': unicode(self.section2.location.name) if section_name is None else section_name,
|
||||
}
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, expected_response_code)
|
||||
return response
|
||||
|
||||
def test_index_no_visible_section_in_chapter(self):
|
||||
self.client.login(username=self.user.username, password=self.password)
|
||||
|
||||
# reload the chapter from the store so its children information is updated
|
||||
self.chapter = self.store.get_item(self.chapter.location)
|
||||
|
||||
# disable the visibility of the sections in the chapter
|
||||
for section in self.chapter.get_children():
|
||||
section.visible_to_staff_only = True
|
||||
self.store.update_item(section, ModuleStoreEnum.UserID.test)
|
||||
|
||||
url = reverse(
|
||||
'courseware_chapter',
|
||||
kwargs={'course_id': unicode(self.course.id), 'chapter': unicode(self.chapter.location.name)},
|
||||
)
|
||||
response = self.client.get(url)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertNotIn('Problem 1', response.content)
|
||||
self.assertNotIn('Problem 2', response.content)
|
||||
|
||||
@unittest.skipUnless(settings.FEATURES.get('ENABLE_SHOPPING_CART'), "Shopping Cart not enabled in settings")
|
||||
@patch.dict(settings.FEATURES, {'ENABLE_PAID_COURSE_REGISTRATION': True})
|
||||
def test_course_about_in_cart(self):
|
||||
@@ -299,15 +384,37 @@ class ViewsTestCase(ModuleStoreTestCase):
|
||||
self.assertEqual(views.user_groups(mock_user), [])
|
||||
|
||||
def test_get_current_child(self):
|
||||
self.assertIsNone(views.get_current_child(MagicMock()))
|
||||
mock_xmodule = MagicMock()
|
||||
self.assertIsNone(views.get_current_child(mock_xmodule))
|
||||
|
||||
mock_xmodule.position = -1
|
||||
mock_xmodule.get_display_items.return_value = ['one', 'two']
|
||||
mock_xmodule.get_display_items.return_value = ['one', 'two', 'three']
|
||||
self.assertEqual(views.get_current_child(mock_xmodule), 'one')
|
||||
mock_xmodule_2 = MagicMock()
|
||||
mock_xmodule_2.position = 3
|
||||
mock_xmodule_2.get_display_items.return_value = []
|
||||
self.assertIsNone(views.get_current_child(mock_xmodule_2))
|
||||
|
||||
mock_xmodule.position = 2
|
||||
self.assertEqual(views.get_current_child(mock_xmodule), 'two')
|
||||
self.assertEqual(views.get_current_child(mock_xmodule, requested_child='first'), 'one')
|
||||
self.assertEqual(views.get_current_child(mock_xmodule, requested_child='last'), 'three')
|
||||
|
||||
mock_xmodule.position = 3
|
||||
mock_xmodule.get_display_items.return_value = []
|
||||
self.assertIsNone(views.get_current_child(mock_xmodule))
|
||||
|
||||
def test_get_redirect_url(self):
|
||||
self.assertIn(
|
||||
'activate_block_id',
|
||||
get_redirect_url(self.course_key, self.section.location),
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'child=first',
|
||||
get_redirect_url(self.course_key, self.section.location, child='first'),
|
||||
)
|
||||
|
||||
self.assertIn(
|
||||
'child=last',
|
||||
get_redirect_url(self.course_key, self.section.location, child='last'),
|
||||
)
|
||||
|
||||
def test_redirect_to_course_position(self):
|
||||
mock_module = MagicMock()
|
||||
|
||||
@@ -7,12 +7,13 @@ from xmodule.modulestore.django import modulestore
|
||||
from django.core.urlresolvers import reverse
|
||||
|
||||
|
||||
def get_redirect_url(course_key, usage_key):
|
||||
def get_redirect_url(course_key, usage_key, child=None):
|
||||
""" Returns the redirect url back to courseware
|
||||
|
||||
Args:
|
||||
course_id(str): Course Id string
|
||||
location(str): The location id of course component
|
||||
child(str): Optional child parameter to pass to the URL
|
||||
|
||||
Raises:
|
||||
ItemNotFoundError if no data at the location or NoPathToItem if location not in any class
|
||||
@@ -50,4 +51,7 @@ def get_redirect_url(course_key, usage_key):
|
||||
|
||||
redirect_url += "?{}".format(urlencode({'activate_block_id': unicode(final_target_id)}))
|
||||
|
||||
if child:
|
||||
redirect_url += "&child={}".format(child)
|
||||
|
||||
return redirect_url
|
||||
|
||||
@@ -181,7 +181,7 @@ def render_accordion(user, request, course, chapter, section, field_data_cache):
|
||||
return render_to_string('courseware/accordion.html', context)
|
||||
|
||||
|
||||
def get_current_child(xmodule, min_depth=None):
|
||||
def get_current_child(xmodule, min_depth=None, requested_child=None):
|
||||
"""
|
||||
Get the xmodule.position's display item of an xmodule that has a position and
|
||||
children. If xmodule has no position or is out of bounds, return the first
|
||||
@@ -193,23 +193,36 @@ def get_current_child(xmodule, min_depth=None):
|
||||
|
||||
Returns None only if there are no children at all.
|
||||
"""
|
||||
def _get_child(children):
|
||||
"""
|
||||
Returns either the first or last child based on the value of
|
||||
the requested_child parameter. If requested_child is None,
|
||||
returns the first child.
|
||||
"""
|
||||
if requested_child == 'first':
|
||||
return children[0]
|
||||
elif requested_child == 'last':
|
||||
return children[-1]
|
||||
else:
|
||||
return children[0]
|
||||
|
||||
def _get_default_child_module(child_modules):
|
||||
"""Returns the first child of xmodule, subject to min_depth."""
|
||||
if not child_modules:
|
||||
default_child = None
|
||||
elif not min_depth > 0:
|
||||
default_child = child_modules[0]
|
||||
default_child = _get_child(child_modules)
|
||||
else:
|
||||
content_children = [child for child in child_modules if
|
||||
child.has_children_at_depth(min_depth - 1) and child.get_display_items()]
|
||||
default_child = content_children[0] if content_children else None
|
||||
default_child = _get_child(content_children) if content_children else None
|
||||
|
||||
return default_child
|
||||
|
||||
if not hasattr(xmodule, 'position'):
|
||||
return None
|
||||
|
||||
if xmodule.position is None:
|
||||
if xmodule.position is None or requested_child:
|
||||
return _get_default_child_module(xmodule.get_display_items())
|
||||
else:
|
||||
# position is 1-indexed.
|
||||
@@ -421,14 +434,6 @@ def _index_bulk_op(request, course_key, chapter, section, position):
|
||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||
course_key, user, course, depth=2)
|
||||
|
||||
course_module = get_module_for_descriptor(
|
||||
user, request, course, field_data_cache, course_key, course=course
|
||||
)
|
||||
if course_module is None:
|
||||
log.warning(u'If you see this, something went wrong: if we got this'
|
||||
u' far, should have gotten a course module for this user')
|
||||
return redirect(reverse('about_course', args=[course_key.to_deprecated_string()]))
|
||||
|
||||
studio_url = get_studio_url(course, 'course')
|
||||
|
||||
language_preference = get_user_preference(request.user, LANGUAGE_KEY)
|
||||
@@ -478,89 +483,89 @@ def _index_bulk_op(request, course_key, chapter, section, position):
|
||||
|
||||
# passing CONTENT_DEPTH avoids returning 404 for a course with an
|
||||
# empty first section and a second section with content
|
||||
return redirect_to_course_position(course_module, CONTENT_DEPTH)
|
||||
return redirect_to_course_position(course, CONTENT_DEPTH)
|
||||
|
||||
chapter_descriptor = course.get_child_by(lambda m: m.location.name == chapter)
|
||||
if chapter_descriptor is not None:
|
||||
save_child_position(course_module, chapter)
|
||||
save_child_position(course, chapter)
|
||||
else:
|
||||
raise Http404('No chapter descriptor found with name {}'.format(chapter))
|
||||
|
||||
chapter_module = course_module.get_child_by(lambda m: m.location.name == chapter)
|
||||
if chapter_module is None:
|
||||
# User may be trying to access a chapter that isn't live yet
|
||||
if masquerade and masquerade.role == 'student': # if staff is masquerading as student be kinder, don't 404
|
||||
log.debug('staff masquerading as student: no chapter %s', chapter)
|
||||
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
|
||||
raise Http404
|
||||
raise Http404('No chapter descriptor found with name {}'.format(chapter))
|
||||
|
||||
if course_has_entrance_exam(course):
|
||||
# Message should not appear outside the context of entrance exam subsection.
|
||||
# if section is none then we don't need to show message on welcome back screen also.
|
||||
if getattr(chapter_module, 'is_entrance_exam', False) and section is not None:
|
||||
if getattr(chapter_descriptor, 'is_entrance_exam', False) and section is not None:
|
||||
context['entrance_exam_current_score'] = get_entrance_exam_score(request, course)
|
||||
context['entrance_exam_passed'] = user_has_passed_entrance_exam(request, course)
|
||||
|
||||
if section is not None:
|
||||
section_descriptor = chapter_descriptor.get_child_by(lambda m: m.location.name == section)
|
||||
|
||||
if section_descriptor is None:
|
||||
# Specifically asked-for section doesn't exist
|
||||
if masquerade and masquerade.role == 'student': # don't 404 if staff is masquerading as student
|
||||
log.debug('staff masquerading as student: no section %s', section)
|
||||
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
|
||||
raise Http404
|
||||
|
||||
# Allow chromeless operation
|
||||
if section_descriptor.chrome:
|
||||
chrome = [s.strip() for s in section_descriptor.chrome.lower().split(",")]
|
||||
if 'accordion' not in chrome:
|
||||
context['disable_accordion'] = True
|
||||
if 'tabs' not in chrome:
|
||||
context['disable_tabs'] = True
|
||||
|
||||
if section_descriptor.default_tab:
|
||||
context['default_tab'] = section_descriptor.default_tab
|
||||
|
||||
# cdodge: this looks silly, but let's refetch the section_descriptor with depth=None
|
||||
# which will prefetch the children more efficiently than doing a recursive load
|
||||
section_descriptor = modulestore().get_item(section_descriptor.location, depth=None)
|
||||
|
||||
# Load all descendants of the section, because we're going to display its
|
||||
# html, which in general will need all of its children
|
||||
field_data_cache.add_descriptor_descendents(
|
||||
section_descriptor, depth=None
|
||||
)
|
||||
|
||||
section_module = get_module_for_descriptor(
|
||||
user,
|
||||
request,
|
||||
section_descriptor,
|
||||
field_data_cache,
|
||||
course_key,
|
||||
position,
|
||||
course=course
|
||||
)
|
||||
|
||||
if section_module is None:
|
||||
# User may be trying to be clever and access something
|
||||
# they don't have access to.
|
||||
raise Http404
|
||||
|
||||
# Save where we are in the chapter.
|
||||
save_child_position(chapter_module, section)
|
||||
section_render_context = {'activate_block_id': request.GET.get('activate_block_id')}
|
||||
context['fragment'] = section_module.render(STUDENT_VIEW, section_render_context)
|
||||
context['section_title'] = section_descriptor.display_name_with_default_escaped
|
||||
else:
|
||||
prev_section = get_current_child(chapter_module)
|
||||
if prev_section is None:
|
||||
if section is None:
|
||||
section_descriptor = get_current_child(chapter_descriptor, requested_child=request.GET.get("child"))
|
||||
if section_descriptor:
|
||||
section = section_descriptor.url_name
|
||||
else:
|
||||
# Something went wrong -- perhaps this chapter has no sections visible to the user.
|
||||
# Clearing out the last-visited state and showing "first-time" view by redirecting
|
||||
# to courseware.
|
||||
course_module.position = None
|
||||
course_module.save()
|
||||
course.position = None
|
||||
course.save()
|
||||
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
|
||||
else:
|
||||
section_descriptor = chapter_descriptor.get_child_by(lambda m: m.location.name == section)
|
||||
|
||||
if section_descriptor is None:
|
||||
# Specifically asked-for section doesn't exist
|
||||
if masquerade and masquerade.role == 'student': # don't 404 if staff is masquerading as student
|
||||
log.debug('staff masquerading as student: no section %s', section)
|
||||
return redirect(reverse('courseware', args=[course.id.to_deprecated_string()]))
|
||||
raise Http404
|
||||
|
||||
# Allow chromeless operation
|
||||
if section_descriptor.chrome:
|
||||
chrome = [s.strip() for s in section_descriptor.chrome.lower().split(",")]
|
||||
if 'accordion' not in chrome:
|
||||
context['disable_accordion'] = True
|
||||
if 'tabs' not in chrome:
|
||||
context['disable_tabs'] = True
|
||||
|
||||
if section_descriptor.default_tab:
|
||||
context['default_tab'] = section_descriptor.default_tab
|
||||
|
||||
# cdodge: this looks silly, but let's refetch the section_descriptor with depth=None
|
||||
# which will prefetch the children more efficiently than doing a recursive load
|
||||
section_descriptor = modulestore().get_item(section_descriptor.location, depth=None)
|
||||
|
||||
# Load all descendants of the section, because we're going to display its
|
||||
# html, which in general will need all of its children
|
||||
field_data_cache.add_descriptor_descendents(
|
||||
section_descriptor, depth=None
|
||||
)
|
||||
|
||||
section_module = get_module_for_descriptor(
|
||||
user,
|
||||
request,
|
||||
section_descriptor,
|
||||
field_data_cache,
|
||||
course_key,
|
||||
position,
|
||||
course=course
|
||||
)
|
||||
|
||||
# Save where we are in the chapter.
|
||||
save_child_position(chapter_descriptor, section)
|
||||
section_render_context = {
|
||||
'activate_block_id': request.GET.get('activate_block_id'),
|
||||
'redirect_url_func': (
|
||||
get_redirect_url if settings.FEATURES.get('ENABLE_NEXT_BUTTON_ACROSS_SECTIONS') else None
|
||||
),
|
||||
'requested_child': request.GET.get("child"),
|
||||
}
|
||||
context['accordion'] = render_accordion(user, request, course, chapter, section, field_data_cache)
|
||||
context['fragment'] = section_module.render(STUDENT_VIEW, section_render_context)
|
||||
context['section_title'] = section_descriptor.display_name_with_default_escaped
|
||||
result = render_to_response('courseware/courseware.html', context)
|
||||
except Exception as e:
|
||||
|
||||
|
||||
@@ -162,6 +162,9 @@ FEATURES['ENABLE_COURSEWARE_SEARCH'] = True
|
||||
# Enable dashboard search for tests
|
||||
FEATURES['ENABLE_DASHBOARD_SEARCH'] = True
|
||||
|
||||
# Enable cross-section Next button for tests
|
||||
FEATURES['ENABLE_NEXT_BUTTON_ACROSS_SECTIONS'] = True
|
||||
|
||||
# Use MockSearchEngine as the search engine for test scenario
|
||||
SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine"
|
||||
# Path at which to store the mock index
|
||||
|
||||
@@ -371,7 +371,10 @@ FEATURES = {
|
||||
# This is the default, but can be disabled if all history
|
||||
# lives in the Extended table, saving the frontend from
|
||||
# making multiple queries.
|
||||
'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True
|
||||
'ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES': True,
|
||||
|
||||
# Enable Next Button to jump sequences in Sequence Navigation bar.
|
||||
'ENABLE_NEXT_BUTTON_ACROSS_SECTIONS': False,
|
||||
}
|
||||
|
||||
# Ignore static asset files on import which match this pattern
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<%! from django.utils.translation import ugettext as _ %>
|
||||
|
||||
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" >
|
||||
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}" data-position="${position}" data-ajax-url="${ajax_url}" data-next-url="${next_url}" data-prev-url="${prev_url}">
|
||||
<div class="path"></div>
|
||||
<div class="sequence-nav">
|
||||
<button class="sequence-nav-button button-previous">
|
||||
|
||||
Reference in New Issue
Block a user