fix: the get_completion JS handler should not ignore FBE blocks
Because xblock handlers normally get a block tree that already has inaccessible blocks stripped, they don't see FBE gated blocks at all. So the get_completion handler would return True for FBE units incorrectly. Leading to a visual bug as an audit user went through their units in the courseware. In order to let the handler know about the full tree, I've added a new attribute you can set on your xblock handlers: my_handler.will_recheck_access = True This will tell the top-level handler code get the full tree for you. As part of this, I've also changed the sequence xblock handler's into proper xblock handlers (not old-style xmodule handlers). This changes their URLs slightly. I've kept the old URLs for now as well, but they'll be removed after Maple. AA-409
This commit is contained in:
@@ -12,9 +12,10 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
beforeEach(function() {
|
beforeEach(function() {
|
||||||
|
var runtime = jasmine.createSpyObj('TestRuntime', ['handlerUrl']);
|
||||||
loadFixtures('sequence.html');
|
loadFixtures('sequence.html');
|
||||||
local.XBlock = window.XBlock = jasmine.createSpyObj('XBlock', ['initializeBlocks']);
|
local.XBlock = window.XBlock = jasmine.createSpyObj('XBlock', ['initializeBlocks']);
|
||||||
this.sequence = new Sequence($('.xblock-student_view-sequential'));
|
this.sequence = new Sequence($('.xblock-student_view-sequential'), runtime);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(function() {
|
afterEach(function() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
this.Sequence = (function() {
|
this.Sequence = (function() {
|
||||||
function Sequence(element) {
|
function Sequence(element, runtime) {
|
||||||
var self = this;
|
var self = this;
|
||||||
|
|
||||||
this.removeBookmarkIconFromActiveNavItem = function(event) {
|
this.removeBookmarkIconFromActiveNavItem = function(event) {
|
||||||
@@ -54,7 +54,8 @@
|
|||||||
this.sr_container = this.$('.sr-is-focusable');
|
this.sr_container = this.$('.sr-is-focusable');
|
||||||
this.num_contents = this.contents.length;
|
this.num_contents = this.contents.length;
|
||||||
this.id = this.el.data('id');
|
this.id = this.el.data('id');
|
||||||
this.ajaxUrl = this.el.data('ajax-url');
|
this.getCompletionUrl = runtime.handlerUrl(element, 'get_completion');
|
||||||
|
this.gotoPositionUrl = runtime.handlerUrl(element, 'goto_position');
|
||||||
this.nextUrl = this.el.data('next-url');
|
this.nextUrl = this.el.data('next-url');
|
||||||
this.prevUrl = this.el.data('prev-url');
|
this.prevUrl = this.el.data('prev-url');
|
||||||
this.savePosition = this.el.data('save-position');
|
this.savePosition = this.el.data('save-position');
|
||||||
@@ -227,7 +228,7 @@
|
|||||||
};
|
};
|
||||||
|
|
||||||
Sequence.prototype.render = function(newPosition) {
|
Sequence.prototype.render = function(newPosition) {
|
||||||
var bookmarked, currentTab, modxFullUrl, sequenceLinks,
|
var bookmarked, currentTab, sequenceLinks,
|
||||||
self = this;
|
self = this;
|
||||||
if (this.position !== newPosition) {
|
if (this.position !== newPosition) {
|
||||||
if (this.position) {
|
if (this.position) {
|
||||||
@@ -236,10 +237,9 @@
|
|||||||
this.update_completion(this.position);
|
this.update_completion(this.position);
|
||||||
}
|
}
|
||||||
if (this.savePosition) {
|
if (this.savePosition) {
|
||||||
modxFullUrl = '' + this.ajaxUrl + '/goto_position';
|
$.postWithPrefix(this.gotoPositionUrl, JSON.stringify({
|
||||||
$.postWithPrefix(modxFullUrl, {
|
|
||||||
position: newPosition
|
position: newPosition
|
||||||
});
|
}));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -414,13 +414,12 @@
|
|||||||
|
|
||||||
Sequence.prototype.update_completion = function(position) {
|
Sequence.prototype.update_completion = function(position) {
|
||||||
var element = this.link_for(position);
|
var element = this.link_for(position);
|
||||||
var completionUrl = this.ajaxUrl + '/get_completion';
|
|
||||||
var usageKey = element[0].attributes['data-id'].value;
|
var usageKey = element[0].attributes['data-id'].value;
|
||||||
var completionIndicators = element.find('.check-circle');
|
var completionIndicators = element.find('.check-circle');
|
||||||
if (completionIndicators.length) {
|
if (completionIndicators.length) {
|
||||||
$.postWithPrefix(completionUrl, {
|
$.postWithPrefix(this.getCompletionUrl, JSON.stringify({
|
||||||
usage_key: usageKey
|
usage_key: usageKey
|
||||||
}, function(data) {
|
}), function(data) {
|
||||||
if (data.complete === true) {
|
if (data.complete === true) {
|
||||||
completionIndicators.removeClass('is-hidden');
|
completionIndicators.removeClass('is-hidden');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -78,7 +78,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
module = new window[moduleType](element);
|
module = new window[moduleType](element, runtime);
|
||||||
|
|
||||||
if ($(element).hasClass('xmodule_edit')) {
|
if ($(element).hasClass('xmodule_edit')) {
|
||||||
$(document).trigger('XModule.loaded.edit', [element, module]);
|
$(document).trigger('XModule.loaded.edit', [element, module]);
|
||||||
|
|||||||
@@ -308,51 +308,76 @@ class SequenceBlock(
|
|||||||
progress = reduce(Progress.add_counts, progresses, None)
|
progress = reduce(Progress.add_counts, progresses, None)
|
||||||
return progress
|
return progress
|
||||||
|
|
||||||
def handle_ajax(self, dispatch, data, view=STUDENT_VIEW): # TODO: bounds checking # lint-amnesty, pylint: disable=arguments-differ
|
@XBlock.json_handler
|
||||||
''' get = request.POST instance '''
|
def get_completion(self, data, _suffix=''):
|
||||||
if dispatch == 'goto_position':
|
"""Returns whether the provided vertical is complete based off the 'usage_key' value in the incoming dict"""
|
||||||
# set position to default value if either 'position' argument not
|
return self._get_completion(data)
|
||||||
# found in request or it is a non-positive integer
|
# This 'will_recheck_access' attribute is checked by the upper-level handler code, where it will avoid stripping
|
||||||
position = data.get('position', '1')
|
# inaccessible blocks from our tree. We don't want them stripped because 'get_completion' needs to know about FBE
|
||||||
if position.isdigit() and int(position) > 0:
|
# blocks even if the user can't complete them, otherwise it might accidentally say a vertical is complete when
|
||||||
self.position = int(position)
|
# there are still incomplete but access-locked blocks left.
|
||||||
else:
|
get_completion.will_recheck_access = True
|
||||||
self.position = 1
|
|
||||||
return json.dumps({'success': True})
|
|
||||||
|
|
||||||
if dispatch == 'get_completion':
|
def _get_completion(self, data):
|
||||||
completion_service = self.runtime.service(self, 'completion')
|
"""Returns whether the provided vertical is complete based off the 'usage_key' value in the incoming dict"""
|
||||||
|
complete = False
|
||||||
usage_key = data.get('usage_key', None)
|
usage_key = data.get('usage_key', None)
|
||||||
if not usage_key:
|
if usage_key:
|
||||||
return None
|
|
||||||
item = self.get_child(UsageKey.from_string(usage_key))
|
item = self.get_child(UsageKey.from_string(usage_key))
|
||||||
if not item:
|
if item:
|
||||||
return None
|
completion_service = self.runtime.service(self, 'completion')
|
||||||
|
complete = completion_service.vertical_is_complete(item)
|
||||||
|
return {'complete': complete}
|
||||||
|
|
||||||
complete = completion_service.vertical_is_complete(item)
|
@XBlock.json_handler
|
||||||
return json.dumps({
|
def goto_position(self, data, _suffix=''):
|
||||||
'complete': complete
|
"""Sets the xblock position based off the 'position' value in the incoming dict"""
|
||||||
})
|
return self._goto_position(data)
|
||||||
elif dispatch == 'metadata':
|
|
||||||
context = {'exclude_units': True}
|
|
||||||
prereq_met = True
|
|
||||||
prereq_meta_info = {}
|
|
||||||
banner_text = None
|
|
||||||
display_items = self.get_display_items()
|
|
||||||
|
|
||||||
if self._required_prereq():
|
def _goto_position(self, data):
|
||||||
if self.runtime.user_is_staff:
|
"""Sets the xblock position based off the 'position' value in the incoming dict"""
|
||||||
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') # lint-amnesty, pylint: disable=line-too-long
|
# set position to default value if either 'position' argument not
|
||||||
else:
|
# found in request or it is a non-positive integer
|
||||||
# check if prerequisite has been met
|
position = data.get('position', 1)
|
||||||
prereq_met, prereq_meta_info = self._compute_is_prereq_met(True)
|
if isinstance(position, int) and position > 0:
|
||||||
meta = self._get_render_metadata(context, display_items, prereq_met, prereq_meta_info, banner_text, view)
|
self.position = position
|
||||||
meta['display_name'] = self.display_name_with_default
|
else:
|
||||||
meta['format'] = getattr(self, 'format', '')
|
self.position = 1
|
||||||
return json.dumps(meta)
|
return {'success': True}
|
||||||
|
|
||||||
|
# If you are reading this and it's past the 'Maple' Open edX release, you can delete this handle_ajax method, as
|
||||||
|
# these are now individual xblock-style handler methods. We want to keep these around for a single release, simply
|
||||||
|
# to handle learners that haven't refreshed their courseware page when the server gets updated and their old
|
||||||
|
# javascript calls these old handlers.
|
||||||
|
# If you do clean this up, you can also move the internal private versions just directly into the handler methods,
|
||||||
|
# as nothing else calls them (at time of writing).
|
||||||
|
def handle_ajax(self, dispatch, data):
|
||||||
|
"""Old xmodule-style ajax handler"""
|
||||||
|
if dispatch == 'goto_position':
|
||||||
|
return json.dumps(self._goto_position(data))
|
||||||
|
elif dispatch == 'get_completion':
|
||||||
|
return json.dumps(self._get_completion(data))
|
||||||
raise NotFoundError('Unexpected dispatch type')
|
raise NotFoundError('Unexpected dispatch type')
|
||||||
|
|
||||||
|
def get_metadata(self, view=STUDENT_VIEW):
|
||||||
|
"""Returns a dict of some common block properties"""
|
||||||
|
context = {'exclude_units': True}
|
||||||
|
prereq_met = True
|
||||||
|
prereq_meta_info = {}
|
||||||
|
banner_text = None
|
||||||
|
display_items = self.get_display_items()
|
||||||
|
|
||||||
|
if self._required_prereq():
|
||||||
|
if self.runtime.user_is_staff:
|
||||||
|
banner_text = _('This subsection is unlocked for learners when they meet the prerequisite requirements.') # lint-amnesty, pylint: disable=line-too-long
|
||||||
|
else:
|
||||||
|
# check if prerequisite has been met
|
||||||
|
prereq_met, prereq_meta_info = self._compute_is_prereq_met(True)
|
||||||
|
meta = self._get_render_metadata(context, display_items, prereq_met, prereq_meta_info, banner_text, view)
|
||||||
|
meta['display_name'] = self.display_name_with_default
|
||||||
|
meta['format'] = getattr(self, 'format', '')
|
||||||
|
return meta
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def verify_current_content_visibility(cls, date, hide_after_date):
|
def verify_current_content_visibility(cls, date, hide_after_date):
|
||||||
"""
|
"""
|
||||||
@@ -518,7 +543,6 @@ class SequenceBlock(
|
|||||||
'is_time_limited': self.is_time_limited,
|
'is_time_limited': self.is_time_limited,
|
||||||
'position': self.position,
|
'position': self.position,
|
||||||
'tag': self.location.block_type,
|
'tag': self.location.block_type,
|
||||||
'ajax_url': self.ajax_url,
|
|
||||||
'next_url': context.get('next_url'),
|
'next_url': context.get('next_url'),
|
||||||
'prev_url': context.get('prev_url'),
|
'prev_url': context.get('prev_url'),
|
||||||
'banner_text': banner_text,
|
'banner_text': banner_text,
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta
|
|||||||
from unittest.mock import Mock, patch
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
import ddt
|
import ddt
|
||||||
|
from django.test import RequestFactory
|
||||||
from django.test.utils import override_settings
|
from django.test.utils import override_settings
|
||||||
from django.utils.timezone import now
|
from django.utils.timezone import now
|
||||||
from freezegun import freeze_time
|
from freezegun import freeze_time
|
||||||
@@ -369,42 +370,71 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
|||||||
# assert content shown as normal
|
# assert content shown as normal
|
||||||
self._assert_ungated(html, self.sequence_1_2)
|
self._assert_ungated(html, self.sequence_1_2)
|
||||||
|
|
||||||
def test_handle_ajax_get_completion_success(self):
|
def test_xblock_handler_get_completion_success(self):
|
||||||
"""
|
"""Test that the completion data is returned successfully on targeted vertical through ajax call"""
|
||||||
Test that the completion data is returned successfully on
|
|
||||||
targeted vertical through ajax call
|
|
||||||
"""
|
|
||||||
for child in self.sequence_3_1.get_children():
|
for child in self.sequence_3_1.get_children():
|
||||||
usage_key = str(child.location)
|
usage_key = str(child.location)
|
||||||
completion_return = json.loads(self.sequence_3_1.handle_ajax(
|
request = RequestFactory().post(
|
||||||
'get_completion',
|
'/',
|
||||||
{'usage_key': usage_key}
|
data=json.dumps({'usage_key': usage_key}),
|
||||||
))
|
content_type='application/json',
|
||||||
assert completion_return is not None
|
)
|
||||||
assert 'complete' in completion_return
|
completion_return = self.sequence_3_1.handle('get_completion', request)
|
||||||
assert completion_return['complete'] is True
|
assert completion_return.json == {'complete': True}
|
||||||
|
|
||||||
def test_handle_ajax_get_completion_return_none(self):
|
def test_xblock_handler_get_completion_bad_key(self):
|
||||||
"""
|
"""Test that the completion data is returned as False when usage key is None through ajax call"""
|
||||||
Test that the completion data is returned successfully None
|
request = RequestFactory().post(
|
||||||
when usage key is None through ajax call
|
'/',
|
||||||
"""
|
data=json.dumps({'usage_key': None}),
|
||||||
usage_key = None
|
content_type='application/json',
|
||||||
completion_return = self.sequence_3_1.handle_ajax(
|
|
||||||
'get_completion',
|
|
||||||
{'usage_key': usage_key}
|
|
||||||
)
|
)
|
||||||
assert completion_return is None
|
completion_return = self.sequence_3_1.handle('get_completion', request)
|
||||||
|
assert completion_return.json == {'complete': False}
|
||||||
|
|
||||||
def test_handle_ajax_metadata(self):
|
def test_handle_ajax_get_completion_success(self):
|
||||||
"""
|
"""Test that the old-style ajax handler for completion still works"""
|
||||||
Test that the sequence metadata is returned from the
|
for child in self.sequence_3_1.get_children():
|
||||||
metadata ajax handler.
|
usage_key = str(child.location)
|
||||||
"""
|
completion_return = self.sequence_3_1.handle_ajax('get_completion', {'usage_key': usage_key})
|
||||||
|
assert json.loads(completion_return) == {'complete': True}
|
||||||
|
|
||||||
|
def test_xblock_handler_goto_position_success(self):
|
||||||
|
"""Test that we can set position through ajax call"""
|
||||||
|
assert self.sequence_3_1.position != 5
|
||||||
|
request = RequestFactory().post(
|
||||||
|
'/',
|
||||||
|
data=json.dumps({'position': 5}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
goto_return = self.sequence_3_1.handle('goto_position', request)
|
||||||
|
assert goto_return.json == {'success': True}
|
||||||
|
assert self.sequence_3_1.position == 5
|
||||||
|
|
||||||
|
def test_xblock_handler_goto_position_bad_position(self):
|
||||||
|
"""Test that we gracefully handle bad positions as position 1"""
|
||||||
|
assert self.sequence_3_1.position != 1
|
||||||
|
request = RequestFactory().post(
|
||||||
|
'/',
|
||||||
|
data=json.dumps({'position': -10}),
|
||||||
|
content_type='application/json',
|
||||||
|
)
|
||||||
|
goto_return = self.sequence_3_1.handle('goto_position', request)
|
||||||
|
assert goto_return.json == {'success': True}
|
||||||
|
assert self.sequence_3_1.position == 1
|
||||||
|
|
||||||
|
def test_handle_ajax_goto_position_success(self):
|
||||||
|
"""Test that the old-style ajax handler for setting position still works"""
|
||||||
|
goto_return = self.sequence_3_1.handle_ajax('goto_position', {'position': 5})
|
||||||
|
assert json.loads(goto_return) == {'success': True}
|
||||||
|
assert self.sequence_3_1.position == 5
|
||||||
|
|
||||||
|
def test_get_metadata(self):
|
||||||
|
"""Test that the sequence metadata is returned correctly"""
|
||||||
# rather than dealing with json serialization of the Mock object,
|
# rather than dealing with json serialization of the Mock object,
|
||||||
# let's just disable the bookmarks service
|
# let's just disable the bookmarks service
|
||||||
self.sequence_3_1.xmodule_runtime._services['bookmarks'] = None # lint-amnesty, pylint: disable=protected-access
|
self.sequence_3_1.xmodule_runtime._services['bookmarks'] = None # lint-amnesty, pylint: disable=protected-access
|
||||||
metadata = json.loads(self.sequence_3_1.handle_ajax('metadata', {}))
|
metadata = self.sequence_3_1.get_metadata()
|
||||||
assert len(metadata['items']) == 3
|
assert len(metadata['items']) == 3
|
||||||
assert metadata['tag'] == 'sequential'
|
assert metadata['tag'] == 'sequential'
|
||||||
assert metadata['display_name'] == self.sequence_3_1.display_name_with_default
|
assert metadata['display_name'] == self.sequence_3_1.display_name_with_default
|
||||||
@@ -412,14 +442,11 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
|||||||
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
@override_settings(FIELD_OVERRIDE_PROVIDERS=(
|
||||||
'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',
|
'openedx.features.content_type_gating.field_override.ContentTypeGatingFieldOverride',
|
||||||
))
|
))
|
||||||
def test_handle_ajax_metadata_content_type_gated_content(self):
|
def test_get_metadata_content_type_gated_content(self):
|
||||||
"""
|
"""The contains_content_type_gated_content field tells whether the item contains content type gated content"""
|
||||||
The contains_content_type_gated_content field should reflect
|
|
||||||
whether the given item contains content type gated content
|
|
||||||
"""
|
|
||||||
self.sequence_5_1.xmodule_runtime._services['bookmarks'] = None # pylint: disable=protected-access
|
self.sequence_5_1.xmodule_runtime._services['bookmarks'] = None # pylint: disable=protected-access
|
||||||
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
|
ContentTypeGatingConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1))
|
||||||
metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {}))
|
metadata = self.sequence_5_1.get_metadata()
|
||||||
assert metadata['items'][0]['contains_content_type_gated_content'] is False
|
assert metadata['items'][0]['contains_content_type_gated_content'] is False
|
||||||
|
|
||||||
# When a block contains content type gated problems, set the contains_content_type_gated_content field
|
# When a block contains content type gated problems, set the contains_content_type_gated_content field
|
||||||
@@ -428,7 +455,7 @@ class SequenceBlockTestCase(XModuleXmlImportTest):
|
|||||||
enabled_for_enrollment=Mock(return_value=True),
|
enabled_for_enrollment=Mock(return_value=True),
|
||||||
content_type_gate_for_block=Mock(return_value=Fragment('i_am_gated'))
|
content_type_gate_for_block=Mock(return_value=Fragment('i_am_gated'))
|
||||||
))
|
))
|
||||||
metadata = json.loads(self.sequence_5_1.handle_ajax('metadata', {}))
|
metadata = self.sequence_5_1.get_metadata()
|
||||||
assert metadata['items'][0]['contains_content_type_gated_content'] is True
|
assert metadata['items'][0]['contains_content_type_gated_content'] is True
|
||||||
|
|
||||||
def get_context_dict_from_string(self, data):
|
def get_context_dict_from_string(self, data):
|
||||||
|
|||||||
@@ -1068,31 +1068,32 @@ def handle_xblock_callback(request, course_id, usage_id, handler, suffix=None):
|
|||||||
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
|
return _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=course)
|
||||||
|
|
||||||
|
|
||||||
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None,
|
def _get_usage_key_for_course(course_key, usage_id) -> UsageKey:
|
||||||
will_recheck_access=False):
|
|
||||||
"""
|
"""
|
||||||
Gets a module instance based on its `usage_id` in a course, for a given request/user
|
Returns UsageKey mapped into the course for a given usage_id string
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
return UsageKey.from_string(unquote_slashes(usage_id)).map_into_course(course_key)
|
||||||
|
except InvalidKeyError as exc:
|
||||||
|
raise Http404("Invalid location") from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _get_descriptor_by_usage_key(usage_key):
|
||||||
|
"""
|
||||||
|
Gets a descriptor instance based on a mapped-to-course usage_key
|
||||||
|
|
||||||
Returns (instance, tracking_context)
|
Returns (instance, tracking_context)
|
||||||
"""
|
"""
|
||||||
user = request.user
|
|
||||||
|
|
||||||
try:
|
|
||||||
course_id = CourseKey.from_string(course_id)
|
|
||||||
usage_key = UsageKey.from_string(unquote_slashes(usage_id)).map_into_course(course_id)
|
|
||||||
except InvalidKeyError:
|
|
||||||
raise Http404("Invalid location") # lint-amnesty, pylint: disable=raise-missing-from
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
descriptor = modulestore().get_item(usage_key)
|
descriptor = modulestore().get_item(usage_key)
|
||||||
descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
|
descriptor_orig_usage_key, descriptor_orig_version = modulestore().get_block_original_usage(usage_key)
|
||||||
except ItemNotFoundError:
|
except ItemNotFoundError as exc:
|
||||||
log.warning(
|
log.warning(
|
||||||
"Invalid location for course id %s: %s",
|
"Invalid location for course id %s: %s",
|
||||||
usage_key.course_key,
|
usage_key.course_key,
|
||||||
usage_key
|
usage_key
|
||||||
)
|
)
|
||||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
raise Http404 from exc
|
||||||
|
|
||||||
tracking_context = {
|
tracking_context = {
|
||||||
'module': {
|
'module': {
|
||||||
@@ -1107,9 +1108,23 @@ def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_inf
|
|||||||
tracking_context['module']['original_usage_key'] = str(descriptor_orig_usage_key)
|
tracking_context['module']['original_usage_key'] = str(descriptor_orig_usage_key)
|
||||||
tracking_context['module']['original_usage_version'] = str(descriptor_orig_version)
|
tracking_context['module']['original_usage_version'] = str(descriptor_orig_version)
|
||||||
|
|
||||||
unused_masquerade, user = setup_masquerade(request, course_id, has_access(user, 'staff', descriptor, course_id))
|
return descriptor, tracking_context
|
||||||
|
|
||||||
|
|
||||||
|
def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_info=False, course=None,
|
||||||
|
will_recheck_access=False):
|
||||||
|
"""
|
||||||
|
Gets a module instance based on its `usage_id` in a course, for a given request/user
|
||||||
|
|
||||||
|
Returns (instance, tracking_context)
|
||||||
|
"""
|
||||||
|
course_key = CourseKey.from_string(course_id)
|
||||||
|
usage_key = _get_usage_key_for_course(course_key, usage_id)
|
||||||
|
descriptor, tracking_context = _get_descriptor_by_usage_key(usage_key)
|
||||||
|
|
||||||
|
_, user = setup_masquerade(request, course_key, has_access(request.user, 'staff', descriptor, course_key))
|
||||||
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
field_data_cache = FieldDataCache.cache_for_descriptor_descendents(
|
||||||
course_id,
|
course_key,
|
||||||
user,
|
user,
|
||||||
descriptor,
|
descriptor,
|
||||||
read_only=CrawlersConfig.is_crawler(request),
|
read_only=CrawlersConfig.is_crawler(request),
|
||||||
@@ -1130,7 +1145,7 @@ def get_module_by_usage_id(request, course_id, usage_id, disable_staff_debug_inf
|
|||||||
log.debug("No module %s for user %s -- access denied?", usage_key, user)
|
log.debug("No module %s for user %s -- access denied?", usage_key, user)
|
||||||
raise Http404
|
raise Http404
|
||||||
|
|
||||||
return (instance, tracking_context)
|
return instance, tracking_context
|
||||||
|
|
||||||
|
|
||||||
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=None):
|
def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course=None):
|
||||||
@@ -1154,23 +1169,31 @@ def _invoke_xblock_handler(request, course_id, usage_id, handler, suffix, course
|
|||||||
# Make a CourseKey from the course_id, raising a 404 upon parse error.
|
# Make a CourseKey from the course_id, raising a 404 upon parse error.
|
||||||
try:
|
try:
|
||||||
course_key = CourseKey.from_string(course_id)
|
course_key = CourseKey.from_string(course_id)
|
||||||
except InvalidKeyError:
|
except InvalidKeyError as exc:
|
||||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
raise Http404 from exc
|
||||||
|
|
||||||
set_custom_attributes_for_course_key(course_key)
|
set_custom_attributes_for_course_key(course_key)
|
||||||
|
|
||||||
with modulestore().bulk_operations(course_key):
|
with modulestore().bulk_operations(course_key):
|
||||||
try:
|
usage_key = _get_usage_key_for_course(course_key, usage_id)
|
||||||
usage_key = UsageKey.from_string(unquote_slashes(usage_id))
|
|
||||||
except InvalidKeyError:
|
|
||||||
raise Http404 # lint-amnesty, pylint: disable=raise-missing-from
|
|
||||||
if is_xblock_aside(usage_key):
|
if is_xblock_aside(usage_key):
|
||||||
# Get the usage key for the block being wrapped by the aside (not the aside itself)
|
# Get the usage key for the block being wrapped by the aside (not the aside itself)
|
||||||
block_usage_key = usage_key.usage_key
|
block_usage_key = usage_key.usage_key
|
||||||
else:
|
else:
|
||||||
block_usage_key = usage_key
|
block_usage_key = usage_key
|
||||||
|
|
||||||
|
# Peek at the handler method to see if it actually wants to check access itself. (The handler may not want
|
||||||
|
# inaccessible blocks stripped from the tree.) This ends up doing two modulestore lookups for the descriptor,
|
||||||
|
# but the blocks should be available in the request cache the second time.
|
||||||
|
# At the time of writing, this is only used by one handler. If this usage grows, we may want to re-evaluate
|
||||||
|
# how we do this to something more elegant. If you are the author of a third party block that decides it wants
|
||||||
|
# to set this too, please let us know so we can consider making this easier / better-documented.
|
||||||
|
descriptor, _ = _get_descriptor_by_usage_key(block_usage_key)
|
||||||
|
handler_method = getattr(descriptor, handler, False)
|
||||||
|
will_recheck_access = handler_method and getattr(handler_method, 'will_recheck_access', False)
|
||||||
|
|
||||||
instance, tracking_context = get_module_by_usage_id(
|
instance, tracking_context = get_module_by_usage_id(
|
||||||
request, course_id, str(block_usage_key), course=course
|
request, course_id, str(block_usage_key), course=course, will_recheck_access=will_recheck_access,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Name the transaction so that we can view XBlock handlers separately in
|
# Name the transaction so that we can view XBlock handlers separately in
|
||||||
|
|||||||
@@ -897,6 +897,31 @@ class TestHandleXBlockCallback(SharedModuleStoreTestCase, LoginEnrollmentTestCas
|
|||||||
)
|
)
|
||||||
assert not mock_score_signal.called
|
assert not mock_score_signal.called
|
||||||
|
|
||||||
|
@ddt.data(
|
||||||
|
# See seq_module.py for the definition of these handlers
|
||||||
|
('get_completion', True), # has the 'will_recheck_access' attribute set to True
|
||||||
|
('goto_position', False), # does not set it
|
||||||
|
)
|
||||||
|
@ddt.unpack
|
||||||
|
@patch('lms.djangoapps.courseware.module_render.get_module_for_descriptor', wraps=get_module_for_descriptor)
|
||||||
|
def test_will_recheck_access_handler_attribute(self, handler, will_recheck_access, mock_get_module):
|
||||||
|
"""Confirm that we pay attention to any 'will_recheck_access' attributes on handler methods"""
|
||||||
|
course = CourseFactory.create()
|
||||||
|
descriptor_kwargs = {
|
||||||
|
'category': 'sequential',
|
||||||
|
'parent': course,
|
||||||
|
}
|
||||||
|
descriptor = ItemFactory.create(**descriptor_kwargs)
|
||||||
|
usage_id = str(descriptor.location)
|
||||||
|
|
||||||
|
# Send no special parameters, which will be invalid, but we don't care
|
||||||
|
request = self.request_factory.post('/', data='{}', content_type='application/json')
|
||||||
|
request.user = self.mock_user
|
||||||
|
|
||||||
|
render.handle_xblock_callback(request, str(course.id), usage_id, handler)
|
||||||
|
assert mock_get_module.call_count == 1
|
||||||
|
assert mock_get_module.call_args[1]['will_recheck_access'] == will_recheck_access
|
||||||
|
|
||||||
|
|
||||||
@ddt.ddt
|
@ddt.ddt
|
||||||
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_XBLOCK_VIEW_ENDPOINT': True})
|
@patch.dict('django.conf.settings.FEATURES', {'ENABLE_XBLOCK_VIEW_ENDPOINT': True})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<%! from django.utils.translation import pgettext, ugettext as _ %>
|
<%! from django.utils.translation import pgettext, ugettext as _ %>
|
||||||
|
|
||||||
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}"
|
<div id="sequence_${element_id}" class="sequence" data-id="${item_id}"
|
||||||
data-position="${position}" data-ajax-url="${ajax_url}"
|
data-position="${position}"
|
||||||
data-next-url="${next_url}" data-prev-url="${prev_url}"
|
data-next-url="${next_url}" data-prev-url="${prev_url}"
|
||||||
data-save-position="${'true' if save_position else 'false'}"
|
data-save-position="${'true' if save_position else 'false'}"
|
||||||
data-show-completion="${'true' if show_completion else 'false'}"
|
data-show-completion="${'true' if show_completion else 'false'}"
|
||||||
|
|||||||
@@ -514,7 +514,7 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView):
|
|||||||
if request.user.is_anonymous:
|
if request.user.is_anonymous:
|
||||||
view = PUBLIC_VIEW
|
view = PUBLIC_VIEW
|
||||||
|
|
||||||
return Response(json.loads(sequence.handle_ajax('metadata', None, view=view)))
|
return Response(sequence.get_metadata(view=view))
|
||||||
|
|
||||||
|
|
||||||
class Resume(DeveloperErrorViewMixin, APIView):
|
class Resume(DeveloperErrorViewMixin, APIView):
|
||||||
|
|||||||
Reference in New Issue
Block a user