Commit 9cedb7cb by Xavier Antoviaque

Merge branch 'dropdown-pagination' into dropdown-pagination-rebased

parents 4a9a6d44 46dab9e3
......@@ -23,14 +23,17 @@ Instructor Tool: An XBlock for instructors to export student answers from a cour
All processing is done offline.
"""
import json
from django.core.paginator import Paginator
from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xblock.fields import Scope, String, Dict
from xblock.fields import Scope, String, Dict, List
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__)
PAGE_SIZE = 15
# Make '_' a no-op so we can scrape strings
def _(text):
......@@ -63,6 +66,12 @@ class InstructorToolBlock(XBlock):
default=None,
scope=Scope.user_state,
)
display_data = List(
# The list of results associated with the most recent successful export.
# Stored separately to avoid the overhead of sending it to the client.
default=None,
scope=Scope.user_state,
)
has_author_view = True
@property
......@@ -75,6 +84,11 @@ class InstructorToolBlock(XBlock):
# different celery queues; our task listener is waiting for tasks on the LMS queue)
return Fragment(u'<p>Instructor Tool Block</p><p>This block only works from the LMS.</p>')
def studio_view(self, context=None):
""" View for editing Instructor Tool block in Studio. """
# Display friendly message explaining that the block is not editable.
return Fragment(u'<p>This is a preconfigured block. It is not editable.</p>')
def check_pending_export(self):
"""
If we're waiting for an export, see if it has finished, and if so, get the result.
......@@ -90,11 +104,26 @@ class InstructorToolBlock(XBlock):
self.active_export_task_id = ''
if task_result.successful():
if isinstance(task_result.result, dict) and not task_result.result.get('error'):
self.display_data = task_result.result['display_data']
del task_result.result['display_data']
self.last_export_result = task_result.result
else:
self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))}
self.display_data = None
else:
self.last_export_result = {'error': unicode(task_result.result)}
self.display_data = None
@XBlock.json_handler
def get_result_page(self, data, suffix=''):
""" Return requested page of `last_export_result`. """
paginator = Paginator(self.display_data, PAGE_SIZE)
page = data.get('page', None)
return {
'display_data': paginator.page(page).object_list,
'num_results': len(self.display_data),
'page_size': PAGE_SIZE
}
def student_view(self, context=None):
""" Normal View """
......@@ -105,9 +134,92 @@ class InstructorToolBlock(XBlock):
_('Rating Question'): 'RatingBlock',
_('Long Answer'): 'AnswerBlock',
}
block_types = ('pb-mcq', 'pb-rating', 'pb-answer')
flat_block_tree = []
def get_block_id(block):
"""
Return ID of `block`, taking into account needs of both LMS/CMS and workbench runtimes.
"""
usage_id = block.scope_ids.usage_id
# Try accessing block ID. If usage_id does not have it, return usage_id itself
return unicode(getattr(usage_id, 'block_id', usage_id))
def get_block_name(block):
"""
Return name of `block`.
Try attributes in the following order:
- block.question
- block.name (fallback for old courses)
- block.display_name
- block ID
"""
# - Try "question" attribute:
block_name = getattr(block, 'question', block.name)
if not block_name:
# - Try display_name:
block_name = getattr(block, 'display_name', None)
if not block_name:
# - Default to ID:
block_name = get_block_id(block)
return block_name
def get_block_type(block):
"""
Return type of `block`, taking into account different key styles that might be in use.
"""
try:
block_type = block.runtime.id_reader.get_block_type(block.scope_ids.def_id)
except AttributeError:
block_type = block.runtime.id_reader.get_block_type(block.scope_ids.usage_id)
return block_type
def build_tree(block, ancestors):
"""
Build up a tree of information about the XBlocks descending from root_block
"""
block_id = get_block_id(block)
block_name = get_block_name(block)
block_type = get_block_type(block)
if not block_type == 'pb-choice':
eligible = block_type in block_types
if eligible:
# If this block is a question whose answers we can export,
# we mark all of its ancestors as exportable too
if ancestors and not ancestors[-1]["eligible"]:
for ancestor in ancestors:
ancestor["eligible"] = True
new_entry = {
"depth": len(ancestors),
"id": block_id,
"name": block_name,
"eligible": eligible,
}
flat_block_tree.append(new_entry)
if block.has_children and not getattr(block, "has_dynamic_children", lambda: False)():
for child_id in block.children:
build_tree(block.runtime.get_block(child_id), ancestors=(ancestors + [new_entry]))
root_block = self
while root_block.parent:
root_block = root_block.get_parent()
root_block_id = get_block_id(root_block)
root_entry = {
"depth": 0,
"id": root_block_id,
"name": "All",
}
flat_block_tree.append(root_entry)
for child_id in root_block.children:
child_block = root_block.runtime.get_block(child_id)
build_tree(child_block, [root_entry])
html = loader.render_template(
'templates/html/instructor_tool.html',
{'block_choices': block_choices}
{'block_choices': block_choices, 'block_tree': flat_block_tree}
)
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/instructor_tool.css'))
......@@ -144,6 +256,7 @@ class InstructorToolBlock(XBlock):
self.last_export_result = {
'error': message,
}
self.display_data = None
raise JsonHandlerError(code, message)
@XBlock.json_handler
......@@ -157,6 +270,7 @@ class InstructorToolBlock(XBlock):
def _delete_export(self):
self.last_export_result = None
self.display_data = None
self.active_export_task_id = ''
@XBlock.json_handler
......@@ -187,9 +301,6 @@ class InstructorToolBlock(XBlock):
root_block_id = self.scope_ids.usage_id
# Block ID not in workbench runtime.
root_block_id = unicode(getattr(root_block_id, 'block_id', root_block_id))
get_root = True
else:
get_root = False
# Launch task
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
......@@ -203,7 +314,6 @@ class InstructorToolBlock(XBlock):
block_types,
user_id,
match_string,
get_root=get_root
)
if async_result.ready():
# In development mode, the task may have executed synchronously.
......
......@@ -25,6 +25,12 @@
display: table-cell;
padding-left: 1em;
}
.data-export-field-container {
width: 43%;
}
.data-export-options .data-export-actions {
max-width: 10%;
}
.data-export-field {
margin-top: .5em;
margin-bottom: .5em;
......@@ -34,7 +40,7 @@
vertical-align: middle;
}
.data-export-field input, .data-export-field select {
max-width: 60%;
width: 55%;
float: right;
}
.data-export-results, .data-export-download, .data-export-cancel, .data-export-delete {
......
......@@ -6,7 +6,6 @@ import time
from celery.task import task
from celery.utils.log import get_task_logger
from instructor_task.models import ReportStore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from student.models import user_by_anonymous_id
from xmodule.modulestore.django import modulestore
......@@ -21,7 +20,7 @@ logger = get_task_logger(__name__)
@task()
def export_data(course_id, source_block_id_str, block_types, user_id, match_string, get_root=True):
def export_data(course_id, source_block_id_str, block_types, user_id, match_string):
"""
Exports student answers to all MCQ questions to a CSV file.
"""
......@@ -31,18 +30,10 @@ def export_data(course_id, source_block_id_str, block_types, user_id, match_stri
try:
course_key = CourseKey.from_string(course_id)
src_block = modulestore().get_items(course_key, qualifiers={'name': source_block_id_str}, depth=0)[0]
if src_block is None:
raise InvalidKeyError
except InvalidKeyError:
except IndexError:
raise ValueError("Could not find the specified Block ID.")
course_key_str = unicode(course_key)
root = src_block
if get_root:
# Get the root block for the course.
while root.parent:
root = root.get_parent()
type_map = {cls.__name__: cls for cls in [MCQBlock, RatingBlock, AnswerBlock]}
if not block_types:
......@@ -65,7 +56,7 @@ def export_data(course_id, source_block_id_str, block_types, user_id, match_stri
# Blocks may refer to missing children. Don't break in this case.
pass
scan_for_blocks(root)
scan_for_blocks(src_block)
# Define the header row of our CSV:
rows = []
......
......@@ -27,8 +27,16 @@
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Root block ID:" %}</span>
<input type="text" name="root_block_id" />
<span>{% trans "Section/Question:" %}</span>
<select name="root_block_id">
{% for block in block_tree %}
<option value="{{ block.id }}"
{% if not block.eligible %} disabled="disabled" {% endif %}>
{% for _ in ""|ljust:block.depth %}&nbsp;&nbsp;{% endfor %}
{{ block.name }}
</option>
{% endfor %}
</select>
</label>
</div>
</div>
......
......@@ -7,7 +7,7 @@ from mock import patch, Mock
from selenium.common.exceptions import NoSuchElementException
from xblockutils.base_test import SeleniumXBlockTest
from problem_builder.instructor_tool import InstructorToolBlock
from problem_builder.instructor_tool import PAGE_SIZE, InstructorToolBlock
class MockTasksModule(object):
......@@ -62,6 +62,36 @@ class InstructorToolTest(SeleniumXBlockTest):
'instructor_task.models': MockInstructorTaskModelsModule(),
})
@patch.object(InstructorToolBlock, 'user_is_staff', Mock(return_value=True))
def test_data_export_delete(self):
instructor_tool = self.go_to_view()
start_button = instructor_tool.find_element_by_class_name('data-export-start')
result_block = instructor_tool.find_element_by_class_name('data-export-results')
status_area = instructor_tool.find_element_by_class_name('data-export-status')
download_button = instructor_tool.find_element_by_class_name('data-export-download')
cancel_button = instructor_tool.find_element_by_class_name('data-export-cancel')
delete_button = instructor_tool.find_element_by_class_name('data-export-delete')
start_button.click()
self.wait_until_visible(result_block)
self.wait_until_visible(delete_button)
delete_button.click()
self.wait_until_hidden(result_block)
self.wait_until_hidden(delete_button)
self.assertTrue(start_button.is_enabled())
self.assertEqual('', status_area.text)
self.assertFalse(download_button.is_displayed())
self.assertFalse(cancel_button.is_displayed())
@patch.dict('sys.modules', {
'problem_builder.tasks': MockTasksModule(successful=True),
'instructor_task': True,
'instructor_task.models': MockInstructorTaskModelsModule(),
})
@patch.object(InstructorToolBlock, 'user_is_staff', Mock(return_value=True))
def test_data_export_success(self):
instructor_tool = self.go_to_view()
start_button = instructor_tool.find_element_by_class_name('data-export-start')
......@@ -146,6 +176,7 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click()
self.wait_until_visible(result_block)
time.sleep(1) # Allow some time for result block to fully fade in
self.assertFalse(first_page_button.is_enabled())
self.assertFalse(prev_page_button.is_enabled())
......@@ -179,6 +210,7 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click()
self.wait_until_visible(result_block)
time.sleep(1) # Allow some time for result block to fully fade in
for contents in [
'Test section', 'Test subsection', 'Test unit',
......@@ -199,7 +231,7 @@ class InstructorToolTest(SeleniumXBlockTest):
successful=True, display_data=[[
'Test section', 'Test subsection', 'Test unit',
'Test type', 'Test question', 'Test answer', 'Test username'
] for _ in range(45)]),
] for _ in range(PAGE_SIZE*3)]),
'instructor_task': True,
'instructor_task.models': MockInstructorTaskModelsModule(),
})
......@@ -218,13 +250,14 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click()
self.wait_until_visible(result_block)
time.sleep(1) # Allow some time for result block to fully fade in
for contents in [
'Test section', 'Test subsection', 'Test unit',
'Test type', 'Test question', 'Test answer', 'Test username'
]:
occurrences = re.findall(contents, result_block.text)
self.assertEqual(len(occurrences), 15)
self.assertEqual(len(occurrences), PAGE_SIZE)
self.assertFalse(first_page_button.is_enabled())
self.assertFalse(prev_page_button.is_enabled())
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment