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:
Michael Terry
2021-04-26 14:24:01 -04:00
parent baa83d77e3
commit b90039b23d
9 changed files with 210 additions and 111 deletions

View File

@@ -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() {

View File

@@ -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');
} }

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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):

View File

@@ -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

View File

@@ -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})

View File

@@ -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'}"

View File

@@ -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):