Commit c7f34db7 by Braden MacDonald

Merge v1.4: Add CSV Export

parents b08c156e db10cd81
......@@ -24,6 +24,7 @@
from collections import OrderedDict
import functools
import json
import time
from markdown import markdown
import pkg_resources
......@@ -37,6 +38,7 @@ from xblockutils.resources import ResourceLoader
from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin
from .utils import _
try:
# pylint: disable=import-error
from django.conf import settings
......@@ -83,12 +85,122 @@ class ResourceMixin(XBlockWithSettingsMixin, ThemableXBlockMixin):
return frag
class CSVExportMixin(object):
"""
Allows Poll or Surveys XBlocks to support CSV downloads of all users'
details per block.
"""
active_export_task_id = String(
# The UUID of the celery AsyncResult for the most recent export,
# IF we are sill waiting for it to finish
default="",
scope=Scope.user_state_summary,
)
last_export_result = Dict(
# The info dict returned by the most recent successful export.
# If the export failed, it will have an "error" key set.
default=None,
scope=Scope.user_state_summary,
)
@XBlock.json_handler
def csv_export(self, data, suffix=''):
"""
Asynchronously export given data as a CSV file.
"""
# Launch task
from .tasks import export_csv_data # Import here since this is edX LMS specific
# Make sure we nail down our state before sending off an asynchronous task.
async_result = export_csv_data.delay(
unicode(getattr(self.scope_ids, 'usage_id', None)),
unicode(getattr(self.runtime, 'course_id', 'course_id')),
)
if not async_result.ready():
self.active_export_task_id = async_result.id
else:
self._store_export_result(async_result)
return self._get_export_status()
@XBlock.json_handler
def get_export_status(self, data, suffix=''):
"""
Return current export's pending status, previous result,
and the download URL.
"""
return self._get_export_status()
def _get_export_status(self):
self.check_pending_export()
return {
'export_pending': bool(self.active_export_task_id),
'last_export_result': self.last_export_result,
'download_url': self.download_url_for_last_report,
}
def check_pending_export(self):
"""
If we're waiting for an export, see if it has finished, and if so, get the result.
"""
from .tasks import export_csv_data # Import here since this is edX LMS specific
if self.active_export_task_id:
async_result = export_csv_data.AsyncResult(self.active_export_task_id)
if async_result.ready():
self._store_export_result(async_result)
@property
def download_url_for_last_report(self):
""" Get the URL for the last report, if any """
from lms.djangoapps.instructor_task.models import ReportStore # pylint: disable=import-error
# Unfortunately this is a bit inefficient due to the ReportStore API
if not self.last_export_result or self.last_export_result['error'] is not None:
return None
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
course_key = getattr(self.scope_ids.usage_id, 'course_key', None)
return dict(report_store.links_for(course_key)).get(self.last_export_result['report_filename'])
def student_module_queryset(self):
from courseware.models import StudentModule # pylint: disable=import-error
return StudentModule.objects.filter(
course_id=self.runtime.course_id,
module_state_key=self.scope_ids.usage_id,
).order_by('-modified')
def _store_export_result(self, task_result):
""" Given an AsyncResult or EagerResult, save it. """
self.active_export_task_id = ''
if task_result.successful():
if isinstance(task_result.result, dict) and not task_result.result.get('error'):
self.last_export_result = task_result.result
else:
self.last_export_result = {'error': u'Unexpected result: {}'.format(repr(task_result.result))}
else:
self.last_export_result = {'error': unicode(task_result.result)}
def prepare_data(self):
"""
Return a two-dimensional list containing cells of data ready for CSV export.
"""
raise NotImplementedError
def get_filename(self):
"""
Return a string to be used as the filename for the CSV export.
"""
raise NotImplementedError
@XBlock.wants('settings')
@XBlock.needs('i18n')
class PollBase(XBlock, ResourceMixin, PublishEventMixin):
"""
Base class for Poll-like XBlocks.
"""
has_author_view = True
event_namespace = 'xblock.pollbase'
private_results = Boolean(default=False, help=_("Whether or not to display results to the user."))
max_submissions = Integer(default=1, help=_("The maximum number of times a user may send a submission."))
......@@ -301,7 +413,7 @@ class PollBase(XBlock, ResourceMixin, PublishEventMixin):
return cls.json_handler(func)
class PollBlock(PollBase):
class PollBlock(PollBase, CSVExportMixin):
"""
Poll XBlock. Allows a teacher to poll users, and presents the results so
far of the poll to the user when finished.
......@@ -396,6 +508,13 @@ class PollBlock(PollBase):
return None
def author_view(self, context=None):
"""
Used to hide CSV export in Studio view
"""
context['studio_edit'] = True
return self.student_view(context)
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None):
"""
......@@ -620,8 +739,27 @@ class PollBlock(PollBase):
"""),
]
class SurveyBlock(PollBase):
def get_filename(self):
return u"poll-data-export-{}.csv".format(time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(time.time())))
def prepare_data(self):
header_row = ['user_id', 'username', 'user_email', 'question', 'answer']
data = {}
answers_dict = dict(self.answers)
for sm in self.student_module_queryset():
choice = json.loads(sm.state)['choice']
if sm.student.id not in data:
data[sm.student.id] = [
sm.student.id,
sm.student.username,
sm.student.email,
self.question,
answers_dict[choice]['label'],
]
return [header_row] + data.values()
class SurveyBlock(PollBase, CSVExportMixin):
# pylint: disable=too-many-instance-attributes
display_name = String(default=_('Survey'))
......@@ -656,6 +794,13 @@ class SurveyBlock(PollBase):
choices = Dict(help=_("The user's answers"), scope=Scope.user_state)
event_namespace = 'xblock.survey'
def author_view(self, context=None):
"""
Used to hide CSV export in Studio view
"""
context['studio_edit'] = True
return self.student_view(context)
@XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None):
"""
......@@ -1032,3 +1177,28 @@ class SurveyBlock(PollBase):
feedback="### Thank you

