Commit 472a3130 by Kelketek

Merge pull request #41 from open-craft/extended-export

Extended export
parents 504935e7 0b04fbd1
...@@ -5,4 +5,6 @@ ...@@ -5,4 +5,6 @@
/workbench.* /workbench.*
/dist /dist
/templates /templates
*.iml *.iml
\ No newline at end of file .idea/*
problem_builder.tests.*
...@@ -31,6 +31,7 @@ from xblock.fragment import Fragment ...@@ -31,6 +31,7 @@ from xblock.fragment import Fragment
from xblock.validation import ValidationMessage from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin from xblockutils.studio_editable import StudioEditableXBlockMixin
from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .step import StepMixin from .step import StepMixin
import uuid import uuid
...@@ -102,7 +103,7 @@ class AnswerMixin(object): ...@@ -102,7 +103,7 @@ class AnswerMixin(object):
@XBlock.needs("i18n") @XBlock.needs("i18n")
class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
""" """
A field where the student enters an answer A field where the student enters an answer
...@@ -199,6 +200,11 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock): ...@@ -199,6 +200,11 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
""" """
self.student_input = submission[0]['value'].strip() self.student_input = submission[0]['value'].strip()
self.save() self.save()
if sub_api:
# Also send to the submissions API:
sub_api.create_submission(self.student_item_key, self.student_input)
log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input)) log.info(u'Answer submitted for`{}`: "{}"'.format(self.name, self.student_input))
return self.get_results() return self.get_results()
......
...@@ -24,6 +24,7 @@ All processing is done offline. ...@@ -24,6 +24,7 @@ All processing is done offline.
""" """
import json import json
from xblock.core import XBlock from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xblock.fields import Scope, String, Dict from xblock.fields import Scope, String, Dict
from xblock.fragment import Fragment from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader from xblockutils.resources import ResourceLoader
...@@ -31,6 +32,12 @@ from xblockutils.resources import ResourceLoader ...@@ -31,6 +32,12 @@ from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__) loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
@XBlock.needs("i18n")
@XBlock.wants('user') @XBlock.wants('user')
class DataExportBlock(XBlock): class DataExportBlock(XBlock):
""" """
...@@ -38,6 +45,12 @@ class DataExportBlock(XBlock): ...@@ -38,6 +45,12 @@ class DataExportBlock(XBlock):
All processing is done offline. All processing is done offline.
""" """
display_name = String(
display_name=_("Title (Display name)"),
help=_("Title to display"),
default=_("Data Export"),
scope=Scope.settings
)
active_export_task_id = String( active_export_task_id = String(
# The UUID of the celery AsyncResult for the most recent export, # The UUID of the celery AsyncResult for the most recent export,
# IF we are sill waiting for it to finish # IF we are sill waiting for it to finish
...@@ -62,10 +75,6 @@ class DataExportBlock(XBlock): ...@@ -62,10 +75,6 @@ class DataExportBlock(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>Data Export Block</p><p>This block only works from the LMS.</p>') 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): 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.
...@@ -91,10 +100,16 @@ class DataExportBlock(XBlock): ...@@ -91,10 +100,16 @@ class DataExportBlock(XBlock):
""" Normal View """ """ Normal View """
if not self.user_is_staff(): if not self.user_is_staff():
return Fragment(u'<p>This interface can only be used by course staff.</p>') return Fragment(u'<p>This interface can only be used by course staff.</p>')
html = loader.render_template('templates/html/data_export.html') block_choices = {
_('Multiple Choice Question'): 'MCQBlock',
_('Rating Question'): 'RatingBlock',
_('Long Answer'): 'AnswerBlock',
}
html = loader.render_template('templates/html/data_export.html', {'block_choices': block_choices})
fragment = Fragment(html) fragment = Fragment(html)
fragment.add_css_url(self.runtime.local_resource_url(self, 'public/css/data_export.css')) 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.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/data_export.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.initialize_js('DataExportBlock') fragment.initialize_js('DataExportBlock')
return fragment return fragment
...@@ -117,12 +132,21 @@ class DataExportBlock(XBlock): ...@@ -117,12 +132,21 @@ class DataExportBlock(XBlock):
'download_url': self.download_url_for_last_report, 'download_url': self.download_url_for_last_report,
} }
def raise_error(self, code, message):
"""
Raises an error and marks the block with a simulated failed task dict.
"""
self.last_export_result = {
'error': message,
}
raise JsonHandlerError(code, message)
@XBlock.json_handler @XBlock.json_handler
def get_status(self, request, suffix=''): def get_status(self, data, suffix=''):
return self._get_status() return self._get_status()
@XBlock.json_handler @XBlock.json_handler
def delete_export(self, request, suffix=''): def delete_export(self, data, suffix=''):
self._delete_export() self._delete_export()
return self._get_status() return self._get_status()
...@@ -131,13 +155,38 @@ class DataExportBlock(XBlock): ...@@ -131,13 +155,38 @@ class DataExportBlock(XBlock):
self.active_export_task_id = '' self.active_export_task_id = ''
@XBlock.json_handler @XBlock.json_handler
def start_export(self, request, suffix=''): def start_export(self, data, suffix=''):
""" Start a new asynchronous export """ """ Start a new asynchronous export """
block_types = data.get('block_types', None)
username = data.get('username', None)
root_block_id = data.get('root_block_id', None)
if not root_block_id:
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
user_service = self.runtime.service(self, 'user')
if not self.user_is_staff(): if not self.user_is_staff():
return {'error': 'permission denied'} return {'error': 'permission denied'}
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
self._delete_export() self._delete_export()
async_result = export_data_task.delay(unicode(self.scope_ids.usage_id), self.get_user_id()) if not username:
user_id = None
else:
user_id = user_service.get_anonymous_user_id(username, unicode(self.runtime.course_id))
if user_id is None:
self.raise_error(404, _("Could not find the specified username."))
async_result = export_data_task.delay(
# course_id not available in workbench.
unicode(getattr(self.runtime, 'course_id', 'course_id')),
root_block_id,
block_types,
user_id,
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.
# Store the result now, because we won't be able to retrieve it later :-/ # Store the result now, because we won't be able to retrieve it later :-/
...@@ -150,7 +199,6 @@ class DataExportBlock(XBlock): ...@@ -150,7 +199,6 @@ class DataExportBlock(XBlock):
# The task is running asynchronously. Store the result ID so we can query its progress: # The task is running asynchronously. Store the result ID so we can query its progress:
self.active_export_task_id = async_result.id self.active_export_task_id = async_result.id
return self._get_status() return self._get_status()
return {'result': 'started'}
@XBlock.json_handler @XBlock.json_handler
def cancel_export(self, request, suffix=''): def cancel_export(self, request, suffix=''):
...@@ -171,7 +219,3 @@ class DataExportBlock(XBlock): ...@@ -171,7 +219,3 @@ class DataExportBlock(XBlock):
def user_is_staff(self): def user_is_staff(self):
"""Return a Boolean value indicating whether the current user is a member of staff.""" """Return a Boolean value indicating whether the current user is a member of staff."""
return self._get_user_attr('edx-platform.user_is_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-options {
margin-top: 2em;
}
.data-export-actions button { .data-export-actions button {
display: none; display: none;
} }
.data-export-status { .data-export-status {
height: 8em; height: 8em;
} }
.data-export-field {
margin-top: .5em;
margin-bottom: .5em;
}
.data-export-field label span {
font-weight: bold;
}
.data-export-helptext {
font-size: 75%;
}
.data-export-field-container {
margin-bottom: 1em;
}
\ No newline at end of file
function DataExportBlock(runtime, element) { function DataExportBlock(runtime, element) {
'use strict'; 'use strict';
var $startButton = $('.data-export-start', element); var $element = $(element);
var $cancelButton = $('.data-export-cancel', element); // Set up gettext in case it isn't available in the client runtime:
var $downloadButton = $('.data-export-download', element); if (typeof gettext == "undefined") {
var $deleteButton = $('.data-export-delete', element); window.gettext = function gettext_stub(string) { return string; };
window.ngettext = function ngettext_stub(strA, strB, n) { return n == 1 ? strA : strB; };
}
var $startButton = $element.find('.data-export-start');
var $cancelButton = $element.find('.data-export-cancel');
var $downloadButton = $element.find('.data-export-download');
var $deleteButton = $element.find('.data-export-delete');
var $blockTypes = $element.find("select[name='block_types']");
var $rootBlockId = $element.find("input[name='root_block_id']");
var $username = $element.find("input[name='username']");
var status; var status;
function getStatus() { function getStatus() {
$.ajax({ $.ajax({
...@@ -11,9 +21,10 @@ function DataExportBlock(runtime, element) { ...@@ -11,9 +21,10 @@ function DataExportBlock(runtime, element) {
url: runtime.handlerUrl(element, 'get_status'), url: runtime.handlerUrl(element, 'get_status'),
data: '{}', data: '{}',
success: updateStatus, success: updateStatus,
dataType: 'json', dataType: 'json'
}); });
} }
function updateStatus(newStatus) { function updateStatus(newStatus) {
var statusChanged = newStatus !== status; var statusChanged = newStatus !== status;
status = newStatus; status = newStatus;
...@@ -23,17 +34,25 @@ function DataExportBlock(runtime, element) { ...@@ -23,17 +34,25 @@ function DataExportBlock(runtime, element) {
} }
if (statusChanged) updateView(); if (statusChanged) updateView();
} }
function showSpinner() { function showSpinner() {
$startButton.prop('disabled', true); $startButton.prop('disabled', true);
$cancelButton.prop('disabled', true); $cancelButton.prop('disabled', true);
$downloadButton.prop('disabled', true); $downloadButton.prop('disabled', true);
$deleteButton.prop('disabled', true); $deleteButton.prop('disabled', true);
$('.data-export-status', element).empty().append( $('.data-export-status', $element).empty().append(
$('<i>').addClass('icon fa fa-spinner fa-spin') $('<i>').addClass('icon fa fa-spinner fa-spin')
); );
} }
function handleError(data) {
// Shim to make the XBlock JsonHandlerError response work with our format.
status = {'last_export_result': JSON.parse(data.responseText), 'export_pending': false};
updateView();
}
function updateView() { function updateView() {
var $statusArea = $('.data-export-status', element), startTime; var $statusArea = $('.data-export-status', $element), startTime;
$statusArea.empty(); $statusArea.empty();
$startButton.toggle(!status.export_pending).prop('disabled', false); $startButton.toggle(!status.export_pending).prop('disabled', false);
$cancelButton.toggle(status.export_pending).prop('disabled', false); $cancelButton.toggle(status.export_pending).prop('disabled', false);
...@@ -42,49 +61,76 @@ function DataExportBlock(runtime, element) { ...@@ -42,49 +61,76 @@ function DataExportBlock(runtime, element) {
if (status.last_export_result) { if (status.last_export_result) {
if (status.last_export_result.error) { if (status.last_export_result.error) {
$statusArea.append($('<p>').text( $statusArea.append($('<p>').text(
'Data export failed. Reason: ' + status.last_export_result.error _.template(
gettext('Data export failed. Reason: <%= error %>'),
{'error': status.last_export_result.error}
)
)); ));
} else { } else {
startTime = new Date(status.last_export_result.start_timestamp * 1000); startTime = new Date(status.last_export_result.start_timestamp * 1000);
$statusArea.append($('<p>').text( $statusArea.append($('<p>').text(
'A report is available for download.' gettext('A report is available for download.')
)); ));
$statusArea.append($('<p>').text( $statusArea.append($('<p>').text(
'It was created at ' + startTime.toString() + _.template(
' and took ' + status.last_export_result.generation_time_s.toFixed(1) + ngettext(
' seconds to finish.' 'It was created at <%= creation_time %> and took <%= seconds %> second to finish.',
'It was created at <%= creation_time %> and took <%= seconds %> seconds to finish.',
status.last_export_result.generation_time_s.toFixed(1)
),
{
'creation_time': startTime.toString(),
'seconds': status.last_export_result.generation_time_s.toFixed(1)
}
)
)); ));
} }
} else { } else {
if (status.export_pending) { if (status.export_pending) {
$statusArea.append($('<p>').text( $statusArea.append($('<p>').text(
'The report is currently being generated…' gettext('The report is currently being generated…')
)); ));
} else { } else {
$statusArea.append($('<p>').text( $statusArea.append($('<p>').text(
'No report data available.' gettext('No report data available.')
)); ));
} }
} }
} }
function addHandler($button, handlerName) {
function addHandler($button, handlerName, form_submit) {
$button.on('click', function() { $button.on('click', function() {
var data;
if (form_submit) {
data = {
block_types: $blockTypes.val(),
root_block_id: $rootBlockId.val(),
username: $username.val()
};
data = JSON.stringify(data);
} else {
data = '{}';
}
$.ajax({ $.ajax({
type: 'POST', type: 'POST',
url: runtime.handlerUrl(element, handlerName), url: runtime.handlerUrl(element, handlerName),
data: '{}', data: data,
success: updateStatus, success: updateStatus,
dataType: 'json', error: handleError,
dataType: 'json'
}); });
showSpinner(); showSpinner();
}); });
} }
addHandler($startButton, 'start_export');
addHandler($startButton, 'start_export', true);
addHandler($cancelButton, 'cancel_export'); addHandler($cancelButton, 'cancel_export');
addHandler($deleteButton, 'delete_export'); addHandler($deleteButton, 'delete_export');
$downloadButton.on('click', function() { $downloadButton.on('click', function() {
window.location.href = status.download_url; window.location.href = status.download_url;
}); });
showSpinner(); showSpinner();
getStatus(); getStatus();
} }
...@@ -6,40 +6,53 @@ import time ...@@ -6,40 +6,53 @@ 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.edx.keys import UsageKey from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from xmodule.modulestore.django import modulestore from xmodule.modulestore.django import modulestore
from .mcq import MCQBlock, RatingBlock from .mcq import MCQBlock, RatingBlock
from problem_builder import AnswerBlock
from .sub_api import sub_api from .sub_api import sub_api
logger = get_task_logger(__name__) logger = get_task_logger(__name__)
@task() @task()
def export_data(source_block_id_str, user_id): def export_data(course_id, source_block_id_str, block_types, user_id, get_root=True):
""" """
Exports student answers to all MCQ questions to a CSV file. Exports student answers to all MCQ questions to a CSV file.
""" """
start_timestamp = time.time() start_timestamp = time.time()
logger.debug("Beginning data export") logger.debug("Beginning data export")
try:
block_key = UsageKey.from_string(source_block_id_str) course_key = CourseKey.from_string(course_id)
src_block = modulestore().get_item(block_key) src_block = modulestore().get_items(course_key, qualifiers={'name': source_block_id_str}, depth=0)[0]
course_key = src_block.scope_ids.usage_id.course_key.replace(branch=None, version_guid=None) if src_block is None:
raise InvalidKeyError
except InvalidKeyError:
raise ValueError("Could not find the specified Block ID.")
course_key_str = unicode(course_key) course_key_str = unicode(course_key)
# Get the root block:
root = src_block root = src_block
while root.parent: if get_root:
root = root.get_parent() # 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:
block_types = tuple(type_map.values())
else:
block_types = tuple(type_map[class_name] for class_name in block_types)
# Build an ordered list of blocks to include in the export - each block is a column in the CSV file # Build an ordered list of blocks to include in the export - each block is a column in the CSV file
blocks_to_include = [] blocks_to_include = []
def scan_for_blocks(block): def scan_for_blocks(block):
""" Recursively scan the course tree for blocks of interest """ """ Recursively scan the course tree for blocks of interest """
if isinstance(block, (MCQBlock, RatingBlock)): if isinstance(block, block_types):
blocks_to_include.append(block) blocks_to_include.append(block)
elif block.has_children: elif block.has_children:
for child_id in block.children: for child_id in block.children:
...@@ -56,12 +69,23 @@ def export_data(source_block_id_str, user_id): ...@@ -56,12 +69,23 @@ def export_data(source_block_id_str, user_id):
# Load the actual student submissions for each 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) # 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 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 for idx, block in enumerate(blocks_to_include, start=1): # start=1 since first column is student ID
# Get all of the most recent student submissions for this block: # 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_id = unicode(block.scope_ids.usage_id.replace(branch=None, version_guid=None))
block_type = block.scope_ids.block_type block_type = block.scope_ids.block_type
for submission in sub_api.get_all_submissions(course_key_str, block_id, block_type): if not user_id:
student_id = submission['student_id'] submissions = sub_api.get_all_submissions(course_key_str, block_id, block_type)
else:
student_dict = {
'student_id': user_id,
'item_id': block_id,
'course_id': course_key_str,
'item_type': block_type,
}
submissions = sub_api.get_submissions(student_dict, limit=1)
for submission in submissions:
# If the student ID key doesn't exist, we're dealing with a single student and know the ID already.
student_id = submission.get('student_id', user_id)
if student_id not in student_submissions: if student_id not in student_submissions:
student_submissions[student_id] = [student_id] + [""] * len(blocks_to_include) student_submissions[student_id] = [student_id] + [""] * len(blocks_to_include)
student_submissions[student_id][idx] = submission['answer'] student_submissions[student_id][idx] = submission['answer']
......
<h3>Data Export</h3> {% load i18n %}
<h3>{% trans "Data Export" %}</h3>
<p>You can export all student answers to multiple-choice questions to a CSV file here.</p> <p>{% trans "You can export all student answers to multiple-choice questions and long-form answers to a CSV file here." %}</p>
<div class="data-export-status"></div> <div class="data-export-status"></div>
<div class="data-export-actions"> <div class="data-export-actions">
<button class="data-export-download">Download result</button> <button class="data-export-download">{% trans "Download result" %}</button>
<button class="data-export-start">Start a new export</button> <button class="data-export-cancel">{% trans "Cancel current export" %}</button>
<button class="data-export-cancel">Cancel current export</button> <button class="data-export-delete">{% trans "Delete result" %}</button>
<button class="data-export-delete">Delete result</button> </div>
<div class="data-export-options">
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Problem types:" %}</span>
<select multiple name="block_types">
{% for label, value in block_choices.items %}
<option value="{{value}}">{{label}}</option>
{% endfor %}
</select>
</label>
</div>
<div class="data-export-helptext">
{% trans "Select which types of problem to include (selecting none will grab all types)" %}
</div>
</div>
<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" />
</label>
</div>
<div class="data-export-helptext">
{% trans "Input the ID of a chapter, section, or unit if you wish to only get results under it. Otherwise, it will grab all results for the course." %}
</div>
</div>
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Username:" %}</span>
<input type="text" name="username" />
</label>
</div>
<div class="data-export-helptext">
{% trans "Input the username of a student if you wish to query for a specific one. Otherwise, it will grab all results for all students." %}
</div>
</div>
</div>
<div class="data-export-actions">
<button class="data-export-start">{% trans "Start a new export" %}</button>
</div> </div>
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
import time import time
import pdb
import sys
from mock import patch, Mock from mock import patch, Mock
from selenium.common.exceptions import NoSuchElementException
from xblockutils.base_test import SeleniumXBlockTest from xblockutils.base_test import SeleniumXBlockTest
from problem_builder.data_export import DataExportBlock from problem_builder.data_export import DataExportBlock
...@@ -82,3 +81,11 @@ class DataExportTest(SeleniumXBlockTest): ...@@ -82,3 +81,11 @@ class DataExportTest(SeleniumXBlockTest):
self.wait_until_visible(download_button) self.wait_until_visible(download_button)
self.wait_until_visible(delete_button) self.wait_until_visible(delete_button)
self.assertIn('A report is available for download.', status_area.text) self.assertIn('A report is available for download.', status_area.text)
def test_non_staff_disabled(self):
data_export = self.go_to_view()
self.assertRaises(NoSuchElementException, data_export.find_element_by_class_name, 'data-export-start')
self.assertRaises(NoSuchElementException, data_export.find_element_by_class_name, 'data-export-cancel')
self.assertRaises(NoSuchElementException, data_export.find_element_by_class_name, 'data-export-download')
self.assertRaises(NoSuchElementException, data_export.find_element_by_class_name, 'data-export-delete')
self.assertRaises(NoSuchElementException, data_export.find_element_by_class_name, 'data-export-status')
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