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 ...@@ -23,14 +23,17 @@ Instructor Tool: An XBlock for instructors to export student answers from a cour
All processing is done offline. All processing is done offline.
""" """
import json import json
from django.core.paginator import Paginator
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError 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 xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__) loader = ResourceLoader(__name__)
PAGE_SIZE = 15
# Make '_' a no-op so we can scrape strings # Make '_' a no-op so we can scrape strings
def _(text): def _(text):
...@@ -63,6 +66,12 @@ class InstructorToolBlock(XBlock): ...@@ -63,6 +66,12 @@ class InstructorToolBlock(XBlock):
default=None, default=None,
scope=Scope.user_state, 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 has_author_view = True
@property @property
...@@ -75,6 +84,11 @@ class InstructorToolBlock(XBlock): ...@@ -75,6 +84,11 @@ class InstructorToolBlock(XBlock):
# different celery queues; our task listener is waiting for tasks on the LMS queue) # 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>') 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): def check_pending_export(self):
""" """
If we're waiting for an export, see if it has finished, and if so, get the result. 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): ...@@ -90,11 +104,26 @@ class InstructorToolBlock(XBlock):
self.active_export_task_id = '' self.active_export_task_id = ''
if task_result.successful(): if task_result.successful():
if isinstance(task_result.result, dict) and not task_result.result.get('error'): 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 self.last_export_result = task_result.result
else: else:
self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))} self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))}
self.display_data = None
else: else:
self.last_export_result = {'error': unicode(task_result.result)} 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): def student_view(self, context=None):
""" Normal View """ """ Normal View """
...@@ -105,9 +134,92 @@ class InstructorToolBlock(XBlock): ...@@ -105,9 +134,92 @@ class InstructorToolBlock(XBlock):
_('Rating Question'): 'RatingBlock', _('Rating Question'): 'RatingBlock',
_('Long Answer'): 'AnswerBlock', _('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( html = loader.render_template(
'templates/html/instructor_tool.html', 'templates/html/instructor_tool.html',
{'block_choices': block_choices} {'block_choices': block_choices, 'block_tree': flat_block_tree}
) )
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/instructor_tool.css')) fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/instructor_tool.css'))
...@@ -144,6 +256,7 @@ class InstructorToolBlock(XBlock): ...@@ -144,6 +256,7 @@ class InstructorToolBlock(XBlock):
self.last_export_result = { self.last_export_result = {
'error': message, 'error': message,
} }
self.display_data = None
raise JsonHandlerError(code, message) raise JsonHandlerError(code, message)
@XBlock.json_handler @XBlock.json_handler
...@@ -157,6 +270,7 @@ class InstructorToolBlock(XBlock): ...@@ -157,6 +270,7 @@ class InstructorToolBlock(XBlock):
def _delete_export(self): def _delete_export(self):
self.last_export_result = None self.last_export_result = None
self.display_data = None
self.active_export_task_id = '' self.active_export_task_id = ''
@XBlock.json_handler @XBlock.json_handler
...@@ -187,9 +301,6 @@ class InstructorToolBlock(XBlock): ...@@ -187,9 +301,6 @@ class InstructorToolBlock(XBlock):
root_block_id = self.scope_ids.usage_id root_block_id = self.scope_ids.usage_id
# Block ID not in workbench runtime. # Block ID not in workbench runtime.
root_block_id = unicode(getattr(root_block_id, 'block_id', root_block_id)) root_block_id = unicode(getattr(root_block_id, 'block_id', root_block_id))
get_root = True
else:
get_root = False
# Launch task # Launch task
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
...@@ -203,7 +314,6 @@ class InstructorToolBlock(XBlock): ...@@ -203,7 +314,6 @@ class InstructorToolBlock(XBlock):
block_types, block_types,
user_id, user_id,
match_string, match_string,
get_root=get_root
) )
if async_result.ready(): if async_result.ready():
# In development mode, the task may have executed synchronously. # In development mode, the task may have executed synchronously.
......
...@@ -25,6 +25,12 @@ ...@@ -25,6 +25,12 @@
display: table-cell; display: table-cell;
padding-left: 1em; padding-left: 1em;
} }
.data-export-field-container {
width: 43%;
}
.data-export-options .data-export-actions {
max-width: 10%;
}
.data-export-field { .data-export-field {
margin-top: .5em; margin-top: .5em;
margin-bottom: .5em; margin-bottom: .5em;
...@@ -34,7 +40,7 @@ ...@@ -34,7 +40,7 @@
vertical-align: middle; vertical-align: middle;
} }
.data-export-field input, .data-export-field select { .data-export-field input, .data-export-field select {
max-width: 60%; width: 55%;
float: right; float: right;
} }
.data-export-results, .data-export-download, .data-export-cancel, .data-export-delete { .data-export-results, .data-export-download, .data-export-cancel, .data-export-delete {
......
...@@ -6,7 +6,6 @@ import time ...@@ -6,7 +6,6 @@ import time
from celery.task import task from celery.task import task
from celery.utils.log import get_task_logger from celery.utils.log import get_task_logger
from instructor_task.models import ReportStore from instructor_task.models import ReportStore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
from student.models import user_by_anonymous_id from student.models import user_by_anonymous_id
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
...@@ -21,7 +20,7 @@ logger = get_task_logger(__name__) ...@@ -21,7 +20,7 @@ logger = get_task_logger(__name__)
@task() @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. 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 ...@@ -31,18 +30,10 @@ def export_data(course_id, source_block_id_str, block_types, user_id, match_stri
try: try:
course_key = CourseKey.from_string(course_id) course_key = CourseKey.from_string(course_id)
src_block = modulestore().get_items(course_key, qualifiers={'name': source_block_id_str}, depth=0)[0] src_block = modulestore().get_items(course_key, qualifiers={'name': source_block_id_str}, depth=0)[0]
if src_block is None: except IndexError:
raise InvalidKeyError
except InvalidKeyError:
raise ValueError("Could not find the specified Block ID.") raise ValueError("Could not find the specified Block ID.")
course_key_str = unicode(course_key) 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]} type_map = {cls.__name__: cls for cls in [MCQBlock, RatingBlock, AnswerBlock]}
if not block_types: if not block_types:
...@@ -65,7 +56,7 @@ def export_data(course_id, source_block_id_str, block_types, user_id, match_stri ...@@ -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. # Blocks may refer to missing children. Don't break in this case.
pass pass
scan_for_blocks(root) scan_for_blocks(src_block)
# Define the header row of our CSV: # Define the header row of our CSV:
rows = [] rows = []
......
...@@ -27,8 +27,16 @@ ...@@ -27,8 +27,16 @@
<div class="data-export-field-container"> <div class="data-export-field-container">
<div class="data-export-field"> <div class="data-export-field">
<label> <label>
<span>{% trans "Root block ID:" %}</span> <span>{% trans "Section/Question:" %}</span>
<input type="text" name="root_block_id" /> <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> </label>
</div> </div>
</div> </div>
......
...@@ -7,7 +7,7 @@ from mock import patch, Mock ...@@ -7,7 +7,7 @@ from mock import patch, Mock
from selenium.common.exceptions import NoSuchElementException from selenium.common.exceptions import NoSuchElementException
from xblockutils.base_test import SeleniumXBlockTest 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): class MockTasksModule(object):
...@@ -62,6 +62,36 @@ class InstructorToolTest(SeleniumXBlockTest): ...@@ -62,6 +62,36 @@ class InstructorToolTest(SeleniumXBlockTest):
'instructor_task.models': MockInstructorTaskModelsModule(), 'instructor_task.models': MockInstructorTaskModelsModule(),
}) })
@patch.object(InstructorToolBlock, 'user_is_staff', Mock(return_value=True)) @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): def test_data_export_success(self):
instructor_tool = self.go_to_view() instructor_tool = self.go_to_view()
start_button = instructor_tool.find_element_by_class_name('data-export-start') start_button = instructor_tool.find_element_by_class_name('data-export-start')
...@@ -146,6 +176,7 @@ class InstructorToolTest(SeleniumXBlockTest): ...@@ -146,6 +176,7 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click() start_button.click()
self.wait_until_visible(result_block) 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(first_page_button.is_enabled())
self.assertFalse(prev_page_button.is_enabled()) self.assertFalse(prev_page_button.is_enabled())
...@@ -179,6 +210,7 @@ class InstructorToolTest(SeleniumXBlockTest): ...@@ -179,6 +210,7 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click() start_button.click()
self.wait_until_visible(result_block) self.wait_until_visible(result_block)
time.sleep(1) # Allow some time for result block to fully fade in
for contents in [ for contents in [
'Test section', 'Test subsection', 'Test unit', 'Test section', 'Test subsection', 'Test unit',
...@@ -199,7 +231,7 @@ class InstructorToolTest(SeleniumXBlockTest): ...@@ -199,7 +231,7 @@ class InstructorToolTest(SeleniumXBlockTest):
successful=True, display_data=[[ successful=True, display_data=[[
'Test section', 'Test subsection', 'Test unit', 'Test section', 'Test subsection', 'Test unit',
'Test type', 'Test question', 'Test answer', 'Test username' 'Test type', 'Test question', 'Test answer', 'Test username'
] for _ in range(45)]), ] for _ in range(PAGE_SIZE*3)]),
'instructor_task': True, 'instructor_task': True,
'instructor_task.models': MockInstructorTaskModelsModule(), 'instructor_task.models': MockInstructorTaskModelsModule(),
}) })
...@@ -218,13 +250,14 @@ class InstructorToolTest(SeleniumXBlockTest): ...@@ -218,13 +250,14 @@ class InstructorToolTest(SeleniumXBlockTest):
start_button.click() start_button.click()
self.wait_until_visible(result_block) self.wait_until_visible(result_block)
time.sleep(1) # Allow some time for result block to fully fade in
for contents in [ for contents in [
'Test section', 'Test subsection', 'Test unit', 'Test section', 'Test subsection', 'Test unit',
'Test type', 'Test question', 'Test answer', 'Test username' 'Test type', 'Test question', 'Test answer', 'Test username'
]: ]:
occurrences = re.findall(contents, result_block.text) 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(first_page_button.is_enabled())
self.assertFalse(prev_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