diff --git a/admin/settings/courses.php b/admin/settings/courses.php index f214ab44d69..99892d68dd9 100644 --- a/admin/settings/courses.php +++ b/admin/settings/courses.php @@ -458,4 +458,28 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) { $ADMIN->add('backups', $temp); + // Create a page for asynchronous backup and restore configuration and defaults. + if (!empty($CFG->enableasyncbackup)) { // Only add settings if async mode is enable at site level. + $temp = new admin_settingpage('asyncgeneralsettings', new lang_string('asyncgeneralsettings', 'backup')); + + $temp->add(new admin_setting_configcheckbox( + 'backup/backup_async_message_users', + new lang_string('asyncemailenable', 'backup'), + new lang_string('asyncemailenabledetail', 'backup'), 0)); + + $temp->add(new admin_setting_configtext( + 'backup/backup_async_message_subject', + new lang_string('asyncmessagesubject', 'backup'), + new lang_string('asyncmessagesubjectdetail', 'backup'), + new lang_string('asyncmessagesubjectdefault', 'backup'))); + + $temp->add(new admin_setting_confightmleditor( + 'backup/backup_async_message', + new lang_string('asyncmessagebody', 'backup'), + new lang_string('asyncmessagebodydetail', 'backup'), + new lang_string('asyncmessagebodydefault', 'backup'))); + + $ADMIN->add('backups', $temp); + } + } diff --git a/admin/settings/subsystems.php b/admin/settings/subsystems.php index e5c660b570a..0b627494d65 100644 --- a/admin/settings/subsystems.php +++ b/admin/settings/subsystems.php @@ -51,4 +51,7 @@ if ($hassiteconfig) { // speedup for non-admins, add all caps used on this page $optionalsubsystems->add(new admin_setting_configcheckbox('enablecoursepublishing', new lang_string('enablecoursepublishing', 'hub'), new lang_string('enablecoursepublishing_help', 'hub'), 0)); + + $optionalsubsystems->add(new admin_setting_configcheckbox('enableasyncbackup', new lang_string('enableasyncbackup', 'backup'), + new lang_string('enableasyncbackup_help', 'backup'), 0, 1, 0)); } diff --git a/backup/backup.class.php b/backup/backup.class.php index 0915c8e8d0a..a287a96b18f 100644 --- a/backup/backup.class.php +++ b/backup/backup.class.php @@ -74,6 +74,12 @@ abstract class backup implements checksumable { const MODE_AUTOMATED = 50; const MODE_CONVERTED = 60; + /** + * This mode is for asynchronous backups. + * These backups will run via adhoc scheduled tasks. + */ + const MODE_ASYNC = 70; + // Target (new/existing/current/adding/deleting) const TARGET_CURRENT_DELETING = 0; const TARGET_CURRENT_ADDING = 1; diff --git a/backup/backup.php b/backup/backup.php index c1b1f648f50..e0606d1b384 100644 --- a/backup/backup.php +++ b/backup/backup.php @@ -30,16 +30,27 @@ require_once('../config.php'); require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php'); +// Backup of large courses requires extra memory. Use the amount configured +// in admin settings. +raise_memory_limit(MEMORY_EXTRA); $courseid = required_param('id', PARAM_INT); $sectionid = optional_param('section', null, PARAM_INT); $cmid = optional_param('cm', null, PARAM_INT); $cancel = optional_param('cancel', '', PARAM_ALPHA); +$previous = optional_param('previous', false, PARAM_BOOL); /** * Part of the forms in stages after initial, is POST never GET */ $backupid = optional_param('backup', false, PARAM_ALPHANUM); +// Determine if we are performing realtime for asynchronous backups. +$backupmode = backup::MODE_GENERAL; +if (async_helper::is_async_enabled()) { + $backupmode = backup::MODE_ASYNC; +} + +$courseurl = new moodle_url('/course/view.php', array('id' => $courseid)); $url = new moodle_url('/backup/backup.php', array('id'=>$courseid)); if ($sectionid !== null) { $url->param('section', $sectionid); @@ -53,6 +64,8 @@ $PAGE->set_pagelayout('admin'); $id = $courseid; $cm = null; $course = $DB->get_record('course', array('id'=>$courseid), '*', MUST_EXIST); +$coursecontext = context_course::instance($course->id); +$contextid = $coursecontext->id; $type = backup::TYPE_1COURSE; if (!is_null($sectionid)) { $section = $DB->get_record('course_sections', array('course'=>$course->id, 'id'=>$sectionid), '*', MUST_EXIST); @@ -68,11 +81,10 @@ require_login($course, false, $cm); switch ($type) { case backup::TYPE_1COURSE : - require_capability('moodle/backup:backupcourse', context_course::instance($course->id)); + require_capability('moodle/backup:backupcourse', $coursecontext); $heading = get_string('backupcourse', 'backup', $course->shortname); break; case backup::TYPE_1SECTION : - $coursecontext = context_course::instance($course->id); require_capability('moodle/backup:backupsection', $coursecontext); if ((string)$section->name !== '') { $sectionname = format_string($section->name, true, array('context' => $coursecontext)); @@ -84,102 +96,145 @@ switch ($type) { } break; case backup::TYPE_1ACTIVITY : - require_capability('moodle/backup:backupactivity', context_module::instance($cm->id)); + $activitycontext = context_module::instance($cm->id); + require_capability('moodle/backup:backupactivity', $activitycontext); + $contextid = $activitycontext->id; $heading = get_string('backupactivity', 'backup', $cm->name); break; default : print_error('unknownbackuptype'); } -// Backup of large courses requires extra memory. Use the amount configured -// in admin settings. -raise_memory_limit(MEMORY_EXTRA); - -if (!($bc = backup_ui::load_controller($backupid))) { - $bc = new backup_controller($type, $id, backup::FORMAT_MOODLE, - backup::INTERACTIVE_YES, backup::MODE_GENERAL, $USER->id); -} -$backup = new backup_ui($bc); - $PAGE->set_title($heading); $PAGE->set_heading($heading); -$renderer = $PAGE->get_renderer('core','backup'); if (empty($cancel)) { // Do not print the header if user cancelled the process, as we are going to redirect the user. echo $OUTPUT->header(); } -// Prepare a progress bar which can display optionally during long-running -// operations while setting up the UI. -$slowprogress = new \core\progress\display_if_slow(get_string('preparingui', 'backup')); +// Only let user perform a backup if we aren't in async mode, or if we are +// and there are no pending backups for this item for this user. +if (!async_helper::is_async_pending($id, 'course', 'backup')) { -$previous = optional_param('previous', false, PARAM_BOOL); -if ($backup->get_stage() == backup_ui::STAGE_SCHEMA && !$previous) { - // After schema stage, we are probably going to get to the confirmation stage, - // The confirmation stage has 2 sets of progress, so this is needed to prevent - // it showing 2 progress bars. - $twobars = true; - $slowprogress->start_progress('', 2); -} else { - $twobars = false; -} -$backup->get_controller()->set_progress($slowprogress); -$backup->process(); + // The mix of business logic and display elements below makes me sad. + // This needs to refactored into the renderer and seperated out. -if ($backup->enforce_changed_dependencies()) { - debugging('Your settings have been altered due to unmet dependencies', DEBUG_DEVELOPER); -} - -$loghtml = ''; -if ($backup->get_stage() == backup_ui::STAGE_FINAL) { - // Display an extra backup step bar so that we can show the 'processing' step first. - echo html_writer::start_div('', array('id' => 'executionprogress')); - echo $renderer->progress_bar($backup->get_progress_bar()); - $backup->get_controller()->set_progress(new \core\progress\display()); - - // Prepare logger and add to end of chain. - $logger = new core_backup_html_logger($CFG->debugdeveloper ? backup::LOG_DEBUG : backup::LOG_INFO); - $backup->get_controller()->add_logger($logger); - - // Carry out actual backup. - $backup->execute(); - - // Backup controller gets saved/loaded so the logger object changes and we - // have to retrieve it. - $logger = $backup->get_controller()->get_logger(); - while (!is_a($logger, 'core_backup_html_logger')) { - $logger = $logger->get_next(); + if (!($bc = backup_ui::load_controller($backupid))) { + $bc = new backup_controller($type, $id, backup::FORMAT_MOODLE, + backup::INTERACTIVE_YES, $backupmode, $USER->id); } - // Get HTML from logger. - if ($CFG->debugdisplay) { - $loghtml = $logger->get_html(); + // Prepare a progress bar which can display optionally during long-running + // operations while setting up the UI. + $slowprogress = new \core\progress\display_if_slow(get_string('preparingui', 'backup')); + $renderer = $PAGE->get_renderer('core', 'backup'); + $backup = new backup_ui($bc); + + if ($backup->get_stage() == backup_ui::STAGE_SCHEMA && !$previous) { + // After schema stage, we are probably going to get to the confirmation stage, + // The confirmation stage has 2 sets of progress, so this is needed to prevent + // it showing 2 progress bars. + $twobars = true; + $slowprogress->start_progress('', 2); + } else { + $twobars = false; + } + $backup->get_controller()->set_progress($slowprogress); + $backup->process(); + + if ($backup->enforce_changed_dependencies()) { + debugging('Your settings have been altered due to unmet dependencies', DEBUG_DEVELOPER); } - // Hide the progress display and first backup step bar (the 'finished' step will show next). - echo html_writer::end_div(); - echo html_writer::script('document.getElementById("executionprogress").style.display = "none";'); -} else { - $backup->save_controller(); -} + $loghtml = ''; + if ($backup->get_stage() == backup_ui::STAGE_FINAL) { -// Displaying UI can require progress reporting, so do it here before outputting -// the backup stage bar (as part of the existing progress bar, if required). -$ui = $backup->display($renderer); -if ($twobars) { - $slowprogress->end_progress(); -} + if ($backupmode != backup::MODE_ASYNC) { + // Synchronous backup handling. -echo $renderer->progress_bar($backup->get_progress_bar()); + // Display an extra backup step bar so that we can show the 'processing' step first. + echo html_writer::start_div('', array('id' => 'executionprogress')); + echo $renderer->progress_bar($backup->get_progress_bar()); + $backup->get_controller()->set_progress(new \core\progress\display()); -echo $ui; -$backup->destroy(); -unset($backup); + // Prepare logger and add to end of chain. + $logger = new core_backup_html_logger($CFG->debugdeveloper ? backup::LOG_DEBUG : backup::LOG_INFO); + $backup->get_controller()->add_logger($logger); -// Display log data if there was any. -if ($loghtml != '') { - echo $renderer->log_display($loghtml); + // Carry out actual backup. + $backup->execute(); + + // Backup controller gets saved/loaded so the logger object changes and we + // have to retrieve it. + $logger = $backup->get_controller()->get_logger(); + while (!is_a($logger, 'core_backup_html_logger')) { + $logger = $logger->get_next(); + } + + // Get HTML from logger. + if ($CFG->debugdisplay) { + $loghtml = $logger->get_html(); + } + + // Hide the progress display and first backup step bar (the 'finished' step will show next). + echo html_writer::end_div(); + echo html_writer::script('document.getElementById("executionprogress").style.display = "none";'); + } else { + // Async backup handling. + $backup->get_controller()->finish_ui(); + + echo html_writer::start_div('', array('id' => 'executionprogress')); + echo $renderer->progress_bar($backup->get_progress_bar()); + echo html_writer::end_div(); + + // Create adhoc task for backup. + $asynctask = new \core\task\asynchronous_backup_task(); + $asynctask->set_blocking(false); + $asynctask->set_custom_data(array('backupid' => $backupid)); + \core\task\manager::queue_adhoc_task($asynctask); + + // Add ajax progress bar and initiate ajax via a template. + $restoreurl = new moodle_url('/backup/restorefile.php', array('contextid' => $contextid)); + $progresssetup = array( + 'backupid' => $backupid, + 'contextid' => $contextid, + 'courseurl' => $courseurl->out(), + 'restoreurl' => $restoreurl->out(), + 'headingident' => 'backup' + ); + echo $renderer->render_from_template('core/async_backup_status', $progresssetup); + } + + } else { + $backup->save_controller(); + } + + if ($backup->get_stage() != backup_ui::STAGE_FINAL) { + + // Displaying UI can require progress reporting, so do it here before outputting + // the backup stage bar (as part of the existing progress bar, if required). + $ui = $backup->display($renderer); + if ($twobars) { + $slowprogress->end_progress(); + } + + echo $renderer->progress_bar($backup->get_progress_bar()); + echo $ui; + + // Display log data if there was any. + if ($loghtml != '' && $backupmode != backup::MODE_ASYNC) { + echo $renderer->log_display($loghtml); + } + } + + $backup->destroy(); + unset($backup); + +} else { // User has a pending async operation. + echo $OUTPUT->notification(get_string('pendingasyncerror', 'backup'), 'error'); + echo $OUTPUT->container(get_string('pendingasyncdetail', 'backup')); + echo $OUTPUT->continue_button($courseurl); } echo $OUTPUT->footer(); diff --git a/backup/controller/backup_controller.class.php b/backup/controller/backup_controller.class.php index 35edd9c318a..8ea8d517f91 100644 --- a/backup/controller/backup_controller.class.php +++ b/backup/controller/backup_controller.class.php @@ -58,7 +58,11 @@ class backup_controller extends base_controller { protected $plan; // Backup execution plan protected $includefiles; // Whether this backup includes files or not. - protected $execution; // inmediate/delayed + /** + * Immediate/delayed execution type. + * @var integer + */ + protected $execution; protected $executiontime; // epoch time when we want the backup to be executed (requires cron to run) protected $destination; // Destination chain object (fs_moodle, fs_os, db, email...) @@ -85,11 +89,17 @@ class backup_controller extends base_controller { $this->userid = $userid; // Apply some defaults - $this->execution = backup::EXECUTION_INMEDIATE; $this->operation = backup::OPERATION_BACKUP; $this->executiontime = 0; $this->checksum = ''; + // Set execution based on backup mode. + if ($mode == backup::MODE_ASYNC) { + $this->execution = backup::EXECUTION_DELAYED; + } else { + $this->execution = backup::EXECUTION_INMEDIATE; + } + // Apply current backup version and release if necessary backup_controller_dbops::apply_version_and_release(); @@ -112,7 +122,7 @@ class backup_controller extends base_controller { // display progress must set it. $this->progress = new \core\progress\none(); - // Instantiate the output_controller singleton and active it if interactive and inmediate + // Instantiate the output_controller singleton and active it if interactive and immediate. $oc = output_controller::get_instance(); if ($this->interactive == backup::INTERACTIVE_YES && $this->execution == backup::EXECUTION_INMEDIATE) { $oc->set_active(true); @@ -182,7 +192,8 @@ class backup_controller extends base_controller { // TODO: Check it's a correct status. $this->status = $status; // Ensure that, once set to backup::STATUS_AWAITING, controller is stored in DB. - if ($status == backup::STATUS_AWAITING) { + // Also save if executing so we can better track progress. + if ($status == backup::STATUS_AWAITING || $status == backup::STATUS_EXECUTING) { $this->save_controller(); $tbc = self::load_controller($this->backupid); $this->logger = $tbc->logger; // wakeup loggers @@ -192,14 +203,18 @@ class backup_controller extends base_controller { // If the operation has ended without error (backup::STATUS_FINISHED_OK) // proceed by cleaning the object from database. MDL-29262. $this->save_controller(false, true); + } else if ($status == backup::STATUS_FINISHED_ERR) { + // If the operation has ended with an error save the controller + // preserving the object in the database. We may want it for debugging. + $this->save_controller(); } } public function set_execution($execution, $executiontime = 0) { $this->log('setting controller execution', backup::LOG_DEBUG); - // TODO: Check valid execution mode - // TODO: Check time in future - // TODO: Check time = 0 if inmediate + // TODO: Check valid execution mode. + // TODO: Check time in future. + // TODO: Check time = 0 if immediate. $this->execution = $execution; $this->executiontime = $executiontime; @@ -333,8 +348,8 @@ class backup_controller extends base_controller { * @param bool $cleanobj to decide if the object itself must be cleaned (true) or no (false) */ public function save_controller($includeobj = true, $cleanobj = false) { - // Going to save controller to persistent storage, calculate checksum for later checks and save it - // TODO: flag the controller as NA. Any operation on it should be forbidden util loaded back + // Going to save controller to persistent storage, calculate checksum for later checks and save it. + // TODO: flag the controller as NA. Any operation on it should be forbidden until loaded back. $this->log('saving controller to db', backup::LOG_DEBUG); if ($includeobj ) { // Only calculate checksum if we are going to include the object. $this->checksum = $this->calculate_checksum(); @@ -399,6 +414,7 @@ class backup_controller extends base_controller { $this->log("setting file inclusion to {$this->includefiles}", backup::LOG_DEBUG); return $this->includefiles; } + } /* diff --git a/backup/controller/restore_controller.class.php b/backup/controller/restore_controller.class.php index 2e994f44def..cc5b86993d2 100644 --- a/backup/controller/restore_controller.class.php +++ b/backup/controller/restore_controller.class.php @@ -53,7 +53,11 @@ class restore_controller extends base_controller { /** @var restore_plan */ protected $plan; // Restore execution plan - protected $execution; // inmediate/delayed + /** + * Immediate/delayed execution type. + * @var integer + */ + protected $execution; protected $executiontime; // epoch time when we want the restore to be executed (requires cron to run) protected $checksum; // Cache @checksumable results for lighter @is_checksum_correct() uses @@ -88,7 +92,6 @@ class restore_controller extends base_controller { // Apply some defaults $this->type = ''; $this->format = backup::FORMAT_UNKNOWN; - $this->execution = backup::EXECUTION_INMEDIATE; $this->operation = backup::OPERATION_RESTORE; $this->executiontime = 0; $this->samesite = false; @@ -110,6 +113,13 @@ class restore_controller extends base_controller { // Default logger chain (based on interactive/execution) $this->logger = backup_factory::get_logger_chain($this->interactive, $this->execution, $this->restoreid); + // Set execution based on backup mode. + if ($mode == backup::MODE_ASYNC) { + $this->execution = backup::EXECUTION_DELAYED; + } else { + $this->execution = backup::EXECUTION_INMEDIATE; + } + // By default there is no progress reporter unless you specify one so it // can be used during loading of the plan. if ($progress) { @@ -119,7 +129,7 @@ class restore_controller extends base_controller { } $this->progress->start_progress('Constructing restore_controller'); - // Instantiate the output_controller singleton and active it if interactive and inmediate + // Instantiate the output_controller singleton and active it if interactive and immediate. $oc = output_controller::get_instance(); if ($this->interactive == backup::INTERACTIVE_YES && $this->execution == backup::EXECUTION_INMEDIATE) { $oc->set_active(true); @@ -198,7 +208,8 @@ class restore_controller extends base_controller { // TODO: Check it's a correct status. $this->status = $status; // Ensure that, once set to backup::STATUS_AWAITING | STATUS_NEED_PRECHECK, controller is stored in DB. - if ($status == backup::STATUS_AWAITING || $status == backup::STATUS_NEED_PRECHECK) { + // Also save if executing so we can better track progress. + if ($status == backup::STATUS_AWAITING || $status == backup::STATUS_NEED_PRECHECK || $status == backup::STATUS_EXECUTING) { $this->save_controller(); $tbc = self::load_controller($this->restoreid); $this->logger = $tbc->logger; // wakeup loggers @@ -208,14 +219,18 @@ class restore_controller extends base_controller { // If the operation has ended without error (backup::STATUS_FINISHED_OK) // proceed by cleaning the object from database. MDL-29262. $this->save_controller(false, true); + } else if ($status == backup::STATUS_FINISHED_ERR) { + // If the operation has ended with an error save the controller + // preserving the object in the database. We may want it for debugging. + $this->save_controller(); } } public function set_execution($execution, $executiontime = 0) { $this->log('setting controller execution', backup::LOG_DEBUG); - // TODO: Check valid execution mode - // TODO: Check time in future - // TODO: Check time = 0 if inmediate + // TODO: Check valid execution mode. + // TODO: Check time in future. + // TODO: Check time = 0 if immediate. $this->execution = $execution; $this->executiontime = $executiontime; diff --git a/backup/externallib.php b/backup/externallib.php new file mode 100644 index 00000000000..98119ecaa4e --- /dev/null +++ b/backup/externallib.php @@ -0,0 +1,240 @@ +. + + +/** + * External backup API. + * + * @package core_backup + * @category external + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die; + +require_once("$CFG->libdir/externallib.php"); +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + +/** + * Backup external functions. + * + * @package core_backup + * @category external + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since Moodle 3.7 + */ +class core_backup_external extends external_api { + + /** + * Returns description of method parameters + * + * @return external_function_parameters + * @since Moodle 3.7 + */ + public static function get_async_backup_progress_parameters() { + return new external_function_parameters( + array( + 'backupids' => new external_multiple_structure( + new external_value(PARAM_ALPHANUM, 'Backup id to get progress for', VALUE_REQUIRED, null, NULL_ALLOWED), + 'Backup id to get progress for', VALUE_REQUIRED + ), + 'contextid' => new external_value(PARAM_INT, 'Context id', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + ) + ); + } + + /** + * Get asynchronous backup progress. + * + * @param string $backupids The ids of the backup to get progress for. + * @param int $contextid The context the backup relates to. + * @return array $results The array of results. + * @since Moodle 3.7 + */ + public static function get_async_backup_progress($backupids, $contextid) { + global $CFG; + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); + require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + + // Parameter validation. + self::validate_parameters( + self::get_async_backup_progress_parameters(), + array( + 'backupids' => $backupids, + 'contextid' => $contextid + ) + ); + + // Context validation. + list($context, $course, $cm) = get_context_info_array($contextid); + self::validate_context($context); + + if ($cm) { + require_capability('moodle/backup:backupactivity', $context); + } else { + require_capability('moodle/backup:backupcourse', $context); + $instanceid = $course->id; + } + + $results = array(); + foreach ($backupids as $backupid) { + $results[] = backup_controller_dbops::get_progress($backupid); + } + + return $results; + } + + /** + * Returns description of method result value + * + * @return external_description + * @since Moodle 3.7 + */ + public static function get_async_backup_progress_returns() { + return new external_multiple_structure( + new external_single_structure( + array( + 'status' => new external_value(PARAM_INT, 'Backup Status'), + 'progress' => new external_value(PARAM_FLOAT, 'Backup progress'), + 'backupid' => new external_value(PARAM_ALPHANUM, 'Backup id'), + 'operation' => new external_value(PARAM_ALPHANUM, 'operation type'), + ), 'Backup completion status' + ), 'Backup data' + ); + } + + /** + * Returns description of method parameters + * + * @return external_function_parameters + * @since Moodle 3.7 + */ + public static function get_async_backup_links_backup_parameters() { + return new external_function_parameters( + array( + 'filename' => new external_value(PARAM_FILE, 'Backup filename', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + 'contextid' => new external_value(PARAM_INT, 'Context id', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + ) + ); + } + + /** + * Get the data to be used when generating the table row for an asynchronous backup, + * the table row updates via ajax when backup is complete. + * + * @param string $filename The file name of the backup file. + * @param int $contextid The context the backup relates to. + * @since Moodle 3.7 + */ + public static function get_async_backup_links_backup($filename, $contextid) { + // Parameter validation. + self::validate_parameters( + self::get_async_backup_links_backup_parameters(), + array( + 'filename' => $filename, + 'contextid' => $contextid + ) + ); + + // Context validation. + list($context, $course, $cm) = get_context_info_array($contextid); + self::validate_context($context); + require_capability('moodle/backup:backupcourse', $context); + + if ($cm) { + $filearea = 'activity'; + } else { + $filearea = 'course'; + } + + $results = \async_helper::get_backup_file_info($filename, $filearea, $contextid); + + return $results; + } + + /** + * Returns description of method result value. + * + * @return external_description + * @since Moodle 3.7 + */ + public static function get_async_backup_links_backup_returns() { + return new external_single_structure( + array( + 'filesize' => new external_value(PARAM_TEXT, 'Backup file size'), + 'fileurl' => new external_value(PARAM_URL, 'Backup file URL'), + 'restoreurl' => new external_value(PARAM_URL, 'Backup restore URL'), + ), 'Table row data.'); + } + /** + * Returns description of method parameters + * + * @return external_function_parameters + * @since Moodle 3.7 + */ + public static function get_async_backup_links_restore_parameters() { + return new external_function_parameters( + array( + 'backupid' => new external_value(PARAM_ALPHANUMEXT, 'Backup id', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + 'contextid' => new external_value(PARAM_INT, 'Context id', VALUE_REQUIRED, null, NULL_NOT_ALLOWED), + ) + ); + } + + /** + * Get the data to be used when generating the table row for an asynchronous restore, + * the table row updates via ajax when restore is complete. + * + * @param string $backupid The id of the backup record. + * @param int $contextid The context the restore relates to. + * @return array $results The array of results. + * @since Moodle 3.7 + */ + public static function get_async_backup_links_restore($backupid, $contextid) { + // Parameter validation. + self::validate_parameters( + self::get_async_backup_links_restore_parameters(), + array( + 'backupid' => $backupid, + 'contextid' => $contextid + ) + ); + + // Context validation. + $context = context::instance_by_id($contextid); + self::validate_context($context); + require_capability('moodle/restore:restorecourse', $context); + + $results = \async_helper::get_restore_url($backupid); + + return $results; + } + + /** + * Returns description of method result value. + * + * @return external_description + * @since Moodle 3.7 + */ + public static function get_async_backup_links_restore_returns() { + return new external_single_structure( + array( + 'restoreurl' => new external_value(PARAM_URL, 'Restore url'), + ), 'Table row data.'); + } +} diff --git a/backup/restore.php b/backup/restore.php index 06f58e65a34..4ee134e0093 100644 --- a/backup/restore.php +++ b/backup/restore.php @@ -28,10 +28,20 @@ define('NO_OUTPUT_BUFFERING', true); require_once('../config.php'); require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +// Restore of large courses requires extra memory. Use the amount configured +// in admin settings. +raise_memory_limit(MEMORY_EXTRA); + $contextid = required_param('contextid', PARAM_INT); $stage = optional_param('stage', restore_ui::STAGE_CONFIRM, PARAM_INT); $cancel = optional_param('cancel', '', PARAM_ALPHA); +// Determine if we are performing realtime for asynchronous backups. +$backupmode = backup::MODE_GENERAL; +if (async_helper::is_async_enabled()) { + $backupmode = backup::MODE_ASYNC; +} + list($context, $course, $cm) = get_context_info_array($contextid); navigation_node::override_active_url(new moodle_url('/backup/restorefile.php', array('contextid'=>$contextid))); @@ -70,10 +80,6 @@ $slowprogress->start_progress('', 10); // This progress section counts for loading the restore controller. $slowprogress->start_progress('', 1, 1); -// Restore of large courses requires extra memory. Use the amount configured -// in admin settings. -raise_memory_limit(MEMORY_EXTRA); - if ($stage & restore_ui::STAGE_CONFIRM + restore_ui::STAGE_DESTINATION) { $restore = restore_ui::engage_independent_stage($stage, $contextid); } else { @@ -83,7 +89,7 @@ if ($stage & restore_ui::STAGE_CONFIRM + restore_ui::STAGE_DESTINATION) { $restore = restore_ui::engage_independent_stage($stage/2, $contextid); if ($restore->process()) { $rc = new restore_controller($restore->get_filepath(), $restore->get_course_id(), backup::INTERACTIVE_YES, - backup::MODE_GENERAL, $USER->id, $restore->get_target()); + $backupmode, $USER->id, $restore->get_target()); } } if ($rc) { @@ -120,7 +126,7 @@ if (!$restore->is_independent()) { // Use a temporary (disappearing) progress bar to show the precheck progress if any. $precheckprogress = new \core\progress\display_if_slow(get_string('preparingdata', 'backup')); $restore->get_controller()->set_progress($precheckprogress); - if ($restore->get_stage() == restore_ui::STAGE_PROCESS && !$restore->requires_substage()) { + if ($restore->get_stage() == restore_ui::STAGE_PROCESS && !$restore->requires_substage() && $backupmode != backup::MODE_ASYNC) { try { // Div used to hide the 'progress' step once the page gets onto 'finished'. echo html_writer::start_div('', array('id' => 'executionprogress')); @@ -150,7 +156,35 @@ if (!$restore->is_independent()) { } echo $renderer->progress_bar($restore->get_progress_bar()); -echo $restore->display($renderer); + +if ($restore->get_stage() != restore_ui::STAGE_PROCESS) { + echo $restore->display($renderer); +} else if ($restore->get_stage() == restore_ui::STAGE_PROCESS && $restore->requires_substage()) { + echo $restore->display($renderer); +} else if ($restore->get_stage() == restore_ui::STAGE_PROCESS + && !$restore->requires_substage() + && $backupmode == backup::MODE_ASYNC) { + // Asynchronous restore. + // Create adhoc task for restore. + $restoreid = $restore->get_restoreid(); + $asynctask = new \core\task\asynchronous_restore_task(); + $asynctask->set_blocking(false); + $asynctask->set_custom_data(array('backupid' => $restoreid)); + \core\task\manager::queue_adhoc_task($asynctask); + + // Add ajax progress bar and initiate ajax via a template. + $courseurl = new moodle_url('/course/view.php', array('id' => $course->id)); + $restoreurl = new moodle_url('/backup/restorefile.php', array('contextid' => $contextid)); + $progresssetup = array( + 'backupid' => $restoreid, + 'contextid' => $contextid, + 'courseurl' => $courseurl->out(), + 'restoreurl' => $restoreurl->out() + ); + echo $renderer->render_from_template('core/async_backup_status', $progresssetup); + +} + $restore->destroy(); unset($restore); diff --git a/backup/restorefile.php b/backup/restorefile.php index 2a745a829e1..eef560bf06c 100644 --- a/backup/restorefile.php +++ b/backup/restorefile.php @@ -114,6 +114,7 @@ $PAGE->set_context($context); $PAGE->set_title(get_string('course') . ': ' . $coursefullname); $PAGE->set_heading($heading); $PAGE->set_pagelayout('admin'); +$PAGE->requires->js_call_amd('core_backup/async_backup', 'asyncBackupAllStatus', array($context->id)); $form = new course_restore_form(null, array('contextid'=>$contextid)); $data = $form->get_data(); @@ -128,8 +129,6 @@ if ($data && has_capability('moodle/restore:uploadfile', $context)) { die; } - - echo $OUTPUT->header(); // require uploadfile cap to use file picker @@ -196,4 +195,13 @@ if (!empty($automatedbackups)) { echo $OUTPUT->container_end(); } +// In progress course restores. +if (async_helper::is_async_enabled()) { + echo $OUTPUT->heading_with_help(get_string('asyncrestoreinprogress', 'backup'), 'asyncrestoreinprogress', 'backup'); + echo $OUTPUT->container_start(); + $renderer = $PAGE->get_renderer('core', 'backup'); + echo $renderer->restore_progress_viewer($USER->id, $context); + echo $OUTPUT->container_end(); +} + echo $OUTPUT->footer(); diff --git a/backup/tests/async_backup_test.php b/backup/tests/async_backup_test.php new file mode 100644 index 00000000000..f982a6d497e --- /dev/null +++ b/backup/tests/async_backup_test.php @@ -0,0 +1,118 @@ +. + +/** + * Asyncronhous backup tests. + * + * @package core_backup + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->libdir . '/completionlib.php'); + +/** + * Asyncronhous backup tests. + * + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_backup_async_backup_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Tests the asynchronous backup. + */ + public function test_async_backup() { + global $DB, $CFG, $USER; + + $this->resetAfterTest(true); + $this->setAdminUser(); + $CFG->enableavailability = true; + $CFG->enablecompletion = true; + + // Create a course with some availability data set. + $generator = $this->getDataGenerator(); + $course = $generator->create_course( + array('format' => 'topics', 'numsections' => 3, + 'enablecompletion' => COMPLETION_ENABLED), + array('createsections' => true)); + $forum = $generator->create_module('forum', array( + 'course' => $course->id)); + $forum2 = $generator->create_module('forum', array( + 'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + + // We need a grade, easiest is to add an assignment. + $assignrow = $generator->create_module('assign', array( + 'course' => $course->id)); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + $item = $assign->get_grade_item(); + + // Make a test grouping as well. + $grouping = $generator->create_grouping(array('courseid' => $course->id, + 'name' => 'Grouping!')); + + $availability = '{"op":"|","show":false,"c":[' . + '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' . + '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' . + '{"type":"grouping","id":' . $grouping->id . '}' . + ']}'; + $DB->set_field('course_modules', 'availability', $availability, array( + 'id' => $forum->cmid)); + $DB->set_field('course_sections', 'availability', $availability, array( + 'course' => $course->id, 'section' => 1)); + + // Start backup process. + + // Make the backup controller for an async backup. + $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, + backup::INTERACTIVE_YES, backup::MODE_ASYNC, $USER->id); + $bc->finish_ui(); + $backupid = $bc->get_backupid(); + + $prebackuprec = $DB->get_record('backup_controllers', array('backupid' => $backupid)); + + // Check the initial backup controller was created correctly. + $this->assertEquals(backup::STATUS_AWAITING, $prebackuprec->status); + $this->assertEquals(2, $prebackuprec->execution); + + // Create the adhoc task. + $asynctask = new \core\task\asynchronous_backup_task(); + $asynctask->set_blocking(false); + $asynctask->set_custom_data(array('backupid' => $backupid)); + \core\task\manager::queue_adhoc_task($asynctask); + + // We are expecting trace output during this test. + $this->expectOutputRegex("/$backupid/"); + + // Execute adhoc task. + $now = time(); + $task = \core\task\manager::get_next_adhoc_task($now); + $this->assertInstanceOf('\\core\\task\\asynchronous_backup_task', $task); + $task->execute(); + \core\task\manager::adhoc_task_complete($task); + + $postbackuprec = $DB->get_record('backup_controllers', array('backupid' => $backupid)); + + // Check backup was created successfully. + $this->assertEquals(backup::STATUS_FINISHED_OK, $postbackuprec->status); + $this->assertEquals(1.0, $postbackuprec->progress); + } +} diff --git a/backup/tests/async_restore_test.php b/backup/tests/async_restore_test.php new file mode 100644 index 00000000000..1121e371ad5 --- /dev/null +++ b/backup/tests/async_restore_test.php @@ -0,0 +1,140 @@ +. + +/** + * Asyncronhous restore tests. + * + * @package core_backup + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->libdir . '/completionlib.php'); + +/** + * Asyncronhous restore tests. + * + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_backup_async_restore_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Tests the asynchronous backup. + */ + public function test_async_restore() { + global $DB, $CFG, $USER; + + $this->resetAfterTest(true); + $this->setAdminUser(); + $CFG->enableavailability = true; + $CFG->enablecompletion = true; + + // Create a course with some availability data set. + $generator = $this->getDataGenerator(); + $course = $generator->create_course( + array('format' => 'topics', 'numsections' => 3, + 'enablecompletion' => COMPLETION_ENABLED), + array('createsections' => true)); + $forum = $generator->create_module('forum', array( + 'course' => $course->id)); + $forum2 = $generator->create_module('forum', array( + 'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + + // We need a grade, easiest is to add an assignment. + $assignrow = $generator->create_module('assign', array( + 'course' => $course->id)); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + $item = $assign->get_grade_item(); + + // Make a test grouping as well. + $grouping = $generator->create_grouping(array('courseid' => $course->id, + 'name' => 'Grouping!')); + + $availability = '{"op":"|","show":false,"c":[' . + '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' . + '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' . + '{"type":"grouping","id":' . $grouping->id . '}' . + ']}'; + $DB->set_field('course_modules', 'availability', $availability, array( + 'id' => $forum->cmid)); + $DB->set_field('course_sections', 'availability', $availability, array( + 'course' => $course->id, 'section' => 1)); + + // Backup the course. + $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, + backup::INTERACTIVE_YES, backup::MODE_GENERAL, $USER->id); + $bc->finish_ui(); + $backupid = $bc->get_backupid(); + $bc->execute_plan(); + $bc->destroy(); + + // Get the backup file. + $coursecontext = context_course::instance($course->id); + $fs = get_file_storage(); + $files = $fs->get_area_files($coursecontext->id, 'backup', 'course', false, 'id ASC'); + $backupfile = reset($files); + + // Extract backup file. + $backupdir = "restore_" . uniqid(); + $path = $CFG->tempdir . DIRECTORY_SEPARATOR . "backup" . DIRECTORY_SEPARATOR . $backupdir; + + $fp = get_file_packer('application/vnd.moodle.backup'); + $fp->extract_to_pathname($backupfile, $path); + + // Create restore controller. + $newcourseid = restore_dbops::create_new_course( + $course->fullname, $course->shortname . '_2', $course->category); + $rc = new restore_controller($backupdir, $newcourseid, + backup::INTERACTIVE_NO, backup::MODE_ASYNC, $USER->id, + backup::TARGET_NEW_COURSE); + + $this->assertTrue($rc->execute_precheck()); + $restoreid = $rc->get_restoreid(); + + $prerestorerec = $DB->get_record('backup_controllers', array('backupid' => $restoreid)); + $prerestorerec->controller = ''; + + $rc->destroy(); + + // Create the adhoc task. + $asynctask = new \core\task\asynchronous_restore_task(); + $asynctask->set_blocking(false); + $asynctask->set_custom_data(array('backupid' => $restoreid)); + \core\task\manager::queue_adhoc_task($asynctask); + + // We are expecting trace output during this test. + $this->expectOutputRegex("/$restoreid/"); + + // Execute adhoc task. + $now = time(); + $task = \core\task\manager::get_next_adhoc_task($now); + $this->assertInstanceOf('\\core\\task\\asynchronous_restore_task', $task); + $task->execute(); + \core\task\manager::adhoc_task_complete($task); + + $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $restoreid)); + + // Check backup was created successfully. + $this->assertEquals(backup::STATUS_FINISHED_OK, $postrestorerec->status); + $this->assertEquals(1.0, $postrestorerec->progress); + } +} diff --git a/backup/util/dbops/backup_controller_dbops.class.php b/backup/util/dbops/backup_controller_dbops.class.php index d203db45d3b..3f94cd0eb15 100644 --- a/backup/util/dbops/backup_controller_dbops.class.php +++ b/backup/util/dbops/backup_controller_dbops.class.php @@ -641,4 +641,31 @@ abstract class backup_controller_dbops extends backup_dbops { } } } + + /** + * Get the progress details of a backup operation. + * Get backup records directly from database, if the backup has successfully completed + * there will be no controller object to load. + * + * @param string $backupid The backup id to query. + * @return array $progress The backup progress details. + */ + public static function get_progress($backupid) { + global $DB; + + $progress = array(); + $backuprecord = $DB->get_record( + 'backup_controllers', + array('backupid' => $backupid), + 'status, progress, operation', + MUST_EXIST); + + $status = $backuprecord->status; + $progress = $backuprecord->progress; + $operation = $backuprecord->operation; + + $progress = array('status' => $status, 'progress' => $progress, 'backupid' => $backupid, 'operation' => $operation); + + return $progress; + } } diff --git a/backup/util/helper/async_helper.class.php b/backup/util/helper/async_helper.class.php new file mode 100644 index 00000000000..3cf391fee2f --- /dev/null +++ b/backup/util/helper/async_helper.class.php @@ -0,0 +1,367 @@ +. + +/** + * Helper functions for asynchronous backups and restores. + * + * @package core + * @copyright 2019 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/user/lib.php'); + +/** + * Helper functions for asynchronous backups and restores. + * + * @package core + * @copyright 2019 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class async_helper { + + /** + * @var string $type The type of async operation. + */ + protected $type = 'backup'; + + /** + * @var string $backupid The id of the backup or restore. + */ + protected $backupid; + + /** + * @var object $user The user who created the backup record. + */ + protected $user; + + /** + * @var object $backuprec The backup controller record from the database. + */ + protected $backuprec; + + /** + * Class constructor. + * + * @param string $type The type of async operation. + * @param string $id The id of the backup or restore. + */ + public function __construct($type, $id) { + $this->type = $type; + $this->backupid = $id; + $this->backuprec = $this->get_backup_record($id); + $this->user = $this->get_user(); + } + + /** + * Given a backup id return a the record from the database. + * We use this method rather than 'load_controller' as the controller may + * not exist if this backup/restore has completed. + * + * @param int $id The backup id to get. + * @return object $backuprec The backup controller record. + */ + private function get_backup_record($id) { + global $DB; + + $backuprec = $DB->get_record('backup_controllers', array('backupid' => $id), '*', MUST_EXIST); + + return $backuprec; + } + + /** + * Given a user id return a user object. + * + * @return object $user The limited user record. + */ + private function get_user() { + $userid = $this->backuprec->userid; + $user = core_user::get_user($userid, '*', MUST_EXIST); + + return $user; + } + + /** + * Callback for preg_replace_callback. + * Replaces message placeholders with real values. + * + * @param array $matches The match array from from preg_replace_callback. + * @return string $match The replaced string. + */ + private function lookup_message_variables($matches) { + $options = array( + 'operation' => $this->type, + 'backupid' => $this->backupid, + 'user_username' => $this->user->username, + 'user_email' => $this->user->email, + 'user_firstname' => $this->user->firstname, + 'user_lastname' => $this->user->lastname, + 'link' => $this->get_resource_link(), + ); + + $match = $options[$matches[1]] ?? $matches[1]; + + return $match; + } + + /** + * Get the link to the resource that is being backuped or restored. + * + * @return moodle_url $url The link to the resource. + */ + private function get_resource_link() { + // Get activity context only for backups. + if ($this->backuprec->type == 'activity' && $this->type == 'backup') { + $context = context_module::instance($this->backuprec->itemid); + } else { // Course or Section which have the same context getter. + $context = context_course::instance($this->backuprec->itemid); + } + + // Generate link based on operation type. + if ($this->type == 'backup') { + // For backups simply generate link to restore file area UI. + $url = new moodle_url('/backup/restorefile.php', array('contextid' => $context->id)); + } else { + // For restore generate link to the item itself. + $url = $context->get_url(); + } + + return $url; + } + + /** + * Sends a confirmation message for an aynchronous process. + * + * @return int $messageid The id of the sent message. + */ + public function send_message() { + global $USER; + + $subjectraw = get_config('backup', 'backup_async_message_subject'); + $subjecttext = preg_replace_callback( + '/\{([-_A-Za-z0-9]+)\}/u', + array('async_helper', 'lookup_message_variables'), + $subjectraw); + + $messageraw = get_config('backup', 'backup_async_message'); + $messagehtml = preg_replace_callback( + '/\{([-_A-Za-z0-9]+)\}/u', + array('async_helper', 'lookup_message_variables'), + $messageraw); + $messagetext = html_to_text($messagehtml); + + $message = new \core\message\message(); + $message->component = 'moodle'; + $message->name = 'asyncbackupnotification'; + $message->userfrom = $USER; + $message->userto = $this->user; + $message->subject = $subjecttext; + $message->fullmessage = $messagetext; + $message->fullmessageformat = FORMAT_HTML; + $message->fullmessagehtml = $messagehtml; + $message->smallmessage = ''; + $message->notification = '1'; + + $messageid = message_send($message); + + return $messageid; + } + + /** + * Check if asynchronous backup and restore mode is + * enabled at system level. + * + * @return bool $async True if async mode enabled false otherwise. + */ + static public function is_async_enabled() { + global $CFG; + + $async = false; + if (!empty($CFG->enableasyncbackup)) { + $async = true; + } + + return $async; + } + + /** + * Check if there is a pending async operation for given details. + * + * @param int $id The item id to check in the backup record. + * @param string $type The type of operation: course, activity or section. + * @param string $operation Operation backup or restore. + * @return boolean $asyncpedning Is there a pending async operation. + */ + public static function is_async_pending($id, $type, $operation) { + global $DB, $USER; + $asyncpending = false; + + // Only check for pending async operations if async mode is enabled. + if (self::is_async_enabled()) { + $select = 'userid = ? AND itemid = ? AND type = ? AND operation = ? AND execution = ? AND status < ? AND status > ?'; + $params = array( + $USER->id, + $id, + $type, + $operation, + backup::EXECUTION_DELAYED, + backup::STATUS_FINISHED_ERR, + backup::STATUS_NEED_PRECHECK + ); + $asyncpending = $DB->record_exists_select('backup_controllers', $select, $params); + } + return $asyncpending; + } + + /** + * Get the size, url and restore url for a backup file. + * + * @param string $filename The name of the file to get info for. + * @param string $filearea The file area for the file. + * @param int $contextid The context ID of the file. + * @return array $results The result array containing the size, url and restore url of the file. + */ + public static function get_backup_file_info($filename, $filearea, $contextid) { + $fs = get_file_storage(); + $file = $fs->get_file($contextid, 'backup', $filearea, 0, '/', $filename); + $filesize = display_size ($file->get_filesize()); + $fileurl = moodle_url::make_pluginfile_url( + $file->get_contextid(), + $file->get_component(), + $file->get_filearea(), + null, + $file->get_filepath(), + $file->get_filename(), + true + ); + + $params = array(); + $params['action'] = 'choosebackupfile'; + $params['filename'] = $file->get_filename(); + $params['filepath'] = $file->get_filepath(); + $params['component'] = $file->get_component(); + $params['filearea'] = $file->get_filearea(); + $params['filecontextid'] = $file->get_contextid(); + $params['contextid'] = $contextid; + $params['itemid'] = $file->get_itemid(); + $restoreurl = new moodle_url('/backup/restorefile.php', $params); + $filesize = display_size ($file->get_filesize()); + + $results = array( + 'filesize' => $filesize, + 'fileurl' => $fileurl->out(false), + 'restoreurl' => $restoreurl->out(false)); + + return $results; + } + + /** + * Get the url of a restored backup item based on the backup ID. + * + * @param string $backupid The backup ID to get the restore location url. + * @return array $urlarray The restored item URL as an array. + */ + public static function get_restore_url($backupid) { + global $DB; + + $backupitemid = $DB->get_field('backup_controllers', 'itemid', array('backupid' => $backupid), MUST_EXIST); + $newcontext = context_course::instance($backupitemid); + + $restoreurl = $newcontext->get_url()->out(); + $urlarray = array('restoreurl' => $restoreurl); + + return $urlarray; + } + + /** + * Get markup for in progress async backups, + * to use in backup table UI. + * + * @param \core_backup_renderer $renderer The backup renderer object. + * @param integer $instanceid The context id to get backup data for. + * @return array $tabledata the rows of table data. + */ + public static function get_async_backups($renderer, $instanceid) { + global $DB; + + $tabledata = array(); + + // Get relevant backup ids based on context instance id. + $select = 'itemid = ? AND execution = ? AND status < ? AND status > ?'; + $params = array($instanceid, backup::EXECUTION_DELAYED, backup::STATUS_FINISHED_ERR, backup::STATUS_NEED_PRECHECK); + $backups = $DB->get_records_select('backup_controllers', $select, $params, 'timecreated DESC', 'id, backupid, timecreated'); + + foreach ($backups as $backup) { + $bc = \backup_controller::load_controller($backup->backupid); // Get the backup controller. + $filename = $bc->get_plan()->get_setting('filename')->get_value(); + $timecreated = $backup->timecreated; + $status = $renderer->get_status_display($bc->get_status(), $bc->get_backupid()); + + $tablerow = array($filename, userdate($timecreated), '-', '-', '-', $status); + $tabledata[] = $tablerow; + } + + return $tabledata; + } + + /** + * Get the course name of the resource being restored. + * + * @param \context $context The Moodle context for the restores. + * @return string $coursename The full name of the course. + */ + public static function get_restore_name(\context $context) { + global $DB; + $instanceid = $context->instanceid; + + if ($context->contextlevel == CONTEXT_MODULE) { + // For modules get the course name and module name. + $cm = get_coursemodule_from_id('', $context->instanceid, 0, false, MUST_EXIST); + $coursename = $DB->get_field('course', 'fullname', array('id' => $cm->course)); + $itemname = $coursename . ' - ' . $cm->name; + } else { + $itemname = $DB->get_field('course', 'fullname', array('id' => $context->instanceid)); + + } + + return $itemname; + } + + /** + * Get all the current in progress async restores for a user. + * + * @param int $userid Moodle user id. + * @return array $restores List of current restores in progress. + */ + public static function get_async_restores($userid) { + global $DB; + + $select = 'userid = ? AND execution = ? AND status < ? AND status > ? AND operation = ?'; + $params = array($userid, backup::EXECUTION_DELAYED, backup::STATUS_FINISHED_ERR, backup::STATUS_NEED_PRECHECK, 'restore'); + $restores = $DB->get_records_select( + 'backup_controllers', + $select, + $params, + 'timecreated DESC', + 'id, backupid, status, itemid, timecreated'); + + return $restores; + } + +} + diff --git a/backup/util/helper/tests/async_helper_test.php b/backup/util/helper/tests/async_helper_test.php new file mode 100644 index 00000000000..70b50a2078a --- /dev/null +++ b/backup/util/helper/tests/async_helper_test.php @@ -0,0 +1,148 @@ +. + +/** + * Asyncronhous helper tests. + * + * @package core_backup + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +defined('MOODLE_INTERNAL') || die(); + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + +/** + * Asyncronhous helper tests. + * + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class core_backup_async_helper_testcase extends \core_privacy\tests\provider_testcase { + + /** + * Tests sending message for asynchronous backup. + */ + public function test_send_message() { + global $DB, $USER; + $this->preventResetByRollback(); + $this->resetAfterTest(true); + $this->setAdminUser(); + + set_config('backup_async_message_users', '1', 'backup'); + set_config('backup_async_message_subject', 'Moodle {operation} completed sucessfully', 'backup'); + set_config('backup_async_message', + 'Dear {user_firstname} {user_lastname},
Your {operation} (ID: {backupid}) has completed successfully!', + 'backup'); + set_config('allowedemaildomains', 'example.com'); + + $generator = $this->getDataGenerator(); + $course = $generator->create_course(); // Create a course with some availability data set. + $user2 = $generator->create_user(array('firstname' => 'test', 'lastname' => 'human', 'maildisplay' => 1)); + $generator->enrol_user($user2->id, $course->id, 'editingteacher'); + + $DB->set_field_select('message_processors', 'enabled', 0, "name <> 'email'"); + set_user_preference('message_provider_moodle_asyncbackupnotification', 'email', $user2); + + // Make the backup controller for an async backup. + $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, + backup::INTERACTIVE_YES, backup::MODE_ASYNC, $user2->id); + $bc->finish_ui(); + $backupid = $bc->get_backupid(); + $bc->destroy(); + + $sink = $this->redirectEmails(); + + // Send message. + $asynchelper = new async_helper('backup', $backupid); + $messageid = $asynchelper->send_message(); + + $emails = $sink->get_messages(); + $this->assertCount(1, $emails); + $email = reset($emails); + + $this->assertSame($USER->email, $email->from); + $this->assertSame($user2->email, $email->to); + $this->assertSame('Moodle backup completed sucessfully', $email->subject); + $this->assertNotEmpty($email->header); + $this->assertNotEmpty($email->body); + $this->assertRegExp("/$backupid/", $email->body); + $this->assertThat($email->body, $this->logicalNot($this->stringContains('{'))); + $this->assertGreaterThan(0, $messageid); + $sink->clear(); + } + + /** + * Tests getting the asynchronous backup table items. + */ + public function test_get_async_backups() { + global $DB, $CFG, $USER, $PAGE; + + $this->resetAfterTest(true); + $this->setAdminUser(); + $CFG->enableavailability = true; + $CFG->enablecompletion = true; + + // Create a course with some availability data set. + $generator = $this->getDataGenerator(); + $course = $generator->create_course( + array('format' => 'topics', 'numsections' => 3, + 'enablecompletion' => COMPLETION_ENABLED), + array('createsections' => true)); + $forum = $generator->create_module('forum', array( + 'course' => $course->id)); + $forum2 = $generator->create_module('forum', array( + 'course' => $course->id, 'completion' => COMPLETION_TRACKING_MANUAL)); + + // We need a grade, easiest is to add an assignment. + $assignrow = $generator->create_module('assign', array( + 'course' => $course->id)); + $assign = new assign(context_module::instance($assignrow->cmid), false, false); + $item = $assign->get_grade_item(); + + // Make a test grouping as well. + $grouping = $generator->create_grouping(array('courseid' => $course->id, + 'name' => 'Grouping!')); + + $availability = '{"op":"|","show":false,"c":[' . + '{"type":"completion","cm":' . $forum2->cmid .',"e":1},' . + '{"type":"grade","id":' . $item->id . ',"min":4,"max":94},' . + '{"type":"grouping","id":' . $grouping->id . '}' . + ']}'; + $DB->set_field('course_modules', 'availability', $availability, array( + 'id' => $forum->cmid)); + $DB->set_field('course_sections', 'availability', $availability, array( + 'course' => $course->id, 'section' => 1)); + + // Make the backup controller for an async backup. + $bc = new backup_controller(backup::TYPE_1COURSE, $course->id, backup::FORMAT_MOODLE, + backup::INTERACTIVE_YES, backup::MODE_ASYNC, $USER->id); + $bc->finish_ui(); + $bc->destroy(); + unset($bc); + + $coursecontext = context_course::instance($course->id); + $renderer = $PAGE->get_renderer('core', 'backup'); + + $result = \async_helper::get_async_backups($renderer, $coursecontext->instanceid); + + $this->assertEquals(1, count($result)); + $this->assertEquals('backup.mbz', $result[0][0]); + } +} diff --git a/backup/util/includes/backup_includes.php b/backup/util/includes/backup_includes.php index 87d237d690b..0080a0e3b19 100644 --- a/backup/util/includes/backup_includes.php +++ b/backup/util/includes/backup_includes.php @@ -54,6 +54,7 @@ require_once($CFG->dirroot . '/backup/util/structure/backup_nested_element.class require_once($CFG->dirroot . '/backup/util/structure/backup_optigroup.class.php'); require_once($CFG->dirroot . '/backup/util/structure/backup_optigroup_element.class.php'); require_once($CFG->dirroot . '/backup/util/structure/backup_structure_processor.class.php'); +require_once($CFG->dirroot . '/backup/util/helper/async_helper.class.php'); require_once($CFG->dirroot . '/backup/util/helper/backup_helper.class.php'); require_once($CFG->dirroot . '/backup/util/helper/backup_general_helper.class.php'); require_once($CFG->dirroot . '/backup/util/helper/backup_null_iterator.class.php'); diff --git a/backup/util/includes/restore_includes.php b/backup/util/includes/restore_includes.php index ec0f8eb9801..2a8e020b638 100644 --- a/backup/util/includes/restore_includes.php +++ b/backup/util/includes/restore_includes.php @@ -34,6 +34,7 @@ require_once($CFG->dirroot . '/backup/util/interfaces/executable.class.php'); require_once($CFG->dirroot . '/backup/util/interfaces/processable.class.php'); require_once($CFG->dirroot . '/backup/backup.class.php'); require_once($CFG->dirroot . '/backup/util/structure/restore_path_element.class.php'); +require_once($CFG->dirroot . '/backup/util/helper/async_helper.class.php'); require_once($CFG->dirroot . '/backup/util/helper/backup_anonymizer_helper.class.php'); require_once($CFG->dirroot . '/backup/util/helper/backup_file_manager.class.php'); require_once($CFG->dirroot . '/backup/util/helper/restore_prechecks_helper.class.php'); diff --git a/backup/util/ui/amd/build/async_backup.min.js b/backup/util/ui/amd/build/async_backup.min.js new file mode 100644 index 00000000000..41384ff5135 --- /dev/null +++ b/backup/util/ui/amd/build/async_backup.min.js @@ -0,0 +1 @@ +define(["jquery","core/ajax","core/str","core/notification","core/templates"],function(a,b,c,d,e){function f(b,c){var d=Math.round(c)+"%",e=a("#"+b+"_bar"),f=c.toFixed(2)+"%";e.attr("aria-valuenow",d),e.css("width",d),e.text(f)}function g(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[1],j=a(i).text(),k=h[0],l=a(k).text();b.call([{methodname:"core_backup_get_async_backup_links_backup",args:{filename:l,contextid:n}}])[0].done(function(a){var b={filename:l,time:j,size:a.filesize,fileurl:a.fileurl,restoreurl:a.restoreurl};e.render("core/async_backup_progress_row",b).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function h(c){var f=a("#"+c+"_bar").parent().parent(),g=f.parent(),h=f.siblings(),i=h[0],j=h[1],k=a(j).text();b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:c,contextid:n}}])[0].done(function(b){var c=a(i).text(),f={resourcename:c,restoreurl:b.restoreurl,time:k};e.render("core/async_restore_progress_row",f).then(function(a,b){e.replaceNodeContents(g,a,b)}).fail(function(){d.exception(new Error("Failed to load table row"))})})}function i(e){var g,h=100*e.progress,i=a("#"+m+"_bar"),j=a("#"+m+"_status"),k=a("#"+m+"_detail"),l=a("#"+m+"_button");if(e.status==s){i.addClass("bg-success"),f(m,h);var r="async"+p+"processing";c.get_string(r,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+r))})}else if(e.status==t){i.addClass("bg-danger"),i.removeClass("bg-success"),f(m,100);var v="async"+p+"error",w="async"+p+"errordetail";g=[{key:v,component:"backup"},{key:w,component:"backup"}],c.get_strings(g).then(function(a){return j.text(a[0]),k.text(a[1]),a})["catch"](function(){d.exception(new Error("Failed to load string"))}),a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}else if(e.status==u){i.addClass("bg-success"),f(m,100);var x="async"+p+"complete";if(c.get_string(x,"backup").then(function(a){return j.text(a),a})["catch"](function(){d.exception(new Error("Failed to load string: backup "+x))}),"restore"==p)b.call([{methodname:"core_backup_get_async_backup_links_restore",args:{backupid:m,contextid:n}}])[0].done(function(a){var b="async"+p+"completedetail",e="async"+p+"completebutton",f=[{key:b,component:"backup",param:a.restoreurl},{key:e,component:"backup"}];c.get_strings(f).then(function(b){return k.html(b[0]),l.text(b[1]),l.attr("href",a.restoreurl),b})["catch"](function(){d.exception(new Error("Failed to load string"))})});else{var y="async"+p+"completedetail",z="async"+p+"completebutton";g=[{key:y,component:"backup",param:o},{key:z,component:"backup"}],c.get_strings(g).then(function(a){return k.html(a[0]),l.text(a[1]),l.attr("href",o),a})["catch"](function(){d.exception(new Error("Failed to load string"))})}a(".backup_progress").children("span").removeClass("backup_stage_current"),a(".backup_progress").children("span").last().addClass("backup_stage_current"),clearInterval(q)}}function j(b){b.forEach(function(b){var c=100*b.progress,d=b.backupid,e=a("#"+d+"_bar"),i=b.operation;b.status==s?(e.addClass("bg-success"),f(d,c)):b.status==t?(e.addClass("bg-danger"),e.addClass("complete"),a("#"+d+"_bar").removeClass("bg-success"),f(d,100)):b.status==u&&(e.addClass("bg-success"),e.addClass("complete"),f(d,100),"backup"==i?g(d):h(d))})}function k(){b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:[m],contextid:n}}])[0].done(function(a){i(a[0])})}function l(){var c=[],d=a(".progress").find(".progress-bar").not(".complete");d.each(function(){c.push(this.id.substring(0,32))}),c.length>0?b.call([{methodname:"core_backup_get_async_backup_progress",args:{backupids:c,contextid:n}}])[0].done(function(a){j(a)}):clearInterval(r)}var m,n,o,p,q,r,s=800,t=900,u=1e3,v={},w=5e3;return v.asyncBackupAllStatus=function(a){n=a,r=setInterval(l,w)},v.asyncBackupStatus=function(b,c,d,e){m=b,n=c,o=d,p="backup"==e?"backup":"restore",a(".backup_progress").children("a").removeAttr("href"),q=setInterval(k,w)},v}); \ No newline at end of file diff --git a/backup/util/ui/amd/src/async_backup.js b/backup/util/ui/amd/src/async_backup.js new file mode 100644 index 00000000000..0563fa8239d --- /dev/null +++ b/backup/util/ui/amd/src/async_backup.js @@ -0,0 +1,424 @@ +// 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 . + +/** + * This module updates the UI during an asynchronous + * backup or restore process. + * + * @module backup/util/async_backup + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @since 3.7 + */ +define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'], + function($, ajax, Str, notification, Templates) { + + /** + * Module level constants. + * + * Using var instead of const as ES6 isn't fully supported yet. + */ + var STATUS_EXECUTING = 800; + var STATUS_FINISHED_ERR = 900; + var STATUS_FINISHED_OK = 1000; + + /** + * Module level variables. + */ + var Asyncbackup = {}; + var checkdelay = 5000; // How often we check for progress updates. + var backupid; // The backup id to get the progress for. + var contextid; // The course this backup progress is for. + var restoreurl; // The URL to view course restores. + var typeid; // The type of operation backup or restore. + var backupintervalid; // The id of the setInterval function. + var allbackupintervalid; // The id of the setInterval function. + + /** + * Helper function to update UI components. + * + * @param {string} backupid The id to match elements on. + * @param {number} percentage The completion percentage to apply. + */ + function updateElement(backupid, percentage) { + var percentagewidth = Math.round(percentage) + '%'; + var elementbar = $('#' + backupid + '_bar'); + var percentagetext = percentage.toFixed(2) + '%'; + + // Set progress bar percentage indicators + elementbar.attr('aria-valuenow', percentagewidth); + elementbar.css('width', percentagewidth); + elementbar.text(percentagetext); + } + + /** + * Update backup table row when an async backup completes. + * + * @param {string} backupid The id to match elements on. + */ + function updateBackupTableRow(backupid) { + var statuscell = $('#' + backupid + '_bar').parent().parent(); + var tablerow = statuscell.parent(); + var cellsiblings = statuscell.siblings(); + var timecell = cellsiblings[1]; + var timevalue = $(timecell).text(); + var filenamecell = cellsiblings[0]; + var filename = $(filenamecell).text(); + + ajax.call([{ + // Get the table data via webservice. + methodname: 'core_backup_get_async_backup_links_backup', + args: { + 'filename': filename, + 'contextid': contextid + }, + }])[0].done(function(response) { + // We have the data now update the UI. + var context = { + filename: filename, + time: timevalue, + size: response.filesize, + fileurl: response.fileurl, + restoreurl: response.restoreurl + }; + + Templates.render('core/async_backup_progress_row', context).then(function(html, js) { + Templates.replaceNodeContents(tablerow, html, js); + return; + }).fail(function() { + notification.exception(new Error('Failed to load table row')); + return; + }); + }); + } + + /** + * Update restore table row when an async restore completes. + * + * @param {string} backupid The id to match elements on. + */ + function updateRestoreTableRow(backupid) { + var statuscell = $('#' + backupid + '_bar').parent().parent(); + var tablerow = statuscell.parent(); + var cellsiblings = statuscell.siblings(); + var coursecell = cellsiblings[0]; + var timecell = cellsiblings[1]; + var timevalue = $(timecell).text(); + + ajax.call([{ + // Get the table data via webservice. + methodname: 'core_backup_get_async_backup_links_restore', + args: { + 'backupid': backupid, + 'contextid': contextid + }, + }])[0].done(function(response) { + // We have the data now update the UI. + var resourcename = $(coursecell).text(); + var context = { + resourcename: resourcename, + restoreurl: response.restoreurl, + time: timevalue + }; + + Templates.render('core/async_restore_progress_row', context).then(function(html, js) { + Templates.replaceNodeContents(tablerow, html, js); + return; + }).fail(function() { + notification.exception(new Error('Failed to load table row')); + return; + }); + }); + } + + /** + * Update the Moodle user interface with the progress of + * the backup process. + * + * @param {object} progress The progress and status of the process. + */ + function updateProgress(progress) { + var percentage = progress.progress * 100; + var elementbar = $('#' + backupid + '_bar'); + var elementstatus = $('#' + backupid + '_status'); + var elementdetail = $('#' + backupid + '_detail'); + var elementbutton = $('#' + backupid + '_button'); + var stringRequests; + + if (progress.status == STATUS_EXECUTING) { + // Process is in progress. + // Add in progress class color to bar + elementbar.addClass('bg-success'); + + updateElement(backupid, percentage); + + // Change heading + var strProcessing = 'async' + typeid + 'processing'; + Str.get_string(strProcessing, 'backup').then(function(title) { + elementstatus.text(title); + return title; + }).catch(function() { + notification.exception(new Error('Failed to load string: backup ' + strProcessing)); + }); + + } else if (progress.status == STATUS_FINISHED_ERR) { + // Process completed with error. + + // Add in fail class color to bar + elementbar.addClass('bg-danger'); + + // Remove in progress class color to bar + elementbar.removeClass('bg-success'); + + updateElement(backupid, 100); + + // Change heading and text + var strStatus = 'async' + typeid + 'error'; + var strStatusDetail = 'async' + typeid + 'errordetail'; + stringRequests = [ + {key: strStatus, component: 'backup'}, + {key: strStatusDetail, component: 'backup'} + ]; + Str.get_strings(stringRequests).then(function(strings) { + elementstatus.text(strings[0]); + elementdetail.text(strings[1]); + + return strings; + }) + .catch(function() { + notification.exception(new Error('Failed to load string')); + return; + }); + + $('.backup_progress').children('span').removeClass('backup_stage_current'); + $('.backup_progress').children('span').last().addClass('backup_stage_current'); + + // Stop checking when we either have an error or a completion. + clearInterval(backupintervalid); + + } else if (progress.status == STATUS_FINISHED_OK) { + // Process completed successfully. + + // Add in progress class color to bar + elementbar.addClass('bg-success'); + + updateElement(backupid, 100); + + // Change heading and text + var strComplete = 'async' + typeid + 'complete'; + Str.get_string(strComplete, 'backup').then(function(title) { + elementstatus.text(title); + return title; + }).catch(function() { + notification.exception(new Error('Failed to load string: backup ' + strComplete)); + }); + + if (typeid == 'restore') { + ajax.call([{ + // Get the table data via webservice. + methodname: 'core_backup_get_async_backup_links_restore', + args: { + 'backupid': backupid, + 'contextid': contextid + }, + }])[0].done(function(response) { + var strDetail = 'async' + typeid + 'completedetail'; + var strButton = 'async' + typeid + 'completebutton'; + var stringRequests = [ + {key: strDetail, component: 'backup', param: response.restoreurl}, + {key: strButton, component: 'backup'} + ]; + Str.get_strings(stringRequests).then(function(strings) { + elementdetail.html(strings[0]); + elementbutton.text(strings[1]); + elementbutton.attr('href', response.restoreurl); + + return strings; + }) + .catch(function() { + notification.exception(new Error('Failed to load string')); + return; + }); + + }); + } else { + var strDetail = 'async' + typeid + 'completedetail'; + var strButton = 'async' + typeid + 'completebutton'; + stringRequests = [ + {key: strDetail, component: 'backup', param: restoreurl}, + {key: strButton, component: 'backup'} + ]; + Str.get_strings(stringRequests).then(function(strings) { + elementdetail.html(strings[0]); + elementbutton.text(strings[1]); + elementbutton.attr('href', restoreurl); + + return strings; + }) + .catch(function() { + notification.exception(new Error('Failed to load string')); + return; + }); + + } + + $('.backup_progress').children('span').removeClass('backup_stage_current'); + $('.backup_progress').children('span').last().addClass('backup_stage_current'); + + // Stop checking when we either have an error or a completion. + clearInterval(backupintervalid); + } + } + + /** + * Update the Moodle user interface with the progress of + * all the pending processes. + * + * @param {object} progress The progress and status of the process. + */ + function updateProgressAll(progress) { + progress.forEach(function(element) { + var percentage = element.progress * 100; + var backupid = element.backupid; + var elementbar = $('#' + backupid + '_bar'); + var type = element.operation; + + if (element.status == STATUS_EXECUTING) { + // Process is in element. + + // Add in element class color to bar + elementbar.addClass('bg-success'); + + updateElement(backupid, percentage); + + } else if (element.status == STATUS_FINISHED_ERR) { + // Process completed with error. + + // Add in fail class color to bar + elementbar.addClass('bg-danger'); + elementbar.addClass('complete'); + + // Remove in element class color to bar + $('#' + backupid + '_bar').removeClass('bg-success'); + + updateElement(backupid, 100); + + } else if (element.status == STATUS_FINISHED_OK) { + // Process completed successfully. + + // Add in element class color to bar + elementbar.addClass('bg-success'); + elementbar.addClass('complete'); + + updateElement(backupid, 100); + + // We have a successful backup. Update the UI with download and file details. + if (type == 'backup') { + updateBackupTableRow(backupid); + } else { + updateRestoreTableRow(backupid); + } + + } + + }); + } + + /** + * Get the progress of the backup process via ajax. + */ + function getBackupProgress() { + ajax.call([{ + // Get the backup progress via webservice. + methodname: 'core_backup_get_async_backup_progress', + args: { + 'backupids': [backupid], + 'contextid': contextid + }, + }])[0].done(function(response) { + // We have the progress now update the UI. + updateProgress(response[0]); + }); + } + + /** + * Get the progress of all backup processes via ajax. + */ + function getAllBackupProgress() { + var backupids = []; + var progressbars = $('.progress').find('.progress-bar').not('.complete'); + + progressbars.each(function() { + backupids.push((this.id).substring(0, 32)); + }); + + if (backupids.length > 0) { + ajax.call([{ + // Get the backup progress via webservice. + methodname: 'core_backup_get_async_backup_progress', + args: { + 'backupids': backupids, + 'contextid': contextid + }, + }])[0].done(function(response) { + updateProgressAll(response); + }); + } else { + clearInterval(allbackupintervalid); // No more progress bars to update, stop checking. + } + } + + /** + * Get status updates for all backups. + * + * @public + * @param {number} context The context id. + */ + Asyncbackup.asyncBackupAllStatus = function(context) { + contextid = context; + allbackupintervalid = setInterval(getAllBackupProgress, checkdelay); + }; + + /** + * Get status updates for backup. + * + * @public + * @param {string} backup The backup record id. + * @param {number} context The context id. + * @param {string} restore The restore link. + * @param {string} type The operation type (backup or restore). + */ + Asyncbackup.asyncBackupStatus = function(backup, context, restore, type) { + backupid = backup; + contextid = context; + restoreurl = restore; + + if (type == 'backup') { + typeid = 'backup'; + } else { + typeid = 'restore'; + } + + // Remove the links from the progress bar, no going back now. + $('.backup_progress').children('a').removeAttr('href'); + + // Periodically check for progress updates and update the UI as required. + backupintervalid = setInterval(getBackupProgress, checkdelay); + + }; + + return Asyncbackup; +}); diff --git a/backup/util/ui/renderer.php b/backup/util/ui/renderer.php index d307e54ebb9..4ee83e966f5 100644 --- a/backup/util/ui/renderer.php +++ b/backup/util/ui/renderer.php @@ -22,6 +22,13 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +defined('MOODLE_INTERNAL') || die; + +global $CFG; +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); +require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php'); + /** * The primary renderer for the backup. * @@ -64,9 +71,8 @@ class core_backup_renderer extends plugin_renderer_base { * @return string HTML content that shows the log */ public function log_display($loghtml) { - global $OUTPUT; $out = html_writer::start_div('backup_log'); - $out .= $OUTPUT->heading(get_string('backuplog', 'backup')); + $out .= $this->output->heading(get_string('backuplog', 'backup')); $out .= html_writer::start_div('backup_log_contents'); $out .= $loghtml; $out .= html_writer::end_div(); @@ -533,6 +539,32 @@ class core_backup_renderer extends plugin_renderer_base { return $this->render($files); } + /** + * Generate the status indicator markup for display in the + * backup restore file area UI. + * + * @param int $statuscode The status code of the backup. + * @param string $backupid The backup record id. + * @return string|boolean $status The status indicator for the operation. + */ + public function get_status_display($statuscode, $backupid) { + if ($statuscode == backup::STATUS_AWAITING || $statuscode == backup::STATUS_EXECUTING) { // Inprogress. + $progresssetup = array( + 'backupid' => $backupid, + 'width' => '100' + ); + $status = $this->render_from_template('core/async_backup_progress', $progresssetup); + } else if ($statuscode == backup::STATUS_FINISHED_ERR) { // Error. + $icon = $this->output->render(new \pix_icon('i/delete', get_string('failed', 'backup'))); + $status = \html_writer::span($icon, 'action-icon'); + } else if ($statuscode == backup::STATUS_FINISHED_OK) { // Complete. + $icon = $this->output->render(new \pix_icon('i/checked', get_string('successful', 'backup'))); + $status = \html_writer::span($icon, 'action-icon'); + } + + return $status; + } + /** * Displays a backup files viewer * @@ -544,12 +576,35 @@ class core_backup_renderer extends plugin_renderer_base { global $CFG; $files = $viewer->files; + $async = async_helper::is_async_enabled(); + + $tablehead = array( + get_string('filename', 'backup'), + get_string('time'), + get_string('size'), + get_string('download'), + get_string('restore')); + if ($async) { + $tablehead[] = get_string('status', 'backup'); + } + $table = new html_table(); $table->attributes['class'] = 'backup-files-table generaltable'; - $table->head = array(get_string('filename', 'backup'), get_string('time'), get_string('size'), get_string('download'), get_string('restore')); + $table->head = $tablehead; $table->width = '100%'; $table->data = array(); + // First add in progress asynchronous backups. + // Only if asynchronous backups are enabled. + // Also only render async status in correct area. Courese OR activity (not both). + if ($async + && (($viewer->filearea == 'course' && $viewer->currentcontext->contextlevel == CONTEXT_COURSE) + || ($viewer->filearea == 'activity' && $viewer->currentcontext->contextlevel == CONTEXT_MODULE)) + ) { + $table->data = \async_helper::get_async_backups($this, $viewer->currentcontext->instanceid); + } + + // Add completed backups. foreach ($files as $file) { if ($file->is_directory()) { continue; @@ -585,13 +640,18 @@ class core_backup_renderer extends plugin_renderer_base { $downloadlink = ''; } } - $table->data[] = array( + $tabledata = array( $file->get_filename(), - userdate($file->get_timemodified()), - display_size($file->get_filesize()), + userdate ($file->get_timemodified()), + display_size ($file->get_filesize()), $downloadlink, - $restorelink, - ); + $restorelink + ); + if ($async) { + $tabledata[] = $this->get_status_display(backup::STATUS_FINISHED_OK, null); + } + + $table->data[] = $tabledata; } $html = html_writer::table($table); @@ -853,6 +913,42 @@ class core_backup_renderer extends plugin_renderer_base { $output .= html_writer::end_tag('div'); return $output; } + + /** + * Get markup to render table for all of a users async + * in progress restores. + * + * @param int $userid The Moodle user id. + * @param \context $context The Moodle context for these restores. + * @return string $html The table HTML. + */ + public function restore_progress_viewer ($userid, $context) { + $tablehead = array(get_string('course'), get_string('time'), get_string('status', 'backup')); + + $table = new html_table(); + $table->attributes['class'] = 'backup-files-table generaltable'; + $table->head = $tablehead; + $tabledata = array(); + + // Get all in progress async restores for this user. + $restores = \async_helper::get_async_restores($userid); + + // For each backup get, new item name, time restore created and progress. + foreach ($restores as $restore) { + + $restorename = \async_helper::get_restore_name($context); + $timecreated = $restore->timecreated; + $status = $this->get_status_display($restore->status, $restore->backupid); + + $tablerow = array($restorename, userdate($timecreated), $status); + $tabledata[] = $tablerow; + } + + $table->data = $tabledata; + $html = html_writer::table($table); + + return $html; + } } /** diff --git a/course/delete.php b/course/delete.php index def41a6b7cc..66bb257276f 100644 --- a/course/delete.php +++ b/course/delete.php @@ -24,6 +24,7 @@ require_once(__DIR__ . '/../config.php'); require_once($CFG->dirroot . '/course/lib.php'); +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); $id = required_param('id', PARAM_INT); // Course ID. $delete = optional_param('delete', '', PARAM_ALPHANUM); // Confirmation hash. @@ -74,16 +75,26 @@ if ($delete === md5($course->timemodified)) { } $strdeletecheck = get_string("deletecheck", "", $courseshortname); -$strdeletecoursecheck = get_string("deletecoursecheck"); -$message = "{$strdeletecoursecheck}

{$coursefullname} ({$courseshortname})"; - -$continueurl = new moodle_url('/course/delete.php', array('id' => $course->id, 'delete' => md5($course->timemodified))); -$continuebutton = new single_button($continueurl, get_string('delete'), 'post'); $PAGE->navbar->add($strdeletecheck); $PAGE->set_title("$SITE->shortname: $strdeletecheck"); $PAGE->set_heading($SITE->fullname); echo $OUTPUT->header(); -echo $OUTPUT->confirm($message, $continuebutton, $categoryurl); + +// Only let user delete this course if there is not an async backup in progress. +if (!async_helper::is_async_pending($id, 'course', 'backup')) { + $strdeletecoursecheck = get_string("deletecoursecheck"); + $message = "{$strdeletecoursecheck}

{$coursefullname} ({$courseshortname})"; + + $continueurl = new moodle_url('/course/delete.php', array('id' => $course->id, 'delete' => md5($course->timemodified))); + $continuebutton = new single_button($continueurl, get_string('delete'), 'post'); + echo $OUTPUT->confirm($message, $continuebutton, $categoryurl); +} else { + // Async backup is pending, don't let user delete course. + echo $OUTPUT->notification(get_string('pendingasyncerror', 'backup'), 'error'); + echo $OUTPUT->container(get_string('pendingasyncdeletedetail', 'backup')); + echo $OUTPUT->continue_button($categoryurl); +} + echo $OUTPUT->footer(); exit; diff --git a/course/view.php b/course/view.php index 81db3ce4280..e1a3e3c0b9b 100644 --- a/course/view.php +++ b/course/view.php @@ -5,6 +5,7 @@ require_once('../config.php'); require_once('lib.php'); require_once($CFG->libdir.'/completionlib.php'); + require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); $id = optional_param('id', 0, PARAM_INT); $name = optional_param('name', '', PARAM_TEXT); @@ -243,6 +244,10 @@ $PAGE->set_heading($course->fullname); echo $OUTPUT->header(); +if ($USER->editing == 1 && async_helper::is_async_pending($id, 'course', 'backup')) { + echo $OUTPUT->notification(get_string('pendingasyncedit', 'backup'), 'warning'); +} + if ($completion->is_enabled()) { // This value tracks whether there has been a dynamic change to the page. // It is used so that if a user does this - (a) set some tickmarks, (b) diff --git a/lang/en/backup.php b/lang/en/backup.php index 2fdc6b33a1f..c518dab3d2c 100644 --- a/lang/en/backup.php +++ b/lang/en/backup.php @@ -22,6 +22,36 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +$string['asyncbackupcomplete'] = 'The backup process has completed'; +$string['asyncbackupcompletebutton'] = 'Continue'; +$string['asyncbackupcompletedetail'] = 'The backup process has completed successfully completed.
You can access the backup in the restore page.'; +$string['asyncbackuperror'] = 'The backup process has failed'; +$string['asyncbackuperrordetail'] = 'The backup process has failed. Please contact your system administrator.'; +$string['asyncbackuppending'] = 'The backup process is pending'; +$string['asyncbackupprocessing'] = 'The backup is in progress'; +$string['asyncbadexecution'] = 'Bad backup controller execution, is {$a} should be 2'; +$string['asynccheckprogress'] = ' You can check the progress at anytime at the restore page.'; +$string['asyncgeneralsettings'] = 'Asynchronous backup/restore general settings'; +$string['asyncemailenable'] = 'Enable message notifications'; +$string['asyncemailenabledetail'] = 'When enabled users will receive a message when an asynchronous restore/backup completes'; +$string['asyncmessagebody'] = 'Message'; +$string['asyncmessagebodydetail'] = 'Message to send when an asynchronous restore/backup completes'; +$string['asyncmessagebodydefault'] = 'Dear {user_firstname} {user_lastname},
Your {operation} (ID: {backupid}) has completed successfully!

