Commit db10cd81 by Brad Melin Committed by Braden MacDonald

add csv export for poll and survey xblocks

UI improvements

check for null state before adding results to output

hide export buttons in studio

show message in studio rather than download buttons

dont disable export button when csv is available

correct conditions to see studio message
parent b08c156e
...@@ -24,6 +24,7 @@ ...@@ -24,6 +24,7 @@
from collections import OrderedDict from collections import OrderedDict
import functools import functools
import json import json
import time
from markdown import markdown from markdown import markdown
import pkg_resources import pkg_resources
...@@ -37,6 +38,7 @@ from xblockutils.resources import ResourceLoader ...@@ -37,6 +38,7 @@ from xblockutils.resources import ResourceLoader
from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin from xblockutils.settings import XBlockWithSettingsMixin, ThemableXBlockMixin
from .utils import _ from .utils import _
try: try:
# pylint: disable=import-error # pylint: disable=import-error
from django.conf import settings from django.conf import settings
...@@ -83,12 +85,122 @@ class ResourceMixin(XBlockWithSettingsMixin, ThemableXBlockMixin): ...@@ -83,12 +85,122 @@ class ResourceMixin(XBlockWithSettingsMixin, ThemableXBlockMixin):
return frag 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.wants('settings')
@XBlock.needs('i18n') @XBlock.needs('i18n')
class PollBase(XBlock, ResourceMixin, PublishEventMixin): class PollBase(XBlock, ResourceMixin, PublishEventMixin):
""" """
Base class for Poll-like XBlocks. Base class for Poll-like XBlocks.
""" """
has_author_view = True
event_namespace = 'xblock.pollbase' event_namespace = 'xblock.pollbase'
private_results = Boolean(default=False, help=_("Whether or not to display results to the user.")) 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.")) 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): ...@@ -301,7 +413,7 @@ class PollBase(XBlock, ResourceMixin, PublishEventMixin):
return cls.json_handler(func) 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 Poll XBlock. Allows a teacher to poll users, and presents the results so
far of the poll to the user when finished. far of the poll to the user when finished.
...@@ -396,6 +508,13 @@ class PollBlock(PollBase): ...@@ -396,6 +508,13 @@ class PollBlock(PollBase):
return None 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 @XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None): def student_view(self, context=None):
""" """
...@@ -620,8 +739,27 @@ class PollBlock(PollBase): ...@@ -620,8 +739,27 @@ class PollBlock(PollBase):
"""), """),
] ]
def get_filename(self):
class SurveyBlock(PollBase): 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 # pylint: disable=too-many-instance-attributes
display_name = String(default=_('Survey')) display_name = String(default=_('Survey'))
...@@ -656,6 +794,13 @@ class SurveyBlock(PollBase): ...@@ -656,6 +794,13 @@ class SurveyBlock(PollBase):
choices = Dict(help=_("The user's answers"), scope=Scope.user_state) choices = Dict(help=_("The user's answers"), scope=Scope.user_state)
event_namespace = 'xblock.survey' 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 @XBlock.supports("multi_device") # Mark as mobile-friendly
def student_view(self, context=None): def student_view(self, context=None):
""" """
...@@ -1032,3 +1177,28 @@ class SurveyBlock(PollBase): ...@@ -1032,3 +1177,28 @@ class SurveyBlock(PollBase):
feedback="### Thank you

for running the tests."/> 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 { ...@@ -274,7 +274,8 @@ th.survey-answer {
font-weight: bold; font-weight: bold;
} }
.view-results-button-wrapper { .view-results-button-wrapper, .export-results-button-wrapper {
margin-bottom: 5px;
text-align: right; text-align: right;
cursor: pointer; cursor: pointer;
} }
......
...@@ -57,11 +57,22 @@ ...@@ -57,11 +57,22 @@
</div> </div>
{% endif %} {% endif %}
{% if can_view_private_results %}
</div>
{% if can_view_private_results %}
<div class="view-results-button-wrapper"> <div class="view-results-button-wrapper">
<button class="view-results-button">{% trans 'View results' %}</button> <button class="view-results-button">{% trans 'View results' %}</button>
</div> </div>
{% endif %} {% endif %}
</div>
</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 @@ ...@@ -69,7 +69,20 @@
{% endif %} {% endif %}
{% if can_view_private_results %} {% 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 %} {% endif %}
</div> </div>
</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 @@ ...@@ -2,14 +2,17 @@
function PollUtil (runtime, element, pollType) { function PollUtil (runtime, element, pollType) {
var self = this; var self = this;
var exportStatus = {};
this.init = function() { this.init = function() {
// Initialization function used for both Poll Types // Initialization function used for both Poll Types
this.voteUrl = runtime.handlerUrl(element, 'vote'); this.voteUrl = runtime.handlerUrl(element, 'vote');
this.tallyURL = runtime.handlerUrl(element, 'get_results'); this.tallyURL = runtime.handlerUrl(element, 'get_results');
this.csv_url= runtime.handlerUrl(element, 'csv_export');
this.votedUrl = runtime.handlerUrl(element, 'student_voted'); this.votedUrl = runtime.handlerUrl(element, 'student_voted');
this.submit = $('input[type=button]', element); this.submit = $('input[type=button]', element);
this.answers = $('input[type=radio]', 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: // Set up gettext in case it isn't available in the client runtime:
if (typeof gettext == "undefined") { if (typeof gettext == "undefined") {
...@@ -51,6 +54,12 @@ function PollUtil (runtime, element, pollType) { ...@@ -51,6 +54,12 @@ function PollUtil (runtime, element, pollType) {
this.viewResultsButton = $('.view-results-button', element); this.viewResultsButton = $('.view-results-button', element);
this.viewResultsButton.click(this.getResults); 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(); return this.shouldDisplayResults();
}; };
...@@ -185,6 +194,46 @@ function PollUtil (runtime, element, pollType) { ...@@ -185,6 +194,46 @@ function PollUtil (runtime, element, pollType) {
self.getResults(); 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 () { this.getResults = function () {
// Used if results are not private, to show the user how other students voted. // Used if results are not private, to show the user how other students voted.
function adjustGaugeBackground() { 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,
}
import unittest import unittest
import json import json
from xblock.field_data import DictFieldData from xblock.field_data import DictFieldData
from poll.poll import PollBlock, SurveyBlock 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