for running the tests."/>
""")
]
def get_filename(self):
return u"survey-data-export-{}.csv".format(time.strftime("%Y-%m-%d-%H%M%S", time.gmtime(time.time())))
def prepare_data(self):
header_row = ['user_id', 'username', 'user_email']
sorted_questions = sorted(self.questions, key=lambda x: x[0])
questions = [q[1]['label'] for q in sorted_questions]
data = {}
answers_dict = dict(self.answers)
for sm in self.student_module_queryset():
state = json.loads(sm.state)
if sm.student.id not in data and state.get('choices'):
row = [
sm.student.id,
sm.student.username,
sm.student.email,
]
for q in sorted_questions:
choices = state.get('choices')
if choices:
choice = choices[q[0]]
row.append(answers_dict[choice])
data[sm.student.id] = row
return [header_row + questions] + data.values()
......@@ -274,7 +274,8 @@ th.survey-answer {
font-weight: bold;
}
.view-results-button-wrapper {
.view-results-button-wrapper, .export-results-button-wrapper {
margin-bottom: 5px;
text-align: right;
cursor: pointer;
}
......
......@@ -57,11 +57,22 @@
</div>
{% endif %}
{% if can_view_private_results %}
</div>
{% if can_view_private_results %}
<div class="view-results-button-wrapper">
<button class="view-results-button">{% trans 'View results' %}</button>
</div>
{% endif %}
</div>
</div>
{% if can_view_private_results %}
{% if not studio_edit %}
<div class="export-results-button-wrapper">
<button class="export-results-button">Export results to CSV</button>
<button disabled class="download-results-button">Download CSV</button>
<p class="error-message poll-hidden"></p>
</div>
{% else %}
<p>Student data and results CSV available for download in the LMS.</p>
{% endif %}
{% endif %}
......@@ -69,7 +69,20 @@
{% endif %}
{% if can_view_private_results %}
<div class="view-results-button-wrapper"><button class="view-results-button">{% trans 'View results' %}</button></div>
<div class="view-results-button-wrapper">
<button class="view-results-button">{% trans 'View results' %}</button>
</div>
{% endif %}
</div>
</div>
{% if can_view_private_results %}
{% if not studio_edit %}
<div class="export-results-button-wrapper">
<button class="export-results-button">Export results to CSV</button>
<button disabled class="download-results-button">Download CSV</button>
<p class="error-message poll-hidden"></p>
</div>
{% else %}
<p>Student data and results CSV available for download in the LMS.</p>
{% endif %}
{% endif %}
......@@ -2,14 +2,17 @@
function PollUtil (runtime, element, pollType) {
var self = this;
var exportStatus = {};
this.init = function() {
// Initialization function used for both Poll Types
this.voteUrl = runtime.handlerUrl(element, 'vote');
this.tallyURL = runtime.handlerUrl(element, 'get_results');
this.csv_url= runtime.handlerUrl(element, 'csv_export');
this.votedUrl = runtime.handlerUrl(element, 'student_voted');
this.submit = $('input[type=button]', element);
this.answers = $('input[type=radio]', element);
this.errorMessage = $('.error-message', element);
// Set up gettext in case it isn't available in the client runtime:
if (typeof gettext == "undefined") {
......@@ -51,6 +54,12 @@ function PollUtil (runtime, element, pollType) {
this.viewResultsButton = $('.view-results-button', element);
this.viewResultsButton.click(this.getResults);
this.exportResultsButton = $('.export-results-button', element);
this.exportResultsButton.click(this.exportCsv);
this.downloadResultsButton = $('.download-results-button', element);
this.downloadResultsButton.click(this.downloadCsv);
return this.shouldDisplayResults();
};
......@@ -185,6 +194,46 @@ function PollUtil (runtime, element, pollType) {
self.getResults();
};
function getStatus() {
$.ajax({
type: 'POST',
url: runtime.handlerUrl(element, 'get_export_status'),
data: '{}',
success: updateStatus,
dataType: 'json'
});
}
function updateStatus(newStatus) {
var statusChanged = ! _.isEqual(newStatus, exportStatus);
exportStatus = newStatus;
if (exportStatus.export_pending) {
// Keep polling for status updates when an export is running.
setTimeout(getStatus, 1000);
}
if (statusChanged) {
if (newStatus.last_export_result.error) {
self.errorMessage.text(error);
self.errorMessage.show();
} else {
self.downloadResultsButton.attr('disabled', false);
self.errorMessage.hide()
}
}
}
this.exportCsv = function() {
$.ajax({
type: "POST",
url: self.csv_url,
data: JSON.stringify({}),
success: updateStatus
});
};
this.downloadCsv = function() {
window.location = exportStatus.download_url;
};
this.getResults = function () {
// Used if results are not private, to show the user how other students voted.
function adjustGaugeBackground() {
......
import time
from celery.decorators import task # pylint: disable=import-error
from lms.djangoapps.instructor_task.models import ReportStore # pylint: disable=import-error
from opaque_keys.edx.keys import CourseKey, UsageKey # pylint: disable=import-error
from xmodule.modulestore.django import modulestore # pylint: disable=import-error
@task()
def export_csv_data(block_id, course_id):
"""
Exports student answers to all supported questions to a CSV file.
"""
src_block = modulestore().get_item(UsageKey.from_string(block_id))
start_timestamp = time.time()
course_key = CourseKey.from_string(course_id)
filename = src_block.get_filename()
report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD')
report_store.store_rows(course_key, filename, src_block.prepare_data())
generation_time_s = time.time() - start_timestamp
return {
"error": None,
"report_filename": filename,
"start_timestamp": start_timestamp,
"generation_time_s": generation_time_s,
}
......@@ -44,7 +44,7 @@ def package_data(pkg, roots):
setup(
name='xblock-poll',
version='1.3.5',
version='1.4.0',
description='An XBlock for polling users.',
packages=[
'poll',
......
import unittest
import json
from xblock.field_data import DictFieldData
from poll.poll import PollBlock, SurveyBlock
......
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