You can view it here {link}.
Kind Regards,
Your Moodle Administrator.'; +$string['asyncmessagesubject'] = 'Subject'; +$string['asyncmessagesubjectdetail'] = 'Message subject'; +$string['asyncmessagesubjectdefault'] = 'Moodle {operation} completed successfully'; +$string['asyncnowait'] = 'You don\'t need to wait here, the process will continue in the background.'; +$string['asyncprocesspending'] = 'Process pending'; +$string['asyncrestorecomplete'] = 'The restore process has completed'; +$string['asyncrestorecompletebutton'] = 'Continue'; +$string['asyncrestorecompletedetail'] = 'The restore process has completed successfully completed. Clicking continue will take you to the course for the restored item.'; +$string['asyncrestoreerror'] = 'The restore process has failed'; +$string['asyncrestoreerrordetail'] = 'The restore process has failed. Please contact your system administrator.'; +$string['asyncrestorepending'] = 'The restore process is pending'; +$string['asyncrestoreprocessing'] = 'The restore is in progress'; +$string['asyncreturn'] = 'Return to course'; +$string['asyncrestoreinprogress'] = 'Restores in progress'; +$string['asyncrestoreinprogress_help'] = 'Asynchronous course restores that are in progress are shown here.'; $string['autoactivedisabled'] = 'Disabled'; $string['autoactiveenabled'] = 'Enabled'; $string['autoactivemanual'] = 'Manual'; @@ -61,6 +91,7 @@ $string['backupmode30'] = 'Hub'; $string['backupmode40'] = 'Same site'; $string['backupmode50'] = 'Automated'; $string['backupmode60'] = 'Converted'; +$string['backupmode70'] = 'Asynchronous'; $string['backupsection'] = 'Backup course section: {$a}'; $string['backupsettings'] = 'Backup settings'; $string['backupsitedetails'] = 'Site details'; @@ -137,6 +168,8 @@ $string['currentstage2'] = 'Schema settings'; $string['currentstage4'] = 'Confirmation and review'; $string['currentstage8'] = 'Perform backup'; $string['currentstage16'] = 'Complete'; +$string['enableasyncbackup'] = 'Enable asynchronous backups'; +$string['enableasyncbackup_help'] = 'If enabled, all backup and restore operations will be done asynchronously. This does not effect imports and exports. Asynchronous backups and restores allow users to do other operations while a backup or restore is in progress.'; $string['enterasearch'] = 'Enter a search'; $string['error_block_for_module_not_found'] = 'Orphan block instance (id: {$a->bid}) for course module (id: {$a->mid}) found. This block will not be backed up'; $string['error_course_module_not_found'] = 'Orphan course module (id: {$a}) found. This module will not be backed up.'; @@ -149,6 +182,7 @@ $string['errorinvalidformat'] = 'Unknown backup format'; $string['errorinvalidformatinfo'] = 'The selected file is not a valid Moodle backup file and can\'t be restored.'; $string['errorrestorefrontpagebackup'] = 'You can only restore front page backups on the front page'; $string['executionsuccess'] = 'The backup file was successfully created.'; +$string['failed'] = 'Backup failed'; $string['filename'] = 'Filename'; $string['filealiasesrestorefailures'] = 'Aliases restore failures'; $string['filealiasesrestorefailuresinfo'] = 'Some aliases included in the backup file could not be restored. The following list contains their expected location and the source file they were referring to at the original site.'; @@ -203,6 +237,7 @@ $string['importcurrentstage16'] = 'Complete'; $string['importrootsettings'] = 'Import settings'; $string['importsettings'] = 'General import settings'; $string['importsuccess'] = 'Import complete. Click continue to return to the course.'; +$string['inprogress'] = 'Backup in progress'; $string['includeactivities'] = 'Include:'; $string['includeditems'] = 'Included items:'; $string['includesection'] = 'Section {$a}'; @@ -223,6 +258,10 @@ $string['nomatchingcourses'] = 'There are no courses to display'; $string['norestoreoptions'] = 'There are no categories or existing courses you can restore to.'; $string['originalwwwroot'] = 'URL of backup'; $string['overwrite'] = 'Overwrite'; +$string['pendingasyncdetail'] = 'Asynchronous backups only allow a user to have one pending backup for a resource at a time.
Muliple asynchronous backups of the same resource can\'t be queued, as this would likely result in multiple backups with the same content.'; +$string['pendingasyncdeletedetail'] = 'This course has an asynchronous backup pending.
Courses can\'t be deleted until this backup finishes.'; +$string['pendingasyncedit'] = 'There is a pending asynchronous backup for this course. Please do not edit this course until backup is complete.'; +$string['pendingasyncerror'] = 'Backup pending for this resource'; $string['previousstage'] = 'Previous'; $string['preparingui'] = 'Preparing to display page'; $string['preparingdata'] = 'Preparing data'; @@ -323,6 +362,9 @@ $string['skipmodifdays'] = 'Skip courses not modified since'; $string['skipmodifdayshelp'] = 'Choose to skip courses that have not been modified since a number of days'; $string['skipmodifprev'] = 'Skip courses not modified since previous backup'; $string['skipmodifprevhelp'] = 'Choose whether to skip courses that have not been modified since the last automatic backup. This requires logging to be enabled.'; +$string['status'] = 'Status'; +$string['successful'] = 'Backup successful'; +$string['successfulrestore'] = 'Restore successful'; $string['timetaken'] = 'Time taken'; $string['title'] = 'Title'; $string['totalcategorysearchresults'] = 'Total categories: {$a}'; diff --git a/lang/en/moodle.php b/lang/en/moodle.php index 5fb1e314289..9ddc8390a10 100644 --- a/lang/en/moodle.php +++ b/lang/en/moodle.php @@ -1188,6 +1188,7 @@ $string['memberincourse'] = 'People in the course'; $string['messagebody'] = 'Message body'; $string['messagedselectedusers'] = 'Selected users have been messaged and the recipient list has been reset.'; $string['messagedselectedusersfailed'] = 'Something went wrong while messaging selected users. Some may have received the email.'; +$string['messageprovider:asyncbackupnotification'] = 'Asynchronous backup/restore notifications'; $string['messageprovider:availableupdate'] = 'Available update notifications'; $string['messageprovider:backup'] = 'Backup notifications'; $string['messageprovider:badgecreatornotice'] = 'Badge creator notifications'; diff --git a/lib/classes/progress/db_updater.php b/lib/classes/progress/db_updater.php new file mode 100644 index 00000000000..532ab6a3fa0 --- /dev/null +++ b/lib/classes/progress/db_updater.php @@ -0,0 +1,123 @@ +. + +/** + * Progress handler that updates a database table with the progress. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\progress; + +defined('MOODLE_INTERNAL') || die(); + +/** + * Progress handler that updates a database table with the progress. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class db_updater extends base { + + /** + * The primary key of the database record to update. + * + * @var integer + */ + protected $recordid = 0; + + /** + * The database table to insert the progress updates into. + * + * @var string + */ + protected $table = ''; + + /** + * The table field to update with the progress. + * + * @var string + */ + protected $field = ''; + + /** + * The maximum frequency in seconds to update the database (default 5 seconds). + * Lower values will increase database calls. + * + * @var integer + */ + protected $interval = 5; + + + /** + * The timestamp of when the next progress update to the database will be. + * + * @var integer + */ + protected $nextupdate = 0; + + /** + * Constructs the progress reporter. + * + * @param int $recordid The primary key of the database record to update. + * @param string $table The databse table to insert the progress updates into. + * @param string $field The table field to update with the progress. + * @param int $interval The maximum frequency in seconds to update the database (default 5 seconds). + */ + public function __construct($recordid, $table, $field, $interval=5) { + $this->recordid = $recordid; + $this->table = $table; + $this->field = $field; + $this->interval = $interval; + } + + /** + * Updates the progress in the database. + * Database update frequency is set by $interval. + * + * @see \core\progress\base::update_progress() + */ + public function update_progress() { + global $DB; + $now = $this->get_time(); + $lastprogress = $this->lastprogresstime != 0 ? $this->lastprogresstime : $now; + + $progressrecord = new \stdClass(); + $progressrecord->id = $this->recordid; + $progressrecord->{$this->field} = ''; + + // Update database with progress. + if ($now > $this->nextupdate) { // Limit database updates based on time. + list ($min, $max) = $this->get_progress_proportion_range(); + + $progressrecord->{$this->field} = $min; + $DB->update_record($this->table, $progressrecord); + $this->nextupdate = $lastprogress + $this->interval; + } + + // Set progress to 1 (100%) when there are no more progress updates. + // Their is no guarantee that the final update from the get progress method + // will be 1 even for a successful process. So we explicitly set the final DB + // value to 1 when we are no longer in progress. + if (!$this->is_in_progress_section()) { + $progressrecord->{$this->field} = 1; + $DB->update_record($this->table, $progressrecord); + } + } +} diff --git a/lib/classes/task/asynchronous_backup_task.php b/lib/classes/task/asynchronous_backup_task.php new file mode 100644 index 00000000000..73554a3ae40 --- /dev/null +++ b/lib/classes/task/asynchronous_backup_task.php @@ -0,0 +1,90 @@ +. + +/** + * Adhoc task that performs asynchronous backups. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\task; + +use async_helper; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php'); +require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php'); + +/** + * Adhoc task that performs asynchronous backups. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class asynchronous_backup_task extends adhoc_task { + + /** + * Run the adhoc task and preform the backup. + */ + public function execute() { + global $DB; + $started = time(); + + $backupid = $this->get_custom_data()->backupid; + $backuprecordid = $DB->get_field('backup_controllers', 'id', array('backupid' => $backupid), MUST_EXIST); + mtrace('Processing asynchronous backup for backup: ' . $backupid); + + // Get the backup controller by backup id. + $bc = \backup_controller::load_controller($backupid); + $bc->set_progress(new \core\progress\db_updater($backuprecordid, 'backup_controllers', 'progress')); + + // Do some preflight checks on the backup. + $status = $bc->get_status(); + $execution = $bc->get_execution(); + + // Check that the backup is in the correct status and + // that is set for asynchronous execution. + if ($status == \backup::STATUS_AWAITING && $execution == \backup::EXECUTION_DELAYED) { + // Execute the backup. + $bc->execute_plan(); + + // Send message to user if enabled. + $messageenabled = (bool)get_config('backup', 'backup_async_message_users'); + if ($messageenabled && $bc->get_status() == \backup::STATUS_FINISHED_OK) { + $asynchelper = new async_helper('backup', $backupid); + $asynchelper->send_message(); + } + + } else { + // If status isn't 700, it means the process has failed. + // Retrying isn't going to fix it, so marked operation as failed. + $bc->set_status(\backup::STATUS_FINISHED_ERR); + mtrace('Bad backup controller status, is: ' . $status . ' should be 700, marking job as failed.'); + + } + + // Cleanup. + $bc->destroy(); + + $duration = time() - $started; + mtrace('Backup completed in: ' . $duration . ' seconds'); + } +} + diff --git a/lib/classes/task/asynchronous_restore_task.php b/lib/classes/task/asynchronous_restore_task.php new file mode 100644 index 00000000000..9572ba05ed3 --- /dev/null +++ b/lib/classes/task/asynchronous_restore_task.php @@ -0,0 +1,89 @@ +. + +/** + * Adhoc task that performs asynchronous restores. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +namespace core\task; + +use async_helper; + +defined('MOODLE_INTERNAL') || die(); + +require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php'); + +/** + * Adhoc task that performs asynchronous restores. + * + * @package core + * @copyright 2018 Matt Porritt + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class asynchronous_restore_task extends adhoc_task { + + /** + * Run the adhoc task and preform the restore. + */ + public function execute() { + global $DB; + $started = time(); + + $restoreid = $this->get_custom_data()->backupid; + $restorerecordid = $DB->get_field('backup_controllers', 'id', array('backupid' => $restoreid), MUST_EXIST); + mtrace('Processing asynchronous restore for id: ' . $restoreid); + + // Get the restore controller by backup id. + $rc = \restore_controller::load_controller($restoreid); + $rc->set_progress(new \core\progress\db_updater($restorerecordid, 'backup_controllers', 'progress')); + + // Do some preflight checks on the restore. + $status = $rc->get_status(); + $execution = $rc->get_execution(); + + // Check that the restore is in the correct status and + // that is set for asynchronous execution. + if ($status == \backup::STATUS_AWAITING && $execution == \backup::EXECUTION_DELAYED) { + // Execute the restore. + $rc->execute_plan(); + + // Send message to user if enabled. + $messageenabled = (bool)get_config('backup', 'backup_async_message_users'); + if ($messageenabled && $rc->get_status() == \backup::STATUS_FINISHED_OK) { + $asynchelper = new async_helper('restore', $restoreid); + $asynchelper->send_message(); + } + + } else { + // If status isn't 700, it means the process has failed. + // Retrying isn't going to fix it, so marked operation as failed. + $rc->set_status(\backup::STATUS_FINISHED_ERR); + mtrace('Bad backup controller status, is: ' . $status . ' should be 700, marking job as failed.'); + + } + + // Cleanup. + $rc->destroy(); + + $duration = time() - $started; + mtrace('Restore completed in: ' . $duration . ' seconds'); + } +} + diff --git a/lib/db/install.xml b/lib/db/install.xml index 9c127a4edce..6fd0957686c 100644 --- a/lib/db/install.xml +++ b/lib/db/install.xml @@ -2898,6 +2898,7 @@ + @@ -2907,6 +2908,7 @@ + diff --git a/lib/db/messages.php b/lib/db/messages.php index 9c0593c10a3..e2b63f125e8 100644 --- a/lib/db/messages.php +++ b/lib/db/messages.php @@ -117,4 +117,12 @@ $messageproviders = array ( 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF, ] ], + + // Asyncronhous backup/restore notifications. + 'asyncbackupnotification' => array( + 'defaults' => array( + 'popup' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDIN + MESSAGE_DEFAULT_LOGGEDOFF, + 'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF, + ) + ), ); diff --git a/lib/db/services.php b/lib/db/services.php index ea5f502b64e..ec4dd782222 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -74,6 +74,33 @@ $functions = array( 'ajax' => true, 'loginrequired' => false, ), + 'core_backup_get_async_backup_progress' => array( + 'classname' => 'core_backup_external', + 'classpath' => 'backup/externallib.php', + 'methodname' => 'get_async_backup_progress', + 'description' => 'Get the progress of an Asyncronhous backup.', + 'type' => 'read', + 'ajax' => true, + 'loginrequired' => true, + ), + 'core_backup_get_async_backup_links_backup' => array( + 'classname' => 'core_backup_external', + 'classpath' => 'backup/externallib.php', + 'methodname' => 'get_async_backup_links_backup', + 'description' => 'Gets the data to use when updating the status table row in the UI for when an async backup completes.', + 'type' => 'read', + 'ajax' => true, + 'loginrequired' => true, + ), + 'core_backup_get_async_backup_links_restore' => array( + 'classname' => 'core_backup_external', + 'classpath' => 'backup/externallib.php', + 'methodname' => 'get_async_backup_links_restore', + 'description' => 'Gets the data to use when updating the status table row in the UI for when an async restore completes.', + 'type' => 'read', + 'ajax' => true, + 'loginrequired' => true, + ), 'core_badges_get_user_badges' => array( 'classname' => 'core_badges_external', 'methodname' => 'get_user_badges', diff --git a/lib/db/upgrade.php b/lib/db/upgrade.php index 203e02d03a2..a42b8b7f8f3 100644 --- a/lib/db/upgrade.php +++ b/lib/db/upgrade.php @@ -2640,7 +2640,6 @@ function xmldb_main_upgrade($oldversion) { $key = new xmldb_key('useridgroupid', XMLDB_KEY_UNIQUE, array('userid', 'groupid')); // Launch add key useridgroupid. $dbman->add_key($table, $key); - // Main savepoint reached. upgrade_main_savepoint(true, 2019011801.03); } @@ -2934,5 +2933,23 @@ function xmldb_main_upgrade($oldversion) { upgrade_main_savepoint(true, 2019040200.01); } + if ($oldversion < 2019040800.00) { + // Define field and index to be added to backup_controllers. + $table = new xmldb_table('backup_controllers'); + $field = new xmldb_field('progress', XMLDB_TYPE_NUMBER, '15, 14', null, XMLDB_NOTNULL, null, '0', 'timemodified'); + $index = new xmldb_index('useritem_ix', XMLDB_INDEX_NOTUNIQUE, ['userid', 'itemid']); + // Conditionally launch add field progress. + if (!$dbman->field_exists($table, $field)) { + $dbman->add_field($table, $field); + } + // Conditionally launch add index useritem_ix. + if (!$dbman->index_exists($table, $index)) { + $dbman->add_index($table, $index); + } + + // Main savepoint reached. + upgrade_main_savepoint(true, 2019040800.00); + } + return true; } diff --git a/lib/templates/async_backup_progress.mustache b/lib/templates/async_backup_progress.mustache new file mode 100644 index 00000000000..ba96e5311cc --- /dev/null +++ b/lib/templates/async_backup_progress.mustache @@ -0,0 +1,45 @@ +{{! + 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 . +}} +{{! + @template core/async_backup_progress. + + Moodle Asynchronous backup status template. + + The purpose of this template is to render status + updates during an asynchronous backup or restore + process.. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * + + Example context (json): + { + "backupid": "f04abf8cba0319e486a3dfa7e9cb4476", + "width": "500" + } +}} +
+
+ {{# str }} asyncprocesspending, backup {{/ str }} +
+
diff --git a/lib/templates/async_backup_progress_row.mustache b/lib/templates/async_backup_progress_row.mustache new file mode 100644 index 00000000000..012415932c9 --- /dev/null +++ b/lib/templates/async_backup_progress_row.mustache @@ -0,0 +1,52 @@ +{{! + 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 . +}} +{{! + @template core/async_backup_progress_row. + + Moodle Asynchronous backup status table row template. + + The purpose of this template is to render status + table row updates during an asynchronous backup process. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * + + Example context (json): + { + "filename": "backup-moodle2-course-49390-test_course-20190326-1546-nu-nf.mbz", + "time": "Tuesday, 26 March 2019, 3:47 PM", + "size": "7.2KB", + "fileurl": "https://moodle.local/pluginfile.php/7945628/backup/course/backup-moodle2-course-49390-test_course-20190326-1546-nu-nf.mbz?forcedownload=1", + "restoreurl": "https://moodle.local/backup/restorefile.php?action=choosebackupfile&filename=backup-moodle2-course-49390-test_course-20190326-1546-nu-nf.mbz&filepath=%2F&component=backup&filearea=course&filecontextid=7945628&contextid=7945628&itemid=0" + } +}} +
+ + + + + diff --git a/lib/templates/async_backup_status.mustache b/lib/templates/async_backup_status.mustache new file mode 100644 index 00000000000..49cb0425e5e --- /dev/null +++ b/lib/templates/async_backup_status.mustache @@ -0,0 +1,57 @@ +{{! + 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 . +}} +{{! + @template core/async_backup_status. + + Moodle Asynchronous backup status template. + + The purpose of this template is to render status + updates during an asynchronous backup or restore + process.. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * + + Example context (json): + { + "backupid": "f04abf8cba0319e486a3dfa7e9cb4476", + "contextid": "4", + "courseurl": "/course/view.php?id=6", + "restoreurl": "/backup/restorefile.php?contextid=287", + "headingident": "backup", + "width": "500" + } +}} +
+ {{#headingident}}

{{# str }} asyncbackuppending, backup {{/ str }}

{{/headingident}} + {{^headingident}}

{{# str }} asyncrestorepending, backup {{/ str }}

{{/headingident}} + {{> core/async_backup_progress }} +

{{# str }} asyncnowait, backup {{/ str }}
{{# str }} asynccheckprogress, backup, {{restoreurl}} {{/ str }}

+ {{# str }} asyncreturn, backup {{/ str }} +
+ +{{#js}} +require(['core_backup/async_backup'], function(Async) { + Async.asyncBackupStatus("{{backupid}}", "{{contextid}}", "{{restoreurl}}", "{{headingident}}"); +}); +{{/js}} diff --git a/lib/templates/async_restore_progress_row.mustache b/lib/templates/async_restore_progress_row.mustache new file mode 100644 index 00000000000..c67e528b3fa --- /dev/null +++ b/lib/templates/async_restore_progress_row.mustache @@ -0,0 +1,51 @@ +{{! + 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 . +}} +{{! + @template core/async_restore_progress_row. + + Moodle Asynchronous restore status table row template. + + The purpose of this template is to render status + table row updates during an asynchronous restore process. + + Classes required for JS: + * none + + Data attributes required for JS: + * none + + Context variables required for this template: + * + + Example context (json): + { + "resourcename" : "Mathematics 101", + "time": "Tuesday, 26 March 2019, 3:47 PM", + "restoreurl": "https://moodle.local/backup/restorefile.php?action=choosebackupfile&filename=backup-moodle2-course-49390-test_course-20190326-1546-nu-nf.mbz&filepath=%2F&component=backup&filearea=course&filecontextid=7945628&contextid=7945628&itemid=0" + } +}} +
+ + + + diff --git a/version.php b/version.php index 08fe4374e63..10c8c6a58d2 100644 --- a/version.php +++ b/version.php @@ -29,7 +29,7 @@ defined('MOODLE_INTERNAL') || die(); -$version = 2019040600.00; // YYYYMMDD = weekly release date of this DEV branch. +$version = 2019040800.00; // YYYYMMDD = weekly release date of this DEV branch. // RR = release increments - 00 in DEV branches. // .XX = incremental changes.
{{filename}}{{time}}{{size}}{{# str }} download, core {{/ str }}{{# str }} restore, core {{/ str }} + + + + + {{resourcename}} +{{time}} + + + +