Commit 2c6cd225 by Braden MacDonald

Merge branch 'student-answers-dashboard' into fix-hgse-upgrade-merged

parents 30e9b3cd f57db139
------------------------------------------------------------------------------
This license applies to the following third-party libraries included
in this repository:
- backbone.paginator
- Backbone.js
- Underscore.js
------------------------------------------------------------------------------
The MIT License (MIT)
Copyright (c) [year] [fullname]
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
......@@ -2,7 +2,7 @@ from .mentoring import MentoringBlock
from .answer import AnswerBlock, AnswerRecapBlock
from .choice import ChoiceBlock
from .dashboard import DashboardBlock
from .data_export import DataExportBlock
from .student_answers_dashboard import StudentAnswersDashboardBlock
from .mcq import MCQBlock, RatingBlock
from .mrq import MRQBlock
from .message import MentoringMessageBlock
......
.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
.data-export-options, .data-export-results, .data-export-status {
margin-top: 2em;
}
.data-export-options, .data-export-results table {
border: 2px solid #999;
}
.data-export-options, .data-export-results thead {
background-color: #ddd;
}
.data-export-options {
display: table;
padding: 1em;
}
.data-export-header, .data-export-row {
display: table-row;
}
.data-export-header h3, .data-export-results thead {
font-weight: bold;
}
.data-export-header h3 {
margin-top: 0px;
margin-bottom: 10px;
}
.data-export-field-container, .data-export-options .data-export-actions {
display: table-cell;
padding-left: 1em;
}
.data-export-field {
margin-top: .5em;
margin-bottom: .5em;
}
.data-export-field label span {
padding-right: .5em;
vertical-align: middle;
}
.data-export-field input, .data-export-field select {
max-width: 60%;
float: right;
}
.data-export-results, .data-export-download, .data-export-cancel, .data-export-delete {
display: none;
}
.data-export-results table {
width: 100%;
margin-top: 1em;
}
.data-export-results thead {
border-bottom: 2px solid #999;
}
.data-export-results td {
border-left: 1px solid #999;
padding: 5px;
}
.data-export-results tr:nth-child(odd) {
background-color: #eee;
}
.data-export-info p {
font-size: 75%;
}
.data-export-status {
margin-bottom: 1em;
}
.data-export-status i {
font-size: 3em;
}
.data-export-actions {
text-align: right;
}
function DataExportBlock(runtime, element) {
'use strict';
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({
type: 'POST',
url: runtime.handlerUrl(element, 'get_status'),
data: '{}',
success: updateStatus,
dataType: 'json'
});
}
function updateStatus(newStatus) {
var statusChanged = ! _.isEqual(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 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;
$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(
_.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(
gettext('A report is available for download.')
));
$statusArea.append($('<p>').text(
_.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(
gettext('The report is currently being generated…')
));
} else {
$statusArea.append($('<p>').text(
gettext('No report data available.')
));
}
}
}
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,
success: updateStatus,
error: handleError,
dataType: 'json'
});
showSpinner();
});
}
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();
}
function StudentAnswersDashboardBlock(runtime, element) {
'use strict';
var $element = $(element);
// Pagination
var Result = Backbone.Model.extend({
initialize: function(attrs, options) {
_.each(_.zip(Result.properties, options.values), function(pair) {
this.set(pair[0], pair[1]);
}, this);
}
}, { properties: ['section', 'subsection', 'unit', 'type', 'question', 'answer', 'username'] });
var Results = Backbone.PageableCollection.extend({
model: Result,
getCurrentPage: function(returnObject) {
var currentPage = this.state.currentPage;
if (returnObject) {
return this.getPage(currentPage);
}
return currentPage;
},
getTotalPages: function() {
return this.state.totalPages;
}
});
var ResultsView = Backbone.View.extend({
render: function() {
this._insertRecords(this.collection.getCurrentPage(true));
this._updateControls();
this.$('#total-pages').text(this.collection.getTotalPages() || 0);
return this;
},
_insertRecords: function(records) {
var tbody = this.$('tbody');
tbody.empty();
records.each(function(result, index) {
var row = $('<tr>');
_.each(Result.properties, function(name) {
row.append($('<td>').text(result.get(name)));
});
tbody.append(row);
}, this);
if (this.collection.getTotalPages()) {
this.$('#current-page').text(this.collection.getCurrentPage());
} else {
this.$('#current-page').text(0);
}
},
events: {
'click #first-page': '_firstPage',
'click #prev-page': '_prevPage',
'click #next-page': '_nextPage',
'click #last-page': '_lastPage'
},
_firstPage: function() {
this._insertRecords(this.collection.getFirstPage());
this._updateControls();
},
_prevPage: function() {
if (this.collection.hasPreviousPage()) {
this._insertRecords(this.collection.getPreviousPage());
}
this._updateControls();
},
_nextPage: function() {
if (this.collection.hasNextPage()) {
this._insertRecords(this.collection.getNextPage());
}
this._updateControls();
},
_lastPage: function() {
this._insertRecords(this.collection.getLastPage());
this._updateControls();
},
_updateControls: function() {
var currentPage = this.collection.getCurrentPage(),
totalPages = this.collection.getTotalPages() || 0,
backward = ["#first-page", "#prev-page"],
forward = ["#next-page", "#last-page"];
this._enable(backward, currentPage > 1);
this._enable(forward, currentPage < totalPages);
},
_enable: function(controls, condition) {
_.each(controls, function(control) {
this.$(control).prop('disabled', !condition);
}, this);
}
});
var resultsView = new ResultsView({
collection: new Results([], { mode: "client", state: { pageSize: 15 } }),
el: $element.find('#results')
});
// 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 $matchString = $element.find("input[name='match_string']");
var $resultTable = $element.find('.data-export-results');
var status;
function getStatus() {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'get_status'),
data: '{}',
success: updateStatus,
dataType: 'json'
});
}
function updateStatus(newStatus) {
var statusChanged = ! _.isEqual(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')
).css("text-align", "center");
}
function hideResults() {
$resultTable.hide();
}
function showResults() {
if (status.last_export_result) {
$resultTable.show();
}
}
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 $exportInfo = $('.data-export-info', $element),
$statusArea = $('.data-export-status', $element), startTime;
$statusArea.empty();
$exportInfo.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(
_.template(
gettext('Data export failed. Reason: <%= error %>'),
{'error': status.last_export_result.error}
)
));
hideResults();
} else {
startTime = new Date(status.last_export_result.start_timestamp * 1000);
$exportInfo.append($('<p>').text(
_.template(
ngettext(
'Results retrieved on <%= creation_time %> (<%= seconds %> second).',
'Results retrieved on <%= creation_time %> (<%= seconds %> seconds).',
status.last_export_result.generation_time_s.toFixed(1)
),
{
'creation_time': startTime.toString(),
'seconds': status.last_export_result.generation_time_s.toFixed(1)
}
)
));
// Display results
var results = _.map(status.last_export_result.display_data, function(row) {
return new Result(null, { values: row });
});
resultsView.collection.fullCollection.reset(results);
resultsView.render();
showResults();
}
} else {
if (status.export_pending) {
$statusArea.append($('<p>').text(
gettext('The report is currently being generated…')
));
}
}
}
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(),
match_string: $matchString.val()
};
data = JSON.stringify(data);
} else {
data = '{}';
}
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, handlerName),
data: data,
success: updateStatus,
error: handleError,
dataType: 'json'
});
showSpinner();
});
}
addHandler($startButton, 'start_export', true);
addHandler($cancelButton, 'cancel_export');
addHandler($deleteButton, 'delete_export');
$startButton.on('click', hideResults);
$cancelButton.on('click', showResults);
$deleteButton.on('click', hideResults);
$downloadButton.on('click', function() {
window.location.href = status.download_url;
});
showSpinner();
getStatus();
}
......@@ -18,7 +18,7 @@
# "AGPLv3". If not, see <http://www.gnu.org/licenses/>.
#
"""
Data Export: An XBlock for instructors to export student answers from a course.
Student Answers Dashboard: An XBlock for instructors to export student answers from a course.
All processing is done offline.
"""
......@@ -39,16 +39,16 @@ def _(text):
@XBlock.needs("i18n")
@XBlock.wants('user')
class DataExportBlock(XBlock):
class StudentAnswersDashboardBlock(XBlock):
"""
DataExportBlock: An XBlock for instructors to export student answers from a course.
StudentAnswersDashboardBlock: An XBlock for instructors to export student answers from a course.
All processing is done offline.
"""
display_name = String(
display_name=_("Title (Display name)"),
help=_("Title to display"),
default=_("Data Export"),
default=_("Student Answers Dashboard"),
scope=Scope.settings
)
active_export_task_id = String(
......@@ -67,13 +67,13 @@ class DataExportBlock(XBlock):
@property
def display_name_with_default(self):
return "Data Export"
return "Student Answers Dashboard"
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>')
return Fragment(u'<p>Student Answers Dashboard Block</p><p>This block only works from the LMS.</p>')
def check_pending_export(self):
"""
......@@ -105,12 +105,17 @@ class DataExportBlock(XBlock):
_('Rating Question'): 'RatingBlock',
_('Long Answer'): 'AnswerBlock',
}
html = loader.render_template('templates/html/data_export.html', {'block_choices': block_choices})
html = loader.render_template(
'templates/html/student_answers_dashboard.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_css_url(self.runtime.local_resource_url(self, 'public/css/student_answers_dashboard.css'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/student_answers_dashboard.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/underscore-min.js'))
fragment.initialize_js('DataExportBlock')
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/backbone-min.js'))
fragment.add_javascript_url(self.runtime.local_resource_url(self, 'public/js/vendor/backbone.paginator.min.js'))
fragment.initialize_js('StudentAnswersDashboardBlock')
return fragment
@property
......@@ -160,20 +165,17 @@ class DataExportBlock(XBlock):
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
match_string = data.get('match_string', None)
# Process user-submitted data
if block_types == 'all':
block_types = []
else:
get_root = False
block_types = [block_types]
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()
# Make sure we nail down our state before sending off an asynchronous task.
self.save()
if not username:
user_id = None
else:
......@@ -181,13 +183,27 @@ class DataExportBlock(XBlock):
if user_id is None:
self.raise_error(404, _("Could not find the specified username."))
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
# Launch task
from .tasks import export_data as export_data_task # Import here since this is edX LMS specific
self._delete_export()
# Make sure we nail down our state before sending off an asynchronous task.
self.save()
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,
match_string,
get_root=get_root
)
if async_result.ready():
# In development mode, the task may have executed synchronously.
......
......@@ -7,7 +7,8 @@ from celery.task import task
from celery.utils.log import get_task_logger
from instructor_task.models import ReportStore
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import UsageKey, CourseKey
from opaque_keys.edx.keys import CourseKey
from student.models import user_by_anonymous_id
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError
......@@ -19,7 +20,7 @@ logger = get_task_logger(__name__)
@task()
def export_data(course_id, source_block_id_str, block_types, user_id, get_root=True):
def export_data(course_id, source_block_id_str, block_types, user_id, match_string, get_root=True):
"""
Exports student answers to all MCQ questions to a CSV file.
"""
......@@ -48,7 +49,7 @@ def export_data(course_id, source_block_id_str, block_types, user_id, get_root=T
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
blocks_to_include = []
def scan_for_blocks(block):
......@@ -65,40 +66,14 @@ def export_data(course_id, source_block_id_str, block_types, user_id, get_root=T
scan_for_blocks(root)
# Define the header rows of our CSV:
# Define the header row 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 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
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']
# 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]
rows.append(["Section", "Subsection", "Unit", "Type", "Question", "Answer", "Username"])
# Collect results for each block in blocks_to_include
for block in blocks_to_include:
results = _extract_data(course_key_str, block, user_id, match_string)
rows += results
# Generate the CSV:
filename = u"pb-data-export-{}.csv".format(time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(start_timestamp)))
......@@ -113,4 +88,108 @@ def export_data(course_id, source_block_id_str, block_types, user_id, get_root=T
"report_filename": filename,
"start_timestamp": start_timestamp,
"generation_time_s": generation_time_s,
"display_data": [] if len(rows) == 1 else rows[1:1001] # Limit to preview of 1000 items
}
def _extract_data(course_key_str, block, user_id, match_string):
"""
Extract results for `block`.
"""
rows = []
# Extract info for "Section", "Subsection", and "Unit" columns
section_name, subsection_name, unit_name = _get_context(block)
# Extract info for "Type" column
block_type = _get_type(block)
# Extract info for "Question" column
block_question = block.question
# Extract info for "Answer" and "Username" columns
# - Get all of the most recent student submissions for this block:
submissions = _get_submissions(course_key_str, block, user_id)
# - For each submission, look up student's username and answer:
for submission in submissions:
username = _get_username(submission, user_id)
answer = _get_answer(block, submission)
# Short-circuit if answer does not match search criteria
if not match_string.lower() in answer.lower():
continue
rows.append([section_name, subsection_name, unit_name, block_type, block_question, answer, username])
return rows
def _get_context(block):
"""
Return section, subsection, and unit names for `block`.
"""
block_names_by_type = {}
block_iter = block
while block_iter:
block_iter_type = block_iter.scope_ids.block_type
block_names_by_type[block_iter_type] = block_iter.display_name_with_default
block_iter = block_iter.get_parent() if block_iter.parent else None
section_name = block_names_by_type.get('chapter', '')
subsection_name = block_names_by_type.get('sequential', '')
unit_name = block_names_by_type.get('vertical', '')
return section_name, subsection_name, unit_name
def _get_type(block):
"""
Return type of `block`.
"""
return block.scope_ids.block_type
def _get_submissions(course_key_str, block, user_id):
"""
Return submissions for `block`.
"""
# Load the actual student submissions for `block`.
# Note this requires one giant query that retrieves all student submissions for `block` at once.
block_id = unicode(block.scope_ids.usage_id.replace(branch=None, version_guid=None))
block_type = _get_type(block)
if not user_id:
return 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,
}
return sub_api.get_submissions(student_dict, limit=1)
def _get_username(submission, user_id):
"""
Return username of student who provided `submission`.
"""
# 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)
return user_by_anonymous_id(student_id).username
def _get_answer(block, submission):
"""
Return answer associated with this `submission` to `block`.
"""
answer = submission['answer']
try:
choices = block.children
except AttributeError:
pass
else:
for choice in choices:
choice_block = modulestore().get_item(choice)
if choice_block.value == answer:
answer = choice_block.content
break
return answer
{% load i18n %}
<h3>{% trans "Data Export" %}</h3>
<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">{% trans "Download result" %}</button>
<button class="data-export-cancel">{% trans "Cancel current export" %}</button>
<button class="data-export-delete">{% trans "Delete result" %}</button>
</div>
<h2>{% trans "Student Answers Dashboard" %}</h3>
<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 class="data-export-header">
<h3>{% trans "Filters" %}</h3>
</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 class="data-export-row">
<div class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Username:" %}</span>
<input type="text" name="username" />
</label>
</div>
</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 class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Text:" %}</span>
<input type="text" name="match_string" />
</label>
</div>
</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 class="data-export-row">
<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>
<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 class="data-export-field-container">
<div class="data-export-field">
<label>
<span>{% trans "Problem types:" %}</span>
<select name="block_types">
<option value="all">All</option>
{% for label, value in block_choices.items %}
<option value="{{value}}">{{label}}</option>
{% endfor %}
</select>
</label>
</div>
</div>
<div class="data-export-actions">
<button class="data-export-start">Search</button>
</div>
</div>
</div>
<div id="results" class="data-export-results">
<table>
<thead>
<tr>
<td>{% trans "Section" %}</td>
<td>{% trans "Subsection" %}</td>
<td>{% trans "Unit" %}</td>
<td>{% trans "Type" %}</td>
<td>{% trans "Question" %}</td>
<td>{% trans "Answer" %}</td>
<td>{% trans "Username" %}</td>
</tr>
</thead>
<tbody></tbody>
</table>
<div class="data-export-info"></div>
<div class="data-export-result-actions">
<button id="first-page">First</button>
<button id="prev-page">Prev</button>
<span id="current-page"></span>/<span id="total-pages"></span>
<button id="next-page">Next</button>
<button id="last-page">Last</button>
</div>
</div>
<div class="data-export-status"></div>
<div class="data-export-actions">
<button class="data-export-start">{% trans "Start a new export" %}</button>
<button class="data-export-download">{% trans "Download as CSV" %}</button>
<button class="data-export-cancel">{% trans "Cancel search" %}</button>
<button class="data-export-delete">{% trans "Delete results" %}</button>
</div>
# -*- coding: utf-8 -*-
import time
from mock import patch, Mock
from selenium.common.exceptions import NoSuchElementException
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)
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')
......@@ -54,7 +54,8 @@ BLOCKS = [
'pb-choice = problem_builder:ChoiceBlock',
'pb-dashboard = problem_builder:DashboardBlock',
'pb-data-export = problem_builder:DataExportBlock',
'pb-data-export = problem_builder:StudentAnswersDashboardBlock', # Deprecated; use pb-student-answers-dashboard instead
'pb-student-answers-dashboard = problem_builder:StudentAnswersDashboardBlock',
]
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