Commit 472a3130 by Kelketek

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

Extended export
parents 504935e7 0b04fbd1
......@@ -6,3 +6,5 @@
/dist
/templates
*.iml
.idea/*
problem_builder.tests.*
......@@ -31,6 +31,7 @@ from xblock.fragment import Fragment
from xblock.validation import ValidationMessage
from xblockutils.resources import ResourceLoader
from xblockutils.studio_editable import StudioEditableXBlockMixin
from problem_builder.sub_api import SubmittingXBlockMixin, sub_api
from .step import StepMixin
import uuid
......@@ -102,7 +103,7 @@ class AnswerMixin(object):
@XBlock.needs("i18n")
class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
class AnswerBlock(SubmittingXBlockMixin, AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
"""
A field where the student enters an answer
......@@ -199,6 +200,11 @@ class AnswerBlock(AnswerMixin, StepMixin, StudioEditableXBlockMixin, XBlock):
"""
self.student_input = submission[0]['value'].strip()
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))
return self.get_results()
......
......@@ -24,6 +24,7 @@ All processing is done offline.
"""
import json
from xblock.core import XBlock
from xblock.exceptions import JsonHandlerError
from xblock.fields import Scope, String, Dict
from xblock.fragment import Fragment
from xblockutils.resources import ResourceLoader
......@@ -31,6 +32,12 @@ from xblockutils.resources import ResourceLoader
loader = ResourceLoader(__name__)
# Make '_' a no-op so we can scrape strings
def _(text):
return text
@XBlock.needs("i18n")
@XBlock.wants('user')
class DataExportBlock(XBlock):
"""
......@@ -38,6 +45,12 @@ class DataExportBlock(XBlock):
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(
# The UUID of the celery AsyncResult for the most recent export,
# IF we are sill waiting for it to finish
......@@ -62,10 +75,6 @@ class DataExportBlock(XBlock):
# 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.
......@@ -91,10 +100,16 @@ class DataExportBlock(XBlock):
""" 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')
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.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/vendor/underscore-min.js'))
fragment.initialize_js('DataExportBlock')
return fragment
......@@ -117,12 +132,21 @@ class DataExportBlock(XBlock):
'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
def get_status(self, request, suffix=''):
def get_status(self, data, suffix=''):
return self._get_status()
@XBlock.json_handler
def delete_export(self, request, suffix=''):
def delete_export(self, data, suffix=''):
self._delete_export()
return self._get_status()
......@@ -131,13 +155,38 @@ class DataExportBlock(XBlock):
self.active_export_task_id = ''
@XBlock.json_handler
def start_export(self, request, suffix=''):
def start_export(self, data, suffix=''):
""" 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():
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 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():
# In development mode, the task may have executed synchronously.
# Store the result now, because we won't be able to retrieve it later :-/
......@@ -150,7 +199,6 @@ class DataExportBlock(XBlock):
# 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=''):
......@@ -171,7 +219,3 @@ class DataExportBlock(XBlock):
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-options {
margin-top: 2em;
}
.data-export-actions button {
display: none;
}
.data-export-status {
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) {
'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 $element = $(element);
// Set up gettext in case it isn't available in the client runtime:
if (typeof gettext == "undefined") {
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;
function getStatus() {
$.ajax({
......@@ -11,9 +21,10 @@ function DataExportBlock(runtime, element) {
url: runtime.handlerUrl(element, 'get_status'),
data: '{}',
success: updateStatus,
dataType: 'json',
dataType: 'json'
});
}
function updateStatus(newStatus) {
var statusChanged = newStatus !== status;
status = newStatus;
......@@ -23,17 +34,25 @@ function DataExportBlock(runtime, element) {
}
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(
$('.data-export-status', $element).empty().append(
$('<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() {
var $statusArea = $('.data-export-status', element), startTime;
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);
......@@ -42,49 +61,76 @@ function DataExportBlock(runtime, element) {
if (status.last_export_result) {
if (status.last_export_result.error) {
$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 {
startTime = new Date(status.last_export_result.start_timestamp * 1000);
$statusArea.append($('<p>').text(
'A report is available for download.'
gettext('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.'
_.template(
ngettext(
'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 {
if (status.export_pending) {
$statusArea.append($('<p>').text(
'The report is currently being generated…'
gettext('The report is currently being generated…')
));
} else {
$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() {
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({
type: 'POST',
url: runtime.handlerUrl(element, handlerName),
data: '{}',
data: data,
success: updateStatus,
dataType: 'json',
error: handleError,
dataType: 'json'
});
showSpinner();
});
}
addHandler($startButton, 'start_export');
addHandler($startButton, 'start_export', true);
addHandler($cancelButton, 'cancel_export');
addHandler($deleteButton, 'delete_export');
$downloadButton.on('click', function() {
window.location.href = status.download_url;
});
showSpinner();
getStatus();
}
......@@ -6,40 +6,53 @@ 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 opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from xmodule.modulestore.django import modulestore
from .mcq import MCQBlock, RatingBlock
from problem_builder import AnswerBlock
from .sub_api import sub_api
logger = get_task_logger(__name__)
@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.
"""
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)
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:
raise ValueError("Could not find the specified Block ID.")
course_key_str = unicode(course_key)
# Get the root block:
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:
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
blocks_to_include = []
def scan_for_blocks(block):
""" Recursively scan the course tree for blocks of interest """
if isinstance(block, (MCQBlock, RatingBlock)):
if isinstance(block, block_types):
blocks_to_include.append(block)
elif block.has_children:
for child_id in block.children:
......@@ -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.
# 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
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:
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 not user_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:
student_submissions[student_id] = [student_id] + [""] * len(blocks_to_include)
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-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>
<button class="data-export-download">{% trans "Download result" %}</button>
<button class="data-export-cancel">{% trans "Cancel current export" %}</button>
<button class="data-export-delete">{% trans "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>
# -*- coding: utf-8 -*-
import time
import pdb
import sys
from mock import patch, Mock
from selenium.common.exceptions import NoSuchElementException
from xblockutils.base_test import SeleniumXBlockTest
from problem_builder.data_export import DataExportBlock
......@@ -82,3 +81,11 @@ class DataExportTest(SeleniumXBlockTest):
self.wait_until_visible(download_button)
self.wait_until_visible(delete_button)
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