|
| 1 | +# noqa |
| 2 | +# pylint: skip-file |
| 3 | + |
| 4 | +# |
| 5 | +# Copyright (c) 2014-2015 Harvard, edX & OpenCraft |
| 6 | +# |
| 7 | +# This software's license gives you freedom; you can copy, convey, |
| 8 | +# propagate, redistribute and/or modify this program under the terms of |
| 9 | +# the GNU Affero General Public License (AGPL) as published by the Free |
| 10 | +# Software Foundation (FSF), either version 3 of the License, or (at your |
| 11 | +# option) any later version of the AGPL published by the FSF. |
| 12 | +# |
| 13 | +# This program is distributed in the hope that it will be useful, but |
| 14 | +# WITHOUT ANY WARRANTY; without even the implied warranty of |
| 15 | +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero |
| 16 | +# General Public License for more details. |
| 17 | +# |
| 18 | +# You should have received a copy of the GNU Affero General Public License |
| 19 | +# along with this program in a file in the toplevel directory called |
| 20 | +# "AGPLv3". If not, see <http://www.gnu.org/licenses/>. |
| 21 | +# |
| 22 | +""" |
| 23 | +Instructor Tool: An XBlock for instructors to export student answers from a course. |
| 24 | +
|
| 25 | +All processing is done offline. |
| 26 | +""" |
| 27 | +import json |
| 28 | +import pkg_resources |
| 29 | + |
| 30 | +from django.utils.translation import gettext_noop as _ |
| 31 | +from web_fragments.fragment import Fragment |
| 32 | +from xblock.core import XBlock |
| 33 | +from xblock.exceptions import JsonHandlerError |
| 34 | +from xblock.fields import Dict, Scope, String |
| 35 | +from xblock.utils.resources import ResourceLoader |
| 36 | + |
| 37 | +loader = ResourceLoader(__name__) |
| 38 | + |
| 39 | + |
| 40 | +@XBlock.wants('user') |
| 41 | +class DataExportXBlock(XBlock): |
| 42 | + icon_class = "problem" |
| 43 | + display_name = String( |
| 44 | + display_name=_("(Display name)"), |
| 45 | + help=_("Title to display"), |
| 46 | + default=_("Data export"), |
| 47 | + scope=Scope.settings |
| 48 | + ) |
| 49 | + active_export_task_id = String( |
| 50 | + # The UUID of the celery AsyncResult for the most recent export, |
| 51 | + # IF we are sill waiting for it to finish |
| 52 | + default="", |
| 53 | + scope=Scope.user_state, |
| 54 | + ) |
| 55 | + last_export_result = Dict( |
| 56 | + # The info dict returned by the most recent successful export. |
| 57 | + # If the export failed, it will have an "error" key set. |
| 58 | + default=None, |
| 59 | + scope=Scope.user_state, |
| 60 | + ) |
| 61 | + |
| 62 | + has_author_view = True |
| 63 | + |
| 64 | + def author_view(self, context=None): |
| 65 | + """ Studio View """ |
| 66 | + # Warn the user that this block will only work from the LMS. (Since the CMS uses |
| 67 | + # different celery queues; our task listener is waiting for tasks on the LMS queue) |
| 68 | + return Fragment('<p>Data Export Block</p><p>This block only works from the LMS.</p>') |
| 69 | + |
| 70 | + def studio_view(self, context=None): |
| 71 | + """ View for editing Instructor Tool block in Studio. """ |
| 72 | + # Display friendly message explaining that the block is not editable. |
| 73 | + return Fragment('<p>This is a preconfigured block. It is not editable.</p>') |
| 74 | + |
| 75 | + def check_pending_export(self): |
| 76 | + """ |
| 77 | + If we're waiting for an export, see if it has finished, and if so, get the result. |
| 78 | + """ |
| 79 | + from .tasks import export_data as export_data_task |
| 80 | + if self.active_export_task_id: |
| 81 | + async_result = export_data_task.AsyncResult(self.active_export_task_id) |
| 82 | + if async_result.ready(): |
| 83 | + self._save_result(async_result) |
| 84 | + |
| 85 | + def _save_result(self, task_result): |
| 86 | + """ Given an AsyncResult or EagerResult, save it. """ |
| 87 | + self.active_export_task_id = '' |
| 88 | + if task_result.successful(): |
| 89 | + self.last_export_result = task_result.result |
| 90 | + else: |
| 91 | + self.last_export_result = {'error': str(task_result.result)} |
| 92 | + |
| 93 | + def resource_string(self, path): |
| 94 | + """Handy helper for getting resources from our kit.""" |
| 95 | + data = pkg_resources.resource_string(__name__, path) |
| 96 | + return data.decode("utf8") |
| 97 | + |
| 98 | + def student_view(self, context=None): |
| 99 | + """ Normal View """ |
| 100 | + if not self.user_is_staff(): |
| 101 | + return Fragment('<p>This interface can only be used by course staff.</p>') |
| 102 | + |
| 103 | + html = loader.render_django_template('templates/export.html') |
| 104 | + frag = Fragment(html) |
| 105 | + frag.add_javascript(self.resource_string("static/js/src/export.js")) |
| 106 | + frag.initialize_js('DataExportXBlock') |
| 107 | + return frag |
| 108 | + |
| 109 | + @property |
| 110 | + def download_url_for_last_report(self): |
| 111 | + """ Get the URL for the last report, if any """ |
| 112 | + # Unfortunately this is a bit inefficient due to the ReportStore API |
| 113 | + if not self.last_export_result or 'error' in self.last_export_result: |
| 114 | + return None |
| 115 | + from lms.djangoapps.instructor_task.models import ReportStore |
| 116 | + report_store = ReportStore.from_config(config_name='GRADES_DOWNLOAD') |
| 117 | + course_key = getattr(self.scope_ids.usage_id, 'course_key', None) |
| 118 | + return dict(report_store.links_for(course_key)).get(self.last_export_result['report_filename']) |
| 119 | + |
| 120 | + def _get_status(self): |
| 121 | + self.check_pending_export() |
| 122 | + return { |
| 123 | + 'export_pending': bool(self.active_export_task_id), |
| 124 | + 'last_export_result': self.last_export_result, |
| 125 | + 'download_url': self.download_url_for_last_report, |
| 126 | + } |
| 127 | + |
| 128 | + def raise_error(self, code, message): |
| 129 | + """ |
| 130 | + Raises an error and marks the block with a simulated failed task dict. |
| 131 | + """ |
| 132 | + self.last_export_result = { |
| 133 | + 'error': message, |
| 134 | + } |
| 135 | + self.display_data = None |
| 136 | + raise JsonHandlerError(code, message) |
| 137 | + |
| 138 | + @XBlock.json_handler |
| 139 | + def get_status(self, data, suffix=''): |
| 140 | + return self._get_status() |
| 141 | + |
| 142 | + @XBlock.json_handler |
| 143 | + def delete_export(self, data, suffix=''): |
| 144 | + self._delete_export() |
| 145 | + return self._get_status() |
| 146 | + |
| 147 | + def _delete_export(self): |
| 148 | + self.last_export_result = None |
| 149 | + self.display_data = None |
| 150 | + self.active_export_task_id = '' |
| 151 | + |
| 152 | + @XBlock.json_handler |
| 153 | + def start_export(self, data, suffix=''): |
| 154 | + """ Start a new asynchronous export """ |
| 155 | + if not self.user_is_staff(): |
| 156 | + raise JsonHandlerError(403, "Permission denied.") |
| 157 | + |
| 158 | + # Launch task |
| 159 | + from .tasks import export_data as export_data_task |
| 160 | + self._delete_export() |
| 161 | + # Make sure we nail down our state before sending off an asynchronous task. |
| 162 | + self.save() |
| 163 | + async_result = export_data_task.delay( |
| 164 | + # course_id not available in workbench. |
| 165 | + str(getattr(self.runtime, 'course_id', 'course_id')), |
| 166 | + ) |
| 167 | + if async_result.ready(): |
| 168 | + # In development mode, the task may have executed synchronously. |
| 169 | + # Store the result now, because we won't be able to retrieve it later :-/ |
| 170 | + if async_result.successful(): |
| 171 | + # Make sure the result can be represented as JSON, since the non-eager celery |
| 172 | + # requires that |
| 173 | + json.dumps(async_result.result) |
| 174 | + self._save_result(async_result) |
| 175 | + else: |
| 176 | + # The task is running asynchronously. Store the result ID so we can query its progress: |
| 177 | + self.active_export_task_id = async_result.id |
| 178 | + return self._get_status() |
| 179 | + |
| 180 | + @XBlock.json_handler |
| 181 | + def cancel_export(self, request, suffix=''): |
| 182 | + from .tasks import export_data as export_data_task |
| 183 | + if self.active_export_task_id: |
| 184 | + async_result = export_data_task.AsyncResult(self.active_export_task_id) |
| 185 | + async_result.revoke() |
| 186 | + self._delete_export() |
| 187 | + |
| 188 | + def _get_user_attr(self, attr): |
| 189 | + """Get an attribute of the current user.""" |
| 190 | + user_service = self.runtime.service(self, 'user') |
| 191 | + if user_service: |
| 192 | + # May be None when creating bok choy test fixtures |
| 193 | + return user_service.get_current_user().opt_attrs.get(attr) |
| 194 | + return None |
| 195 | + |
| 196 | + def user_is_staff(self): |
| 197 | + """Return a Boolean value indicating whether the current user is a member of staff.""" |
| 198 | + return self._get_user_attr('edx-platform.user_is_staff') |
0 commit comments