Commit ddc822aa by Jonathan Piacenti

Allow student access, answer submissions, and internationalization to data-export.

parent 1efe0ad9
...@@ -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()
......
...@@ -27,17 +27,29 @@ from xblock.core import XBlock ...@@ -27,17 +27,29 @@ from xblock.core import XBlock
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
from problem_builder.sub_api import SubmittingXBlockMixin
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(SubmittingXBlockMixin, XBlock):
""" """
DataExportBlock: An XBlock for instructors to export student answers from a course. DataExportBlock: An XBlock for instructors to export student answers from a course.
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 +74,6 @@ class DataExportBlock(XBlock): ...@@ -62,10 +74,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.
...@@ -95,6 +103,7 @@ class DataExportBlock(XBlock): ...@@ -95,6 +103,7 @@ class DataExportBlock(XBlock):
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
...@@ -137,7 +146,9 @@ class DataExportBlock(XBlock): ...@@ -137,7 +146,9 @@ class DataExportBlock(XBlock):
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()) async_result = export_data_task.delay(
unicode(self.scope_ids.usage_id),
)
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 +161,6 @@ class DataExportBlock(XBlock): ...@@ -150,7 +161,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 +181,3 @@ class DataExportBlock(XBlock): ...@@ -171,7 +181,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')
function DataExportBlock(runtime, element) { function DataExportBlock(runtime, element) {
'use strict'; 'use strict';
// 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 = $('.data-export-start', element); var $startButton = $('.data-export-start', element);
var $cancelButton = $('.data-export-cancel', element); var $cancelButton = $('.data-export-cancel', element);
var $downloadButton = $('.data-export-download', element); var $downloadButton = $('.data-export-download', element);
...@@ -11,7 +16,7 @@ function DataExportBlock(runtime, element) { ...@@ -11,7 +16,7 @@ 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) {
...@@ -42,27 +47,38 @@ function DataExportBlock(runtime, element) { ...@@ -42,27 +47,38 @@ 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.')
)); ));
} }
} }
...@@ -74,7 +90,7 @@ function DataExportBlock(runtime, element) { ...@@ -74,7 +90,7 @@ function DataExportBlock(runtime, element) {
url: runtime.handlerUrl(element, handlerName), url: runtime.handlerUrl(element, handlerName),
data: '{}', data: '{}',
success: updateStatus, success: updateStatus,
dataType: 'json', dataType: 'json'
}); });
showSpinner(); showSpinner();
}); });
......
...@@ -10,13 +10,14 @@ from opaque_keys.edx.keys import UsageKey ...@@ -10,13 +10,14 @@ from opaque_keys.edx.keys import UsageKey
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(source_block_id_str, user_id=None):
""" """
Exports student answers to all MCQ questions to a CSV file. Exports student answers to all MCQ questions to a CSV file.
""" """
...@@ -39,7 +40,7 @@ def export_data(source_block_id_str, user_id): ...@@ -39,7 +40,7 @@ def export_data(source_block_id_str, user_id):
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, (MCQBlock, RatingBlock, AnswerBlock)):
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 +57,23 @@ def export_data(source_block_id_str, user_id): ...@@ -56,12 +57,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 user_id is None:
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-start">{% trans "Start a new export" %}</button>
<button class="data-export-cancel">Cancel current export</button> <button class="data-export-cancel">{% trans "Cancel current export" %}</button>
<button class="data-export-delete">Delete result</button> <button class="data-export-delete">{% trans "Delete result" %}</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