From c053b93f0cc9967bc721363b618fc9bf56b48c5d Mon Sep 17 00:00:00 2001 From: Michael Hawkins Date: Thu, 3 Sep 2020 13:55:30 +0800 Subject: [PATCH] MDL-69559 course: Add course content download UI and execution page --- course/amd/build/downloadcontent.min.js | Bin 0 -> 2525 bytes course/amd/build/downloadcontent.min.js.map | Bin 0 -> 7523 bytes course/amd/src/downloadcontent.js | 125 ++++++++++++++++++ course/classes/output/content_export_link.php | 60 +++++++++ course/downloadcontent.php | 94 +++++++++++++ course/view.php | 14 ++ lang/en/course.php | 1 + lib/navigationlib.php | 12 +- 8 files changed, 305 insertions(+), 1 deletion(-) create mode 100644 course/amd/build/downloadcontent.min.js create mode 100644 course/amd/build/downloadcontent.min.js.map create mode 100644 course/amd/src/downloadcontent.js create mode 100644 course/classes/output/content_export_link.php create mode 100644 course/downloadcontent.php diff --git a/course/amd/build/downloadcontent.min.js b/course/amd/build/downloadcontent.min.js new file mode 100644 index 0000000000000000000000000000000000000000..8e9932041e964444165424c8a15b211826cd6cb2 GIT binary patch literal 2525 zcmbtWO>^5e5WVMDFzAeioS`9~#h`BD_~ta3+HDVxM*{*&2^K`K04Q4{^S@^Sij=H) zl1_UOMPRXSKi*rgYHQ(@sgt?iHejlhXFKv+$xG1FCFlmMOU{yhj})p(Nwq&g80(jl zo9((ZdNLs{{yE*C%7f+J*lf24VS|r=X2G|%-a{n3>p(mBktIi@!NDokWWuR1wez+W zh{zZlg5z(8|Fp5jn-KfO5b%3Xd6a*fhvfE&FA!x$I&6&3?SHY{`^c# ztO3EMA3Ax=n=#Ijfjg`hZ?&Hx2|}PH!{h?v%gLmqOYSg&5w4^Q@VF2y1>1GGWUrQe zGTASq{pZJe=*CBI!m0*)T~x-V0S^*YCw-FpBO^}(`q5!3xYpQDz<9#Riph(gphW9g zYyxIfO(tm-GI)Njr~{9FUJX7KkzKta#U0!;jz3-3i9oJUEXHGnvRxVcAd1Ka1&KrR z)RE3l8hF8Hr-gqw=EQ+A)qq(@Bic1;hjJ;ef{3n_LlJ^SiE1TQiL*(}A5IHtd zQelC6NOxX&4Kq#yB9@5`ptoT?53^?`X?Qq#^Zu`^`Q`iHFR!li(#Y+MylZ|XVWtU= z_5IGp2eiq_yD1uFdD(i8!*8~8eDfOKhq!+WPrl1NtQ(EdU4{YnoL$PisW z>GlIIjwgcYQ@?~d9dwB$39Vn6M=Vh2m1JXg;sCn=jk5?TPH1$IjLCoRKIvQ1k4N^E zge<-i>`9IcAA0`3y$W6n(7oTqPyF@)SyzR^w|brswC kW;*>Parpfq0B_a?zX$&O;~H!D;!A|CRZSlpUGwJ6Hz0G9i~s-t literal 0 HcmV?d00001 diff --git a/course/amd/build/downloadcontent.min.js.map b/course/amd/build/downloadcontent.min.js.map new file mode 100644 index 0000000000000000000000000000000000000000..29cc63d7624071eec28e617db266f7d3cb4c5163 GIT binary patch literal 7523 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 A. + +/** + * Functions related to downloading course content. + * + * @module core_course/downloadcontent + * @package core_course + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import Config from 'core/config'; +import CustomEvents from 'core/custom_interaction_events'; +import * as ModalFactory from 'core/modal_factory'; +import jQuery from 'jquery'; +import Pending from 'core/pending'; + +/** + * Set up listener to trigger the download course content modal. + * + * @return {void} + */ +export const init = () => { + const pendingPromise = new Pending(); + + document.addEventListener('click', (e) => { + const downloadModalTrigger = e.target.closest('[data-downloadcourse]'); + + if (downloadModalTrigger) { + e.preventDefault(); + displayDownloadConfirmation(downloadModalTrigger); + } + }); + + pendingPromise.resolve(); +}; + +/** + * Display the download course content modal. + * + * @method displayDownloadConfirmation + * @param {Object} downloadModalTrigger The DOM element that triggered the download modal. + * @return {void} + */ +const displayDownloadConfirmation = (downloadModalTrigger) => { + ModalFactory.create({ + title: downloadModalTrigger.dataset.downloadTitle, + type: ModalFactory.types.SAVE_CANCEL, + body: `

${downloadModalTrigger.dataset.downloadBody}

`, + buttons: { + save: downloadModalTrigger.dataset.downloadButtonText + }, + templateContext: { + classes: 'downloadcoursecontentmodal' + } + }) + .then(modal => { + // Display the modal. + modal.show(); + + const saveButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="save"]'); + const cancelButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action="cancel"]'); + const modalContainer = document.querySelector('.modal[data-region="modal-container"]'); + + // Create listener to trigger the download when the "Download" button is pressed. + jQuery(saveButton).on(CustomEvents.events.activate, (e) => downloadContent(e, downloadModalTrigger, modal)); + + // Create listener to destroy the modal when closing modal by cancelling. + jQuery(cancelButton).on(CustomEvents.events.activate, () => { + modal.destroy(); + }); + + // Create listener to destroy the modal when closing modal by clicking outside of it. + if (modalContainer.querySelector('.downloadcoursecontentmodal')) { + jQuery(modalContainer).on(CustomEvents.events.activate, () => { + modal.destroy(); + }); + } + }); +}; + +/** + * Trigger downloading of course content. + * + * @method downloadContent + * @param {Event} e The event triggering the download. + * @param {Object} downloadModalTrigger The DOM element that triggered the download modal. + * @param {Object} modal The modal object. + * @return {void} + */ +const downloadContent = (e, downloadModalTrigger, modal) => { + e.preventDefault(); + + // Create a form to submit the file download request, so we can avoid sending sesskey over GET. + const downloadForm = document.createElement('form'); + downloadForm.action = downloadModalTrigger.dataset.downloadLink; + downloadForm.method = 'POST'; + // Open download in a new tab, so current course view is not disrupted. + downloadForm.target = '_blank'; + const downloadSesskey = document.createElement('input'); + downloadSesskey.name = 'sesskey'; + downloadSesskey.value = Config.sesskey; + downloadForm.appendChild(downloadSesskey); + downloadForm.style.display = 'none'; + + document.body.appendChild(downloadForm); + downloadForm.submit(); + document.body.removeChild(downloadForm); + + // Destroy the modal to prevent duplicates if reopened later. + modal.destroy(); +}; diff --git a/course/classes/output/content_export_link.php b/course/classes/output/content_export_link.php new file mode 100644 index 00000000000..6b3deea0ddd --- /dev/null +++ b/course/classes/output/content_export_link.php @@ -0,0 +1,60 @@ +. + +/** + * Prepares content for buttons/links to course content export/download. + * + * @package core_course + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core_course\output; + +/** + * Prepares content for buttons/links to course content export/download. + * + * @package core_course + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class content_export_link { + + /** + * Prepare and return the various attributes required for a link/button to populate/trigger the download course content modal. + * + * @param \context $context The context of the content being exported. + * @return stdClass + */ + public static function get_attributes(\context $context): \stdClass { + global $CFG; + $downloadattr = new \stdClass(); + $downloadattr->url = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id]); + $downloadattr->displaystring = get_string('downloadcoursecontent', 'course'); + $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile); + $downloadlink = new \moodle_url('/course/downloadcontent.php', ['contextid' => $context->id, 'download' => 1]); + + $downloadattr->elementattributes = [ + 'data-downloadcourse' => 1, + 'data-download-body' => get_string('downloadcourseconfirmation', 'course', $maxfilesize), + 'data-download-button-text' => get_string('download'), + 'data-download-link' => $downloadlink->out(false), + 'data-download-title' => get_string('downloadcoursecontent', 'course'), + ]; + + return $downloadattr; + } +} diff --git a/course/downloadcontent.php b/course/downloadcontent.php new file mode 100644 index 00000000000..7813a0fb2e3 --- /dev/null +++ b/course/downloadcontent.php @@ -0,0 +1,94 @@ +. + +/** + * Download course content confirmation and execution. + * + * @package core + * @subpackage course + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +require_once('../config.php'); + +use core\content; +use core\content\export\zipwriter; + +$contextid = required_param('contextid', PARAM_INT); +$isdownload = optional_param('download', 0, PARAM_BOOL); +$coursecontext = context::instance_by_id($contextid); +$courseid = $coursecontext->instanceid; +$courselink = new moodle_url('/course/view.php', ['id' => $courseid]); + +if (!\core\content::can_export_context($coursecontext, $USER)) { + redirect($courselink); +} + +$PAGE->set_url('/course/downloadcontent.php', ['contextid' => $contextid]); +require_login($courseid); + +$courseinfo = get_fast_modinfo($courseid)->get_course(); +$filename = str_replace('/', '', str_replace(' ', '_', $courseinfo->shortname)) . '_' . time() . '.zip'; + +// If download confirmed, prepare and start the zipstream of the course download content. +if ($isdownload) { + confirm_sesskey(); + + $exportoptions = null; + + if (!empty($CFG->maxsizeperdownloadcoursefile)) { + $exportoptions = new stdClass(); + $exportoptions->maxfilesize = $CFG->maxsizeperdownloadcoursefile; + } + + // Use file writer in debug developer mode, so any errors can be displayed instead of being streamed into the output file. + if (debugging('', DEBUG_DEVELOPER)) { + $writer = zipwriter::get_file_writer($filename, $exportoptions); + + ob_start(); + content::export_context($coursecontext, $USER, $writer); + $content = ob_get_clean(); + + // If no errors found, output the file. + if (empty($content)) { + send_file($writer->get_file_path(), $filename); + redirect($courselink); + } else { + // If any errors occurred, display them instead of outputting the file. + debugging("Errors found while producing the download course content output:\n {$content}", DEBUG_DEVELOPER); + } + } else { + // If not developer debugging, stream the output file directly. + $writer = zipwriter::get_stream_writer($filename, $exportoptions); + content::export_context($coursecontext, $USER, $writer); + + redirect($courselink); + } + +} else { + $PAGE->set_title(get_string('downloadcoursecontent', 'course')); + $PAGE->set_heading(format_string($courseinfo->fullname)); + + echo $OUTPUT->header(); + echo $OUTPUT->heading(get_string('downloadcoursecontent', 'course')); + + // Prepare download confirmation information and display it. + $maxfilesize = display_size($CFG->maxsizeperdownloadcoursefile); + $downloadlink = new moodle_url('/course/downloadcontent.php', ['contextid' => $contextid, 'download' => 1]); + + echo $OUTPUT->confirm(get_string('downloadcourseconfirmation', 'course', $maxfilesize), $downloadlink, $courselink); +} diff --git a/course/view.php b/course/view.php index 611ed6c54b3..80b70260400 100644 --- a/course/view.php +++ b/course/view.php @@ -223,12 +223,21 @@ $PAGE->requires->js_init_call('M.core_completion.init'); } + // Determine whether the user has permission to download course content. + $candownloadcourse = \core\content::can_export_context($context, $USER); + // We are currently keeping the button here from 1.x to help new teachers figure out // what to do, even though the link also appears in the course admin block. It also // means you can back out of a situation where you removed the admin block. :) if ($PAGE->user_allowed_editing()) { $buttons = $OUTPUT->edit_button($PAGE->url); $PAGE->set_button($buttons); + } else if ($candownloadcourse) { + // Show the download course content button if user has permission to access it. + // Only showing this if user doesn't have edit rights, since those who do will access it via the actions menu. + $buttonattr = \core_course\output\content_export_link::get_attributes($context); + $button = new single_button($buttonattr->url, $buttonattr->displaystring, 'post', false, $buttonattr->elementattributes); + $PAGE->set_button($OUTPUT->render($button)); } // If viewing a section, make the title more specific @@ -300,4 +309,9 @@ // Include course AJAX include_course_ajax($course, $modnamesused); + // If available, include the JS to prepare the download course content modal. + if ($candownloadcourse) { + $PAGE->requires->js_call_amd('core_course/downloadcontent', 'init'); + } + echo $OUTPUT->footer(); diff --git a/lang/en/course.php b/lang/en/course.php index 506ade49ac7..a4e069df29f 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -54,6 +54,7 @@ $string['customfield_visibility_help'] = 'This setting determines who can view t $string['customfield_visibletoall'] = 'Everyone'; $string['customfield_visibletoteachers'] = 'Teachers'; $string['customfieldsettings'] = 'Common course custom fields settings'; +$string['downloadcourseconfirmation'] = 'You are about to download a zip file of course content (excluding items which cannot be downloaded and any files larger than {$a}).'; $string['downloadcoursecontent'] = 'Download course content'; $string['downloadcoursecontent_help'] = 'This setting determines whether course content may be downloaded by users with the download course content capability (by default users with the role of student or teacher).'; $string['enabledownloadcoursecontent'] = 'Enable download course content'; diff --git a/lib/navigationlib.php b/lib/navigationlib.php index f656e8b89eb..d579e8ef34a 100644 --- a/lib/navigationlib.php +++ b/lib/navigationlib.php @@ -4448,7 +4448,7 @@ class settings_navigation extends navigation_node { * @return navigation_node|false */ protected function load_course_settings($forceopen = false) { - global $CFG; + global $CFG, $USER; require_once($CFG->dirroot . '/course/lib.php'); $course = $this->page->course; @@ -4605,6 +4605,16 @@ class settings_navigation extends navigation_node { } } + // Prepare data for course content download functionality if it is enabled. + // Will only be included here if the action menu is already in use, otherwise a button will be added to the UI elsewhere. + if (\core\content::can_export_context($coursecontext, $USER) && !empty($coursenode->get_children_key_list())) { + $linkattr = \core_course\output\content_export_link::get_attributes($coursecontext); + $actionlink = new action_link($linkattr->url, $linkattr->displaystring, null, $linkattr->elementattributes); + + $coursenode->add($linkattr->displaystring, $actionlink, self::TYPE_SETTING, null, 'download', + new pix_icon('t/download', '')); + } + // Return we are done return $coursenode; } -- 2.17.1