diff --git a/admin/settings/courses.php b/admin/settings/courses.php index 6b53eb09b39..c0da1aa113b 100644 --- a/admin/settings/courses.php +++ b/admin/settings/courses.php @@ -22,6 +22,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core_admin\local\settings\filesize; + $capabilities = array( 'moodle/backup:backupcourse', 'moodle/category:manage', @@ -75,6 +77,17 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { $temp->add(new admin_setting_configselect('moodlecourse/visible', new lang_string('visible'), new lang_string('visible_help'), 1, $choices)); + // Enable/disable download course content. + $choices = [ + DOWNLOAD_COURSE_CONTENT_DISABLED => new lang_string('no'), + DOWNLOAD_COURSE_CONTENT_ENABLED => new lang_string('yes'), + ]; + $downloadcontentsitedefault = new admin_setting_configselect('moodlecourse/downloadcontentsitedefault', + new lang_string('enabledownloadcoursecontent', 'course'), + new lang_string('downloadcoursecontent_help', 'course'), 0, $choices); + $downloadcontentsitedefault->add_dependent_on('downloadcoursecontentallowed'); + $temp->add($downloadcontentsitedefault); + // Course format. $temp->add(new admin_setting_heading('courseformathdr', new lang_string('type_format', 'plugin'), '')); @@ -161,6 +174,21 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { $ADMIN->add('courses', $temp); + // Download course content. + $downloadcoursedefaulturl = new moodle_url('/admin/settings.php', ['section' => 'coursesettings']); + $temp = new admin_settingpage('downloadcoursecontent', new lang_string('downloadcoursecontent', 'course')); + $temp->add(new admin_setting_configcheckbox('downloadcoursecontentallowed', + new lang_string('downloadcoursecontentallowed', 'admin'), + new lang_string('downloadcoursecontentallowed_desc', 'admin', $downloadcoursedefaulturl->out()), 0)); + + // 50MB default maximum size per file when downloading course content. + $defaultmaxdownloadsize = 50 * filesize::UNIT_MB; + $temp->add(new filesize('maxsizeperdownloadcoursefile', new lang_string('maxsizeperdownloadcoursefile', 'admin'), + new lang_string('maxsizeperdownloadcoursefile_desc', 'admin'), $defaultmaxdownloadsize, filesize::UNIT_MB)); + $temp->hide_if('maxsizeperdownloadcoursefile', 'downloadcoursecontentallowed'); + + $ADMIN->add('courses', $temp); + // "courserequests" settingpage. $temp = new admin_settingpage('courserequest', new lang_string('courserequest')); $temp->add(new admin_setting_configcheckbox('enablecourserequests', diff --git a/course/amd/build/downloadcontent.min.js b/course/amd/build/downloadcontent.min.js new file mode 100644 index 00000000000..8e9932041e9 --- /dev/null +++ b/course/amd/build/downloadcontent.min.js @@ -0,0 +1,2 @@ +function _typeof(a){"@babel/helpers - typeof";if("function"==typeof Symbol&&"symbol"==typeof Symbol.iterator){_typeof=function(a){return typeof a}}else{_typeof=function(a){return a&&"function"==typeof Symbol&&a.constructor===Symbol&&a!==Symbol.prototype?"symbol":typeof a}}return _typeof(a)}define ("core_course/downloadcontent",["exports","core/config","core/custom_interaction_events","core/modal_factory","jquery","core/pending"],function(a,b,c,d,e,f){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=i(b);c=i(c);d=h(d);e=i(e);f=i(f);function g(){if("function"!=typeof WeakMap)return null;var a=new WeakMap;g=function(){return a};return a}function h(a){if(a&&a.__esModule){return a}if(null===a||"object"!==_typeof(a)&&"function"!=typeof a){return{default:a}}var b=g();if(b&&b.has(a)){return b.get(a)}var c={},d=Object.defineProperty&&Object.getOwnPropertyDescriptor;for(var e in a){if(Object.prototype.hasOwnProperty.call(a,e)){var f=d?Object.getOwnPropertyDescriptor(a,e):null;if(f&&(f.get||f.set)){Object.defineProperty(c,e,f)}else{c[e]=a[e]}}}c.default=a;if(b){b.set(a,c)}return c}function i(a){return a&&a.__esModule?a:{default:a}}var j=function(){var a=new f.default;document.addEventListener("click",function(a){var b=a.target.closest("[data-downloadcourse]");if(b){a.preventDefault();k(b)}});a.resolve()};a.init=j;var k=function(a){d.create({title:a.dataset.downloadTitle,type:d.types.SAVE_CANCEL,body:"

".concat(a.dataset.downloadBody,"

"),buttons:{save:a.dataset.downloadButtonText},templateContext:{classes:"downloadcoursecontentmodal"}}).then(function(b){b.show();var d=document.querySelector(".modal .downloadcoursecontentmodal [data-action=\"save\"]"),f=document.querySelector(".modal .downloadcoursecontentmodal [data-action=\"cancel\"]"),g=document.querySelector(".modal[data-region=\"modal-container\"]");(0,e.default)(d).on(c.default.events.activate,function(c){return l(c,a,b)});(0,e.default)(f).on(c.default.events.activate,function(){b.destroy()});if(g.querySelector(".downloadcoursecontentmodal")){(0,e.default)(g).on(c.default.events.activate,function(){b.destroy()})}})},l=function(a,c,d){a.preventDefault();var e=document.createElement("form");e.action=c.dataset.downloadLink;e.method="POST";e.target="_blank";var f=document.createElement("input");f.name="sesskey";f.value=b.default.sesskey;e.appendChild(f);e.style.display="none";document.body.appendChild(e);e.submit();document.body.removeChild(e);d.destroy()}}); +//# sourceMappingURL=downloadcontent.min.js.map diff --git a/course/amd/build/downloadcontent.min.js.map b/course/amd/build/downloadcontent.min.js.map new file mode 100644 index 00000000000..29cc63d7624 --- /dev/null +++ b/course/amd/build/downloadcontent.min.js.map @@ -0,0 +1 @@ +{"version":3,"sources":["../src/downloadcontent.js"],"names":["init","pendingPromise","Pending","document","addEventListener","e","downloadModalTrigger","target","closest","preventDefault","displayDownloadConfirmation","resolve","ModalFactory","create","title","dataset","downloadTitle","type","types","SAVE_CANCEL","body","downloadBody","buttons","save","downloadButtonText","templateContext","classes","then","modal","show","saveButton","querySelector","cancelButton","modalContainer","on","CustomEvents","events","activate","downloadContent","destroy","downloadForm","createElement","action","downloadLink","method","downloadSesskey","name","value","Config","sesskey","appendChild","style","display","submit","removeChild"],"mappings":"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","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Functions related to downloading course content.\n *\n * @module core_course/downloadcontent\n * @package core_course\n * @copyright 2020 Michael Hawkins \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Config from 'core/config';\nimport CustomEvents from 'core/custom_interaction_events';\nimport * as ModalFactory from 'core/modal_factory';\nimport jQuery from 'jquery';\nimport Pending from 'core/pending';\n\n/**\n * Set up listener to trigger the download course content modal.\n *\n * @return {void}\n */\nexport const init = () => {\n const pendingPromise = new Pending();\n\n document.addEventListener('click', (e) => {\n const downloadModalTrigger = e.target.closest('[data-downloadcourse]');\n\n if (downloadModalTrigger) {\n e.preventDefault();\n displayDownloadConfirmation(downloadModalTrigger);\n }\n });\n\n pendingPromise.resolve();\n};\n\n/**\n * Display the download course content modal.\n *\n * @method displayDownloadConfirmation\n * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.\n * @return {void}\n */\nconst displayDownloadConfirmation = (downloadModalTrigger) => {\n ModalFactory.create({\n title: downloadModalTrigger.dataset.downloadTitle,\n type: ModalFactory.types.SAVE_CANCEL,\n body: `

${downloadModalTrigger.dataset.downloadBody}

`,\n buttons: {\n save: downloadModalTrigger.dataset.downloadButtonText\n },\n templateContext: {\n classes: 'downloadcoursecontentmodal'\n }\n })\n .then(modal => {\n // Display the modal.\n modal.show();\n\n const saveButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action=\"save\"]');\n const cancelButton = document.querySelector('.modal .downloadcoursecontentmodal [data-action=\"cancel\"]');\n const modalContainer = document.querySelector('.modal[data-region=\"modal-container\"]');\n\n // Create listener to trigger the download when the \"Download\" button is pressed.\n jQuery(saveButton).on(CustomEvents.events.activate, (e) => downloadContent(e, downloadModalTrigger, modal));\n\n // Create listener to destroy the modal when closing modal by cancelling.\n jQuery(cancelButton).on(CustomEvents.events.activate, () => {\n modal.destroy();\n });\n\n // Create listener to destroy the modal when closing modal by clicking outside of it.\n if (modalContainer.querySelector('.downloadcoursecontentmodal')) {\n jQuery(modalContainer).on(CustomEvents.events.activate, () => {\n modal.destroy();\n });\n }\n });\n};\n\n/**\n * Trigger downloading of course content.\n *\n * @method downloadContent\n * @param {Event} e The event triggering the download.\n * @param {Object} downloadModalTrigger The DOM element that triggered the download modal.\n * @param {Object} modal The modal object.\n * @return {void}\n */\nconst downloadContent = (e, downloadModalTrigger, modal) => {\n e.preventDefault();\n\n // Create a form to submit the file download request, so we can avoid sending sesskey over GET.\n const downloadForm = document.createElement('form');\n downloadForm.action = downloadModalTrigger.dataset.downloadLink;\n downloadForm.method = 'POST';\n // Open download in a new tab, so current course view is not disrupted.\n downloadForm.target = '_blank';\n const downloadSesskey = document.createElement('input');\n downloadSesskey.name = 'sesskey';\n downloadSesskey.value = Config.sesskey;\n downloadForm.appendChild(downloadSesskey);\n downloadForm.style.display = 'none';\n\n document.body.appendChild(downloadForm);\n downloadForm.submit();\n document.body.removeChild(downloadForm);\n\n // Destroy the modal to prevent duplicates if reopened later.\n modal.destroy();\n};\n"],"file":"downloadcontent.min.js"} \ No newline at end of file diff --git a/course/amd/src/downloadcontent.js b/course/amd/src/downloadcontent.js new file mode 100644 index 00000000000..67a6d831f9e --- /dev/null +++ b/course/amd/src/downloadcontent.js @@ -0,0 +1,125 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * 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/edit_form.php b/course/edit_form.php index 8a289cc7258..9c674744e25 100644 --- a/course/edit_form.php +++ b/course/edit_form.php @@ -120,6 +120,27 @@ class course_edit_form extends moodleform { $mform->setConstant('visible', $courseconfig->visible); } } + + // Download course content. + if ($CFG->downloadcoursecontentallowed) { + $downloadchoices = [ + DOWNLOAD_COURSE_CONTENT_DISABLED => get_string('no'), + DOWNLOAD_COURSE_CONTENT_ENABLED => get_string('yes'), + ]; + $sitedefaultstring = $downloadchoices[$courseconfig->downloadcontentsitedefault]; + $downloadchoices[DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT] = get_string('sitedefaultspecified', '', $sitedefaultstring); + $downloadselectdefault = $courseconfig->downloadcontent ?? DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT; + + $mform->addElement('select', 'downloadcontent', get_string('enabledownloadcoursecontent', 'course'), $downloadchoices); + $mform->addHelpButton('downloadcontent', 'downloadcoursecontent', 'course'); + $mform->setDefault('downloadcontent', $downloadselectdefault); + + if (!has_capability('moodle/course:configuredownloadcontent', $coursecontext)) { + $mform->hardFreeze('downloadcontent'); + $mform->setConstant('downloadcontent', $downloadselectdefault); + } + } + $mform->addElement('date_time_selector', 'startdate', get_string('startdate')); $mform->addHelpButton('startdate', 'startdate'); $date = (new DateTime())->setTimestamp(usergetmidnight(time())); diff --git a/course/lib.php b/course/lib.php index a834ba73222..e34df5a442e 100644 --- a/course/lib.php +++ b/course/lib.php @@ -71,6 +71,11 @@ define('COURSE_CUSTOMFIELD_EMPTY', -1); // Course activity chooser footer default display option. define('COURSE_CHOOSER_FOOTER_NONE', 'hidden'); +// Download course content options. +define('DOWNLOAD_COURSE_CONTENT_DISABLED', 0); +define('DOWNLOAD_COURSE_CONTENT_ENABLED', 1); +define('DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT', 2); + function make_log_url($module, $url) { switch ($module) { case 'course': diff --git a/course/tests/behat/course_download_content.feature b/course/tests/behat/course_download_content.feature new file mode 100644 index 00000000000..8b2e3b887e7 --- /dev/null +++ b/course/tests/behat/course_download_content.feature @@ -0,0 +1,47 @@ +@core @core_course +Feature: Course content can be downloaded + In order to retain a backup offline copy of course activity/resource data + As a user + I can download a course's content + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | + | Hockey 101 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And I navigate to "Courses > Courses > Download course content" in site administration + And I set the following fields to these values: + | Download course content feature available | 1 | + And I press "Save changes" + And I navigate to "Courses > Courses > Course default settings" in site administration + And I set the field "Enable download course content" to "Yes" + And I press "Save changes" + And I log out + + @javascript + Scenario: A student can download course content when the feature is enabled in their course + Given I log in as "student1" + When I am on "Hockey 101" course homepage + And "Download course content" "button" should exist + And I press "Download course content" + Then I should see "You are about to download a zip file" + # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success. + And I click on "Download" "button" in the "Download course content" "dialogue" + + @javascript + Scenario: A teacher can download course content when the feature is enabled in their course + Given I log in as "teacher1" + When I am on "Hockey 101" course homepage + And "Download course content" "link" should exist in current page administration + And I navigate to "Download course content" in current page administration + Then I should see "You are about to download a zip file" + # Without the ability to check the downloaded file, the absence of an exception being thrown here is considered a success. + And I click on "Download" "button" in the "Download course content" "dialogue" diff --git a/course/tests/behat/course_download_content_permissions.feature b/course/tests/behat/course_download_content_permissions.feature new file mode 100644 index 00000000000..e204b538f9e --- /dev/null +++ b/course/tests/behat/course_download_content_permissions.feature @@ -0,0 +1,112 @@ +@core @core_course +Feature: Access to downloading course content can be controlled + In order to allow or restrict access to download course content + As a trusted user + I can control access to the download course content feature + + Background: + Given the following "users" exist: + | username | firstname | lastname | email | + | teacher1 | Teacher | 1 | teacher1@example.com | + | student1 | Student | 1 | student1@example.com | + And the following "courses" exist: + | fullname | shortname | + | Hockey 101 | C1 | + And the following "course enrolments" exist: + | user | course | role | + | teacher1 | C1 | editingteacher | + | student1 | C1 | student | + And I log in as "admin" + And the following config values are set as admin: + | downloadcoursecontentallowed | 1 | + And I log out + +Scenario: Site admins can remove the download course content feature + Given I log in as "admin" + And I am on "Hockey 101" course homepage + And I navigate to "Edit settings" in current page administration + And I set the field "Enable download course content" to "Yes" + And I press "Save and display" + And "Download course content" "link" should exist in current page administration + When the following config values are set as admin: + | downloadcoursecontentallowed | 0 | + And I am on "Hockey 101" course homepage + Then "Download course content" "link" should not exist in current page administration + And I navigate to "Edit settings" in current page administration + And I should not see "Enable download course content" + +Scenario: Site admins can set the default value for whether download course content is enabled in courses + Given I log in as "admin" + And I am on "Hockey 101" course homepage + And "Download course content" "link" should not exist in current page administration + When I navigate to "Courses > Courses > Course default settings" in site administration + And I set the field "Enable download course content" to "Yes" + And I press "Save changes" + And I am on "Hockey 101" course homepage + Then "Download course content" "link" should exist in current page administration + +Scenario: A teacher can enable and disable the download course content feature when it is available + Given I log in as "teacher1" + When I am on "Hockey 101" course homepage + And "Download course content" "link" should not exist in current page administration + And I navigate to "Edit settings" in current page administration + And I should see "Enable download course content" + And I set the field "Enable download course content" to "Yes" + And I press "Save and display" + Then "Download course content" "link" should exist in current page administration + And I navigate to "Edit settings" in current page administration + And I set the field "Enable download course content" to "No" + And I press "Save and display" + And "Download course content" "link" should not exist in current page administration + +Scenario: Teachers require a capability to access the download course content feature or modify its availability in a course + Given I log in as "admin" + And I navigate to "Courses > Courses > Course default settings" in site administration + And I set the field "Enable download course content" to "Yes" + And I press "Save changes" + And I log out + # Check teacher can see download option and enable dropdown. + And I log in as "teacher1" + And I am on "Hockey 101" course homepage + And "Download course content" "link" should exist in current page administration + And I navigate to "Edit settings" in current page administration + And "Enable download course content" "select" should exist + And I log out + # Remove teacher's capabilities for download course content. + And I log in as "admin" + And I set the following system permissions of "Teacher" role: + | capability | permission | + | moodle/course:downloadcoursecontent | Prohibit | + | moodle/course:configuredownloadcontent | Prohibit | + And I log out + # Check teacher can no longer see download option, and that enable value is visible, but dropdown no longer available. + When I log in as "teacher1" + And I am on "Hockey 101" course homepage + Then "Download course content" "link" should not exist in current page administration + And I navigate to "Edit settings" in current page administration + And I should see "Enable download course content" + And I should see "Site default (Yes)" + And "Enable download course content" "select" should not exist + +Scenario: Students require a capability to access the download course content feature in a course + Given I log in as "teacher1" + And I am on "Hockey 101" course homepage + And I navigate to "Edit settings" in current page administration + And I set the field "Enable download course content" to "Yes" + And I press "Save and display" + And I log out + # Check student can see download button. + And I log in as "student1" + And I am on "Hockey 101" course homepage + And "Download course content" "button" should exist + And I log out + And I log in as "admin" + # Remove student's capability for download course content. + When I set the following system permissions of "Student" role: + | capability | permission | + | moodle/course:downloadcoursecontent | Prohibit | + And I log out + # Check student can no longer see download button. + And I log in as "student1" + And I am on "Hockey 101" course homepage + Then "Download course content" "link" should not exist in current page administration 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/admin.php b/lang/en/admin.php index 4f66c4f9be0..f6768fbff48 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -516,6 +516,8 @@ $string['doctonewwindow'] = 'Open in new window'; $string['doesnotfit'] = 'Email display settings'; $string['doesnotfitdetail'] = 'Display settings for email leaving Moodle.'; $string['download'] = 'Download'; +$string['downloadcoursecontentallowed'] = 'Download course content feature available'; +$string['downloadcoursecontentallowed_desc'] = 'Whether the download course content feature is available to courses. When available, course content downloads can be enabled/disabled using the "Enable download course content" setting within the course edit menu (the default for this can be set in Course default settings).'; $string['durationunits'] = 'duration units'; $string['edithelpdocs'] = 'Edit help documents'; $string['editlang'] = 'Edit'; @@ -802,6 +804,8 @@ sites. If this is not what you wanted then you should make sure you are updating from a STABLE branch of the Moodle code. See Moodle Docs for more details.'; $string['maxbytes'] = 'Maximum uploaded file size'; $string['maxconsecutiveidentchars'] = 'Consecutive identical characters'; +$string['maxsizeperdownloadcoursefile'] = 'Maximum size per file'; +$string['maxsizeperdownloadcoursefile_desc'] = 'The maximum size of each file when downloading course content. Files exceeding this size will be omitted from the download.'; $string['maxeditingtime'] = 'Maximum time to edit posts'; $string['maxusersperpage'] = ' Maximum users per page'; $string['configmaxusersperpage'] = 'Maximum number of users displayed within user selector in course, group, cohort, webservice etc.'; diff --git a/lang/en/course.php b/lang/en/course.php index 156ebabad61..a4e069df29f 100644 --- a/lang/en/course.php +++ b/lang/en/course.php @@ -54,6 +54,10 @@ $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'; $string['errorendbeforestart'] = 'The end date ({$a}) is before the course start date.'; $string['favourite'] = 'Starred course'; $string['gradetopassnotset'] = 'This course does not have a grade to pass set. It may be set in the grade item of the course (Gradebook setup).'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 3fe5a858aee..05702bb9d83 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1930,6 +1930,7 @@ $string['since'] = 'Since'; $string['sincelast'] = 'since last login'; $string['site'] = 'Site'; $string['sitedefault'] = 'Site default'; +$string['sitedefaultspecified'] = 'Site default ({$a})'; $string['siteerrors'] = 'Site errors'; $string['sitefiles'] = 'Site files'; $string['sitefilesused'] = 'Site files used in this course'; diff --git a/lang/en/role.php b/lang/en/role.php index 625c0c029b8..e8841271307 100644 --- a/lang/en/role.php +++ b/lang/en/role.php @@ -172,6 +172,8 @@ $string['course:changelockedcustomfields'] = 'Change locked custom fields'; $string['course:changeshortname'] = 'Change course short name'; $string['course:changesummary'] = 'Change course summary'; $string['course:configurecustomfields'] = 'Configure custom fields'; +$string['course:configuredownloadcontent'] = 'Configure download course content'; +$string['course:downloadcoursecontent'] = 'Download course content'; $string['course:enrolconfig'] = 'Configure enrol instances in courses'; $string['course:enrolreview'] = 'Review course enrolments'; $string['course:setforcedlanguage'] = 'Force course language'; diff --git a/lib/classes/content.php b/lib/classes/content.php index 16992133231..d9ad2c91599 100644 --- a/lib/classes/content.php +++ b/lib/classes/content.php @@ -52,7 +52,30 @@ class content { * @return bool */ public static function can_export_context(context $currentcontext, stdClass $user): bool { - return true; + global $CFG; + + $canexport = false; + + if ($currentcontext->contextlevel == CONTEXT_COURSE) { + if ($CFG->downloadcoursecontentallowed && + has_capability('moodle/course:downloadcoursecontent', $currentcontext, $user)) { + + $courseinfo = get_fast_modinfo($currentcontext->instanceid)->get_course(); + + // If enabled/disabled explicitly set on course, use that as the course setting, otherwise use site default. + if (isset($courseinfo->downloadcontent) && $courseinfo->downloadcontent != DOWNLOAD_COURSE_CONTENT_SITE_DEFAULT) { + $canexport = $courseinfo->downloadcontent; + } else { + $canexport = get_config('moodlecourse')->downloadcontentsitedefault; + } + + } + } else if ($currentcontext->contextlevel == CONTEXT_MODULE) { + // Modules can only be exported if exporting is allowed in their course context. + $canexport = self::can_export_context($currentcontext->get_course_context(), $user); + } + + return $canexport; } /** diff --git a/lib/db/access.php b/lib/db/access.php index 5078f4d3daf..74861f2cb36 100644 --- a/lib/db/access.php +++ b/lib/db/access.php @@ -2576,4 +2576,26 @@ $capabilities = array( 'editingteacher' => CAP_ALLOW, ] ], + + // Allow users to download course content. + 'moodle/course:downloadcoursecontent' => [ + 'captype' => 'read', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => array( + 'student' => CAP_ALLOW, + 'teacher' => CAP_ALLOW, + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ) + ], + + // Allow users to configure download course content functionality within a course, if the feature is available. + 'moodle/course:configuredownloadcontent' => [ + 'captype' => 'write', + 'contextlevel' => CONTEXT_COURSE, + 'archetypes' => array( + 'editingteacher' => CAP_ALLOW, + 'manager' => CAP_ALLOW + ) + ], ); diff --git a/lib/db/install.xml b/lib/db/install.xml index 478146184cc..391c79979ae 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -1,5 +1,5 @@ - @@ -89,6 +89,7 @@ + @@ -4322,4 +4323,4 @@ - + \ No newline at end of file diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index b9c5d342359..4f3fc7d44f0 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2774,6 +2774,7 @@ function xmldb_main_upgrade($oldversion) { // Add example field. $field = new xmldb_field('example', XMLDB_TYPE_TEXT, null, null, null, null, null, 'tutorial'); + if (!$dbman->field_exists($table, $field)) { $dbman->add_field($table, $field); } @@ -2862,5 +2863,18 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2021052500.30); } + if ($oldversion < 2021052500.32) { + // Define field downloadcontent to be added to course. + $table = new xmldb_table('course'); + $field = new xmldb_field('downloadcontent', XMLDB_TYPE_INTEGER, '1', null, null, null, null, 'visibleold'); + + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2021052500.32); + } + return true; } 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; } diff --git a/lib/tests/content_test.php b/lib/tests/content_test.php new file mode 100644 index 00000000000..52c8f56ddd8 --- /dev/null +++ b/lib/tests/content_test.php @@ -0,0 +1,119 @@ +. + +/** + * Unit tests for core\content class. + * + * @package core + * @category test + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core; + +/** + * Unit tests for core\content class. + * + * @package core + * @category test + * @copyright 2020 Michael Hawkins + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class content_test extends \advanced_testcase { + + /** + * A test to confirm only valid cases allow exporting of course content. + */ + public function test_can_export_context_course() { + global $DB; + + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course(); + $course2 = $this->getDataGenerator()->create_course(); + $course1context = \context_course::instance($course1->id); + $course2context = \context_course::instance($course2->id); + + // Enrol user as student in course1 only. + $user = $this->getDataGenerator()->create_and_enrol($course1, 'student'); + + // Confirm by default enrolled user does not have permission to export in course1. + $this->assertFalse(content::can_export_context($course1context, $user)); + + // Make course download available on site, but not enabled in course1 or by default. + set_config('downloadcoursecontentallowed', true); + + // Confirm user still does not have permission to export (disabled in courses by default). + $this->assertFalse(content::can_export_context($course1context, $user)); + + // Enable export in courses by default. + set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_ENABLED, 'moodlecourse'); + + // Confirm user now has permission to export in course1 only. + $this->assertTrue(content::can_export_context($course1context, $user)); + + // Disable course downloads in course1. + $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_DISABLED; + $DB->update_record('course', $course1); + rebuild_course_cache($course1->id); + + // Confirm user does not have permission to export in course1. + $this->assertFalse(content::can_export_context($course1context, $user)); + + // Enable course downloads in course1. + $course1->downloadcontent = DOWNLOAD_COURSE_CONTENT_ENABLED; + $DB->update_record('course', $course1); + rebuild_course_cache($course1->id); + + // Confirm user has permission to export in course1. + $this->assertTrue(content::can_export_context($course1context, $user)); + + // Confirm user does not have permission to export in course they are not enrolled in (course2). + $this->assertFalse(content::can_export_context($course2context, $user)); + + // Disable export in courses by default. + set_config('downloadcontentsitedefault', DOWNLOAD_COURSE_CONTENT_DISABLED, 'moodlecourse'); + + // Confirm user still has permission to export in course1 (still enabled at the course level). + $this->assertTrue(content::can_export_context($course1context, $user)); + + // Disable the course downloads feature. + set_config('downloadcoursecontentallowed', false); + + // Confirm user no longer has permission to export in course1. + $this->assertFalse(content::can_export_context($course1context, $user)); + } + + /** + * A test to confirm unsupported contexts will return false when checking whether content can be exported. + */ + public function test_can_export_context_unsupported_context() { + $this->resetAfterTest(); + + $course1 = $this->getDataGenerator()->create_course(); + $systemcontext = \context_system::instance(); + + // Enrol user as student in course1 only. + $user = $this->getDataGenerator()->create_and_enrol($course1, 'student'); + + // Make course download available on site (course context). + set_config('downloadcoursecontentallowed', true); + + // Confirm system context does not gain permission to export content. + $this->assertFalse(content::can_export_context($systemcontext, $user)); + } +} diff --git a/version.php b/version.php index 40a8acb9c7c..a1508dc11ec 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2021052500.31; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2021052500.32; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes. $release = '4.0dev (Build: 20201023)'; // Human-friendly version name