Commit 504935e7 by Kelketek

Merge pull request #22 from open-craft/data-export

Data Export block prototype
parents f42c7c57 1efe0ad9
...@@ -2,6 +2,7 @@ from .mentoring import MentoringBlock ...@@ -2,6 +2,7 @@ from .mentoring import MentoringBlock
from .answer import AnswerBlock, AnswerRecapBlock from .answer import AnswerBlock, AnswerRecapBlock
from .choice import ChoiceBlock from .choice import ChoiceBlock
from .dashboard import DashboardBlock from .dashboard import DashboardBlock
from .data_export import DataExportBlock
from .mcq import MCQBlock, RatingBlock from .mcq import MCQBlock, RatingBlock
from .mrq import MRQBlock from .mrq import MRQBlock
from .message import MentoringMessageBlock from .message import MentoringMessageBlock
......
# -*- coding: utf-8 -*-
#
# Copyright (c) 2014-2015 Harvard, edX & OpenCraft
#
# This software's license gives you freedom; you can copy, convey,
# propagate, redistribute and/or modify this program under the terms of
# the GNU Affero General Public License (AGPL) as published by the Free
# Software Foundation (FSF), either version 3 of the License, or (at your
# option) any later version of the AGPL published by the FSF.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero
# General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program in a file in the toplevel directory called
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
"""
Data Export: An XBlock for instructors to export student answers from a course.
All processing is done offline.
"""
import json
from xblock.core import XBlock
from xblock.fields import Scope, String, Dict
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__)
@XBlock.wants('user')
class DataExportBlock(XBlock):
"""
DataExportBlock: An XBlock for instructors to export student answers from a course.
All processing is done offline.
"""
active_export_task_id = String(
# The UUID of the celery AsyncResult for the most recent export,
# IF we are sill waiting for it to finish
default="",
scope=Scope.user_state,
)
last_export_result = Dict(
# The info dict returned by the most recent successful export.
# If the export failed, it will have an "error" key set.
default=None,
scope=Scope.user_state,
)
has_author_view = True
@property
def display_name_with_default(self):
return "Data Export"
def author_view(self, context=None):
""" Studio View """
# Warn the user that this block will only work from the LMS. (Since the CMS uses
# different celery queues; our task listener is waiting for tasks on the LMS queue)
return Fragment(u'<p>Data Export Block</p><p>This block only works from the LMS.</p>')
def studio_view(self, context=None):
""" 'Edit' form view in Studio """
return Fragment(u'<p>This block has no configuration options.</p>')
def check_pending_export(self):
"""
If we're waiting for an export, see if it has finished, and if so, get the result.
"""
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
if self.active_export_task_id:
async_result = export_data_task.AsyncResult(self.active_export_task_id)
if async_result.ready():
self._save_result(async_result)
def _save_result(self, task_result):
""" Given an AsyncResult or EagerResult, save it. """
self.active_export_task_id = ''
if task_result.successful():
if isinstance(task_result.result, dict) and not task_result.result.get('error'):
self.last_export_result = task_result.result
else:
self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))}
else:
self.last_export_result = {'error': unicode(task_result.result)}
def student_view(self, context=None):
""" Normal View """
if not self.user_is_staff():
return Fragment(u'<p>This interface can only be used by course staff.</p>')
html = loader.render_template('templates/html/data_export.html')
fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/data_export.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/data_export.js'))
fragment.initialize_js('DataExportBlock')
return fragment
@property
def download_url_for_last_report(self):
""" Get the URL for the last report, if any """
# Unfortunately this is a bit inefficient due to the ReportStore API
if not self.last_export_result or self.last_export_result['error'] is not None:
return None
from instructor_task.models import ReportStore
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
course_key = getattr(self.scope_ids.usage_id, 'course_key', None)
return dict(report_store.links_for(course_key)).get(self.last_export_result['report_filename'])
def _get_status(self):
self.check_pending_export()
return {
'export_pending': bool(self.active_export_task_id),
'last_export_result': self.last_export_result,
'download_url': self.download_url_for_last_report,
}
@XBlock.json_handler
def get_status(self, request, suffix=''):
return self._get_status()
@XBlock.json_handler
def delete_export(self, request, suffix=''):
self._delete_export()
return self._get_status()
def _delete_export(self):
self.last_export_result = None
self.active_export_task_id = ''
@XBlock.json_handler
def start_export(self, request, suffix=''):
""" Start a new asynchronous export """
if not self.user_is_staff():
return {'error': 'permission denied'}
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
self._delete_export()
async_result = export_data_task.delay(unicode(self.scope_ids.usage_id), self.get_user_id())
if async_result.ready():
# In development mode, the task may have executed synchronously.
# Store the result now, because we won't be able to retrieve it later :-/
if async_result.successful():
# Make sure the result can be represented as JSON, since the non-eager celery
# requires that
json.dumps(async_result.result)
self._save_result(async_result)
else:
# The task is running asynchronously. Store the result ID so we can query its progress:
self.active_export_task_id = async_result.id
return self._get_status()
return {'result': 'started'}
@XBlock.json_handler
def cancel_export(self, request, suffix=''):
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
if self.active_export_task_id:
async_result = export_data_task.AsyncResult(self.active_export_task_id)
async_result.revoke()
self._delete_export()
def _get_user_attr(self, attr):
"""Get an attribute of the current user."""
user_service = self.runtime.service(self, 'user')
if user_service:
# May be None when creating bok choy test fixtures
return user_service.get_current_user().opt_attrs.get(attr)
return None
def user_is_staff(self):
"""Return a Boolean value indicating whether the current user is a member of staff."""
return self._get_user_attr('edx-platform.user_is_staff')
def get_user_id(self):
"""Get the edx-platform user_id of the current user."""
return self._get_user_attr('edx-platform.user_id')
.data-export-actions button {
display: none;
}
.data-export-status {
height: 8em;
}
function DataExportBlock(runtime, element) {
'use strict';
var $startButton = $('.data-export-start', element);
var $cancelButton = $('.data-export-cancel', element);
var $downloadButton = $('.data-export-download', element);
var $deleteButton = $('.data-export-delete', element);
var status;
function getStatus() {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'get_status'),
data: '{}',
success: updateStatus,
dataType: 'json',
});
}
function updateStatus(newStatus) {
var statusChanged = newStatus !== status;
status = newStatus;
if (status.export_pending) {
// Keep polling for status updates when an export is running.
setTimeout(getStatus, 1000);
}
if (statusChanged) updateView();
}
function showSpinner() {
$startButton.prop('disabled', true);
$cancelButton.prop('disabled', true);
$downloadButton.prop('disabled', true);
$deleteButton.prop('disabled', true);
$('.data-export-status', element).empty().append(
$('<i>').addClass('icon fa fa-spinner fa-spin')
);
}
function updateView() {
var $statusArea = $('.data-export-status', element), startTime;
$statusArea.empty();
$startButton.toggle(!status.export_pending).prop('disabled', false);
$cancelButton.toggle(status.export_pending).prop('disabled', false);
$downloadButton.toggle(Boolean(status.download_url)).prop('disabled', false);
$deleteButton.toggle(Boolean(status.last_export_result)).prop('disabled', false);
if (status.last_export_result) {
if (status.last_export_result.error) {
$statusArea.append($('<p>').text(
'Data export failed. Reason: ' + status.last_export_result.error
));
} else {
startTime = new Date(status.last_export_result.start_timestamp * 1000);
$statusArea.append($('<p>').text(
'A report is available for download.'
));
$statusArea.append($('<p>').text(
'It was created at ' + startTime.toString() +
' and took ' + status.last_export_result.generation_time_s.toFixed(1) +
' seconds to finish.'
));
}
} else {
if (status.export_pending) {
$statusArea.append($('<p>').text(
'The report is currently being generated…'
));
} else {
$statusArea.append($('<p>').text(
'No report data available.'
));
}
}
}
function addHandler($button, handlerName) {
$button.on('click', function() {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, handlerName),
data: '{}',
success: updateStatus,
dataType: 'json',
});
showSpinner();
});
}
addHandler($startButton, 'start_export');
addHandler($cancelButton, 'cancel_export');
addHandler($deleteButton, 'delete_export');
$downloadButton.on('click', function() {
window.location.href = status.download_url;
});
showSpinner();
getStatus();
}
"""
Celery task for CSV student answer export.
"""
import time
from celery.task import task
from celery.utils.log import get_task_logger
from instructor_task.models import ReportStore
from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore.django import modulestore
from .mcq import MCQBlock, RatingBlock
from .sub_api import sub_api
logger = get_task_logger(__name__)
@task()
def export_data(source_block_id_str, user_id):
"""
Exports student answers to all MCQ questions to a CSV file.
"""
start_timestamp = time.time()
logger.debug("Beginning data export")
block_key = UsageKey.from_string(source_block_id_str)
src_block = modulestore().get_item(block_key)
course_key = src_block.scope_ids.usage_id.course_key.replace(branch=None, version_guid=None)
course_key_str = unicode(course_key)
# Get the root block:
root = src_block
while root.parent:
root = root.get_parent()
# Build an ordered list of blocks to include in the export - each block is a column in the CSV file
blocks_to_include = []
def scan_for_blocks(block):
""" Recursively scan the course tree for blocks of interest """
if isinstance(block, (MCQBlock, RatingBlock)):
blocks_to_include.append(block)
elif block.has_children:
for child_id in block.children:
scan_for_blocks(block.runtime.get_block(child_id))
scan_for_blocks(root)
# Define the header rows of our CSV:
rows = []
rows.append(["Student"] + [block.display_name_with_default for block in blocks_to_include])
rows.append([""] + [block.scope_ids.block_type for block in blocks_to_include])
rows.append([""] + [block.scope_ids.usage_id for block in blocks_to_include])
# Load the actual student submissions for each block in blocks_to_include.
# Note this requires one giant query per block (all student submissions for each block, one block at a time)
student_submissions = {} # Key is student ID, value is a list with same length as blocks_to_include
for idx, block in enumerate(blocks_to_include, start=1): # start=1 since first column is stuent ID
# Get all of the most recent student submissions for this block:
block_id = unicode(block.scope_ids.usage_id.replace(branch=None, version_guid=None))
block_type = block.scope_ids.block_type
for submission in sub_api.get_all_submissions(course_key_str, block_id, block_type):
student_id = submission['student_id']
if student_id not in student_submissions:
student_submissions[student_id] = [student_id] + [""] * len(blocks_to_include)
student_submissions[student_id][idx] = submission['answer']
# Now change from a dict to an array ordered by student ID as we generate the remaining rows:
for student_id in sorted(student_submissions.iterkeys()):
rows.append(student_submissions[student_id])
del student_submissions[student_id]
# Generate the CSV:
filename = u"pb-data-export-{}.csv".format(time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(start_timestamp)))
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
report_store.store_rows(course_key, filename, rows)
generation_time_s = time.time() - start_timestamp
logger.debug("Done data export - took {} seconds".format(generation_time_s))
return {
"error": None,
"report_filename": filename,
"start_timestamp": start_timestamp,
"generation_time_s": generation_time_s,
}
<h3>Data Export</h3>
<p>You can export all student answers to multiple-choice questions to a CSV file here.</p>
<div class="data-export-status"></div>
<div class="data-export-actions">
<button class="data-export-download">Download result</button>
<button class="data-export-start">Start a new export</button>
<button class="data-export-cancel">Cancel current export</button>
<button class="data-export-delete">Delete result</button>
</div>
# -*- coding: utf-8 -*-
import time
import pdb
import sys
from mock import patch, Mock
from xblockutils.base_test import SeleniumXBlockTest
from problem_builder.data_export import DataExportBlock
class MockTasksModule(object):
"""Mock for the tasks module, which can only be meaningfully import in the LMS."""
def __init__(self, successful=True):
self.export_data = Mock()
async_result = self.export_data.async_result
async_result.ready.side_effect = [False, False, True, True]
async_result.id = "export_task_id"
async_result.successful.return_value = successful
if successful:
async_result.result = dict(
error=None,
report_filename='/file/report.csv',
start_timestamp=time.time(),
generation_time_s=23.4,
)
else:
async_result.result = 'error'
self.export_data.AsyncResult.return_value = async_result
self.export_data.delay.return_value = async_result
class MockInstructorTaskModelsModule(object):
def __init__(self):
self.ReportStore = Mock()
self.ReportStore.from_config.return_value.links_for.return_value = [
('/file/report.csv', '/url/report.csv')
]
class DataExportTest(SeleniumXBlockTest):
def setUp(self):
super(DataExportTest, self).setUp()
self.set_scenario_xml("""
<vertical_demo>
<pb-data-export url_name="data_export"/>
</vertical_demo>
""")
def test_students_dont_see_interface(self):
data_export = self.go_to_view()
self.assertIn('This interface can only be used by course staff.', data_export.text)
@patch.dict('sys.modules', {
'problem_builder.tasks': MockTasksModule(successful=True),
'instructor_task': True,
'instructor_task.models': MockInstructorTaskModelsModule(),
})
@patch.object(DataExportBlock, 'user_is_staff', Mock(return_value=True))
def test_data_export(self):
data_export = self.go_to_view()
start_button = data_export.find_element_by_class_name('data-export-start')
cancel_button = data_export.find_element_by_class_name('data-export-cancel')
download_button = data_export.find_element_by_class_name('data-export-download')
delete_button = data_export.find_element_by_class_name('data-export-delete')
status_area = data_export.find_element_by_class_name('data-export-status')
start_button.click()
self.wait_until_hidden(start_button)
self.wait_until_visible(cancel_button)
self.wait_until_hidden(download_button)
self.wait_until_hidden(delete_button)
self.assertIn('The report is currently being generated', status_area.text)
self.wait_until_visible(start_button)
self.wait_until_hidden(cancel_button)
self.wait_until_visible(download_button)
self.wait_until_visible(delete_button)
self.assertIn('A report is available for download.', status_area.text)
...@@ -54,23 +54,7 @@ BLOCKS = [ ...@@ -54,23 +54,7 @@ BLOCKS = [
'pb-choice = problem_builder:ChoiceBlock', 'pb-choice = problem_builder:ChoiceBlock',
'pb-dashboard = problem_builder:DashboardBlock', 'pb-dashboard = problem_builder:DashboardBlock',
# Deprecated. You can temporarily uncomment and run 'python setup.py develop' if you have these blocks 'pb-data-export = problem_builder:DataExportBlock',
# installed from testing mentoring v2 and need to get past an error message.
#'mentoring = problem_builder:MentoringBlock', # Deprecated alias for problem-builder
#'answer = problem_builder:AnswerBlock',
#'mentoring-answer = problem_builder:AnswerBlock',
#'answer-recap = problem_builder:AnswerRecapBlock',
#'mentoring-answer-recap = problem_builder:AnswerRecapBlock',
#'mcq = problem_builder:MCQBlock',
#'mentoring-mcq = problem_builder:MCQBlock',
#'rating = problem_builder:RatingBlock',
#'mentoring-rating = problem_builder:RatingBlock',
#'mrq = problem_builder:MRQBlock',
#'mentoring-mrq = problem_builder:MRQBlock',
#'tip = problem_builder:TipBlock',
#'mentoring-tip = problem_builder:TipBlock',
#'choice = problem_builder:ChoiceBlock',
#'mentoring-choice = problem_builder:ChoiceBlock',
] ]
setup( setup(
......
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