Merge branch 'MDL-68563-master' of git://github.com/lameze/moodle
authorAdrian Greeve <abgreeve@gmail.com>
Mon, 18 May 2020 03:34:38 +0000 (11:34 +0800)
committerAdrian Greeve <abgreeve@gmail.com>
Mon, 18 May 2020 03:34:38 +0000 (11:34 +0800)
194 files changed:
admin/cli/cfg.php
admin/settings/courses.php
admin/tool/mobile/classes/api.php
backup/backup.class.php
backup/controller/backup_controller.class.php
backup/controller/base_controller.class.php
backup/controller/restore_controller.class.php
backup/controller/tests/controller_test.php
backup/copy.php [new file with mode: 0644]
backup/copyprogress.php [new file with mode: 0644]
backup/externallib.php
backup/moodle2/backup_course_task.class.php
backup/moodle2/backup_final_task.class.php
backup/moodle2/backup_root_task.class.php
backup/moodle2/backup_settingslib.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_course_task.class.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_settingslib.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/moodle2_test.php
backup/tests/course_copy_test.php [new file with mode: 0644]
backup/tests/externallib_test.php [new file with mode: 0644]
backup/util/dbops/backup_controller_dbops.class.php
backup/util/dbops/restore_controller_dbops.class.php
backup/util/helper/async_helper.class.php
backup/util/helper/tests/async_helper_test.php
backup/util/includes/backup_includes.php
backup/util/plan/backup_plan.class.php
backup/util/plan/backup_task.class.php
backup/util/ui/amd/build/async_backup.min.js
backup/util/ui/amd/build/async_backup.min.js.map
backup/util/ui/amd/src/async_backup.js
backup/util/ui/classes/copy/copy.php [new file with mode: 0644]
backup/util/ui/classes/output/copy_form.php [new file with mode: 0644]
backup/util/ui/renderer.php
backup/util/ui/tests/behat/import_contentbank_content.feature [new file with mode: 0644]
blocks/moodleblock.class.php
completion/classes/api.php
contentbank/amd/build/search.min.js [new file with mode: 0644]
contentbank/amd/build/search.min.js.map [new file with mode: 0644]
contentbank/amd/build/selectors.min.js [new file with mode: 0644]
contentbank/amd/build/selectors.min.js.map [new file with mode: 0644]
contentbank/amd/src/search.js [new file with mode: 0644]
contentbank/amd/src/selectors.js [new file with mode: 0644]
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/classes/output/bankcontent.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/admin_upload_content.feature
contentbank/contenttype/h5p/tests/contenttype_h5p_test.php
contentbank/index.php
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/search.mustache [new file with mode: 0644]
contentbank/templates/bankcontent/toolbar.mustache [moved from contentbank/templates/toolbar.mustache with 91% similarity]
contentbank/tests/behat/delete_content.feature
contentbank/tests/behat/search_content.feature [new file with mode: 0644]
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/external/delete_content_test.php
contentbank/tests/external/rename_content_test.php
contentbank/tests/fixtures/testable_contenttype.php
contentbank/tests/privacy_test.php
contentbank/upload.php
contentbank/view.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/build/copy_modal.min.js [new file with mode: 0644]
course/amd/build/copy_modal.min.js.map [new file with mode: 0644]
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/src/activitychooser.js
course/amd/src/copy_modal.js [new file with mode: 0644]
course/amd/src/local/activitychooser/dialogue.js
course/classes/management/helper.php
course/lib.php
course/management.php
course/tests/externallib_test.php
course/view.php
h5p/classes/api.php
h5p/classes/editor.php
h5p/classes/editor_framework.php
h5p/classes/helper.php
h5p/classes/local/library/autoloader.php
h5p/classes/local/library/handler.php
h5p/h5plib/v124/joubel/editor/readme_moodle.txt
h5p/h5plib/v124/lang/en/h5plib_v124.php
h5p/tests/editor_test.php
h5p/tests/local/library/handler_test.php [new file with mode: 0644]
lang/en/backup.php
lang/en/contentbank.php
lang/en/moodle.php
lang/en/plugin.php
lib/accesslib.php
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/prefetch.min.js
lib/amd/build/prefetch.min.js.map
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/build/user_date.min.js
lib/amd/build/user_date.min.js.map
lib/amd/src/modal.js
lib/amd/src/prefetch.js
lib/amd/src/templates.js
lib/amd/src/user_date.js
lib/antivirus/clamav/adminlib.php
lib/antivirus/clamav/classes/scanner.php
lib/behat/classes/behat_core_generator.php
lib/classes/task/asynchronous_copy_task.php [new file with mode: 0644]
lib/classes/task/legacy_plugin_cron_task.php
lib/classes/update/validator.php
lib/cronlib.php
lib/db/services.php
lib/deprecatedlib.php
lib/external/externallib.php
lib/external/tests/external_test.php
lib/grouplib.php
lib/navigationlib.php
lib/outputlib.php
lib/outputrequirementslib.php
lib/pagelib.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/tablelib.php
lib/templates/async_backup_progress.mustache
lib/templates/async_copy_complete_cell.mustache [new file with mode: 0644]
lib/templates/time_element.mustache [new file with mode: 0644]
lib/testing/generator/block_generator.php
lib/testing/generator/module_generator.php
lib/testing/generator/repository_generator.php
lib/tests/accesslib_test.php
lib/tests/grouplib_test.php
lib/upgrade.txt
mod/assign/lib.php
mod/chat/lib.php
mod/choice/locallib.php
mod/data/locallib.php
mod/feedback/lib.php
mod/forum/classes/local/exporters/post.php
mod/forum/locallib.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_post.mustache
mod/forum/tests/generator/lib.php
mod/forum/view.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/local/grader.php [new file with mode: 0644]
mod/h5pactivity/classes/local/manager.php [new file with mode: 0644]
mod/h5pactivity/classes/privacy/provider.php
mod/h5pactivity/classes/xapi/handler.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/tests/behat/define_settings.feature [new file with mode: 0644]
mod/h5pactivity/tests/behat/grading_attempts.feature [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php
mod/h5pactivity/tests/event/course_module_viewed_test.php
mod/h5pactivity/tests/event/statement_received_test.php
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/generator_test.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/local/grader_test.php [new file with mode: 0644]
mod/h5pactivity/tests/local/manager_test.php [new file with mode: 0644]
mod/h5pactivity/tests/privacy_test.php
mod/h5pactivity/tests/restore_test.php
mod/h5pactivity/tests/xapi/handler_test.php
mod/h5pactivity/version.php
mod/h5pactivity/view.php
mod/lesson/lib.php
mod/quiz/lib.php
mod/scorm/locallib.php
mod/upgrade.txt
mod/workshop/lib.php
pix/i/contentbank.png [new file with mode: 0644]
pix/i/contentbank.svg [new file with mode: 0644]
report/configlog/classes/output/report_table.php
report/configlog/lang/en/report_configlog.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/tables.scss
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/classes/table/participants.php
user/classes/table/participants_search.php [new file with mode: 0644]
user/tests/table/participants_search_test.php [new file with mode: 0644]
version.php

index 7f56b92..35045ca 100644 (file)
@@ -124,7 +124,12 @@ if ($options['unset'] || $options['set'] !== null) {
         cli_error('The configuration variable is hard-set in the config.php, unable to change.', 4);
     }
 
-    set_config($options['name'], $options['set'], $options['component']);
+    $new = $options['set'];
+    $old = get_config($options['component'], $options['name']);
+    if ($new !== $old) {
+        set_config($options['name'], $options['set'], $options['component']);
+        add_to_config_log($options['name'], $old, $new, $options['component']);
+    }
     exit(0);
 }
 
index 841b72b..8936f7c 100644 (file)
@@ -235,6 +235,11 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             new lang_string('generalgroups', 'backup'), new lang_string('configgeneralgroups', 'backup'),
             array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), array('value'=>1, 'locked'=>0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        ['value' => 1, 'locked' => 0])
+    );
 
     $ADMIN->add('backups', $temp);
 
@@ -256,6 +261,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             new lang_string('generalgroups', 'backup'), new lang_string('configgeneralgroups', 'backup'),
             array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), array('value'=>1, 'locked'=>0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock(
+        'backup/backup_import_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        ['value' => 1, 'locked' => 0])
+    );
 
     $ADMIN->add('backups', $temp);
 
@@ -375,6 +386,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox('backup/backup_auto_groups', new lang_string('generalgroups', 'backup'),
             new lang_string('configgeneralgroups', 'backup'), 1));
     $temp->add(new admin_setting_configcheckbox('backup/backup_auto_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), 1));
+    $temp->add(new admin_setting_configcheckbox(
+        'backup/backup_auto_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        1)
+    );
 
     //$temp->add(new admin_setting_configcheckbox('backup/backup_auto_messages', new lang_string('messages', 'message'), new lang_string('backupmessageshelp','message'), 0));
     //$temp->add(new admin_setting_configcheckbox('backup/backup_auto_blogs', new lang_string('blogs', 'blog'), new lang_string('backupblogshelp','blog'), 0));
@@ -435,6 +452,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_competencies',
         new lang_string('generalcompetencies', 'backup'),
         new lang_string('configrestorecompetencies', 'backup'), array('value' => 1, 'locked' => 0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configrestorecontentbankcontent', 'backup'), array('value' => 1, 'locked' => 0)));
 
     // Restore defaults when merging into another course.
     $temp->add(new admin_setting_heading('mergerestoredefaults', new lang_string('mergerestoredefaults', 'backup'), ''));
index 914ef5f..e95cf83 100644 (file)
@@ -402,7 +402,8 @@ class api {
 
         $availablemods = core_plugin_manager::instance()->get_plugins_of_type('mod');
         $coursemodules = array();
-        $appsupportedmodules = array('assign', 'book', 'chat', 'choice', 'data', 'feedback', 'folder', 'forum', 'glossary', 'imscp',
+        $appsupportedmodules = array(
+            'assign', 'book', 'chat', 'choice', 'data', 'feedback', 'folder', 'forum', 'glossary', 'h5pactivity', 'imscp',
             'label', 'lesson', 'lti', 'page', 'quiz', 'resource', 'scorm', 'survey', 'url', 'wiki', 'workshop');
 
         foreach ($availablemods as $mod) {
@@ -425,6 +426,7 @@ class api {
         $courseblocks = array();
         $appsupportedblocks = array(
             'activity_modules' => 'CoreBlockDelegate_AddonBlockActivityModules',
+            'activity_results' => 'CoreBlockDelegate_AddonBlockActivityResults',
             'site_main_menu' => 'CoreBlockDelegate_AddonBlockSiteMainMenu',
             'myoverview' => 'CoreBlockDelegate_AddonBlockMyOverview',
             'timeline' => 'CoreBlockDelegate_AddonBlockTimeline',
@@ -468,6 +470,7 @@ class api {
                 'NoDelegate_ResponsiveMainMenuItems' => new lang_string('responsivemainmenuitems', 'tool_mobile'),
                 'NoDelegate_H5POffline' => new lang_string('h5poffline', 'tool_mobile'),
                 'NoDelegate_DarkMode' => new lang_string('darkmode', 'tool_mobile'),
+                'CoreFilterDelegate' => new lang_string('type_filter_plural', 'plugin'),
             ),
             "$mainmenu" => array(
                 '$mmSideMenuDelegate_mmaFrontpage' => new lang_string('sitehome'),
index 260058d..034c1bf 100644 (file)
@@ -85,6 +85,15 @@ abstract class backup implements checksumable {
      */
     const MODE_ASYNC = 70;
 
+    /**
+     * This mode is for course copies.
+     * It is similar to async, but identifies back up and restore tasks
+     * as course copies.
+     *
+     * These copies will run via adhoc scheduled tasks.
+     */
+    const MODE_COPY = 80;
+
     // Target (new/existing/current/adding/deleting)
     const TARGET_CURRENT_DELETING = 0;
     const TARGET_CURRENT_ADDING   = 1;
index c2c7fdd..781e8cb 100644 (file)
@@ -70,6 +70,12 @@ class backup_controller extends base_controller {
 
     protected $checksum; // Cache @checksumable results for lighter @is_checksum_correct() uses
 
+    /**
+     * The role ids to keep in a copy operation.
+     * @var array
+     */
+    protected $keptroles = array();
+
     /**
      * Constructor for the backup controller class.
      *
@@ -97,7 +103,7 @@ class backup_controller extends base_controller {
         $this->checksum = '';
 
         // Set execution based on backup mode.
-        if ($mode == backup::MODE_ASYNC) {
+        if ($mode == backup::MODE_ASYNC || $mode == backup::MODE_COPY) {
             $this->execution = backup::EXECUTION_DELAYED;
         } else {
             $this->execution = backup::EXECUTION_INMEDIATE;
@@ -291,7 +297,7 @@ class backup_controller extends base_controller {
 
         // When a backup is intended for the same site, we don't need to include the files.
         // Note, this setting is only used for duplication of an entire course.
-        if ($this->get_mode() === backup::MODE_SAMESITE) {
+        if ($this->get_mode() === backup::MODE_SAMESITE || $this->get_mode() === backup::MODE_COPY) {
             $includefiles = false;
         }
 
@@ -352,6 +358,22 @@ class backup_controller extends base_controller {
         return $this->plan;
     }
 
+    /**
+     * Sets the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @param array $roleids
+     * @throws backup_controller_exception
+     */
+    public function set_kept_roles(array $roleids): void {
+        // Only allow of keeping user roles when controller is in copy mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new backup_controller_exception('cannot_set_keep_roles_wrong_mode');
+        }
+
+        $this->keptroles = $roleids;
+    }
+
     /**
      * Executes the backup
      * @return void Throws and exception of completes
@@ -379,6 +401,12 @@ class backup_controller extends base_controller {
             $this->log('notifying plan about excluded activities by type', backup::LOG_DEBUG);
             $this->plan->set_excluding_activities();
         }
+
+        // Handle copy operation specific settings.
+        if ($this->mode == backup::MODE_COPY) {
+            $this->plan->set_kept_roles($this->keptroles);
+        }
+
         return $this->plan->execute();
     }
 
index 32aa06c..8e8d0d1 100644 (file)
@@ -36,6 +36,13 @@ abstract class base_controller extends backup implements loggable {
     /** @var bool Whether this backup should release the session. */
     protected $releasesession = backup::RELEASESESSION_NO;
 
+    /**
+     * Holds the relevant destination information for course copy operations.
+     *
+     * @var \stdClass.
+     */
+    protected $copy;
+
     /**
      * Gets the progress reporter, which can be used to report progress within
      * the backup or restore process.
@@ -95,4 +102,30 @@ abstract class base_controller extends backup implements loggable {
     public function get_releasesession() {
         return $this->releasesession;
     }
+
+    /**
+     * Store extra data for course copy operations.
+     *
+     * For a course copying these is data required to be passed to the restore step.
+     * We store this data in its own section of the backup controller
+     *
+     * @param \stdClass $data The course copy data.
+     * @throws backup_controller_exception
+     */
+    public function set_copy(\stdClass $data): void {
+        // Only allow setting of copy data when controller is in copy mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new backup_controller_exception('cannot_set_copy_vars_wrong_mode');
+        }
+        $this->copy = $data;
+    }
+
+    /**
+     * Get the course copy data.
+     *
+     * @return \stdClass
+     */
+    public function get_copy(): \stdClass {
+        return $this->copy;
+    }
 }
index cf37e55..39c4116 100644 (file)
@@ -116,7 +116,7 @@ class restore_controller extends base_controller {
         $this->logger = backup_factory::get_logger_chain($this->interactive, $this->execution, $this->restoreid);
 
         // Set execution based on backup mode.
-        if ($mode == backup::MODE_ASYNC) {
+        if ($mode == backup::MODE_ASYNC || $mode == backup::MODE_COPY) {
             $this->execution = backup::EXECUTION_DELAYED;
         } else {
             $this->execution = backup::EXECUTION_INMEDIATE;
@@ -529,6 +529,30 @@ class restore_controller extends base_controller {
         $this->progress->end_progress();
     }
 
+    /**
+     * Do the necessary copy preparation actions.
+     * This method should only be called once the backup of a copy operation is completed.
+     *
+     * @throws restore_controller_exception
+     */
+    public function prepare_copy(): void {
+        // Check that we are in the correct mode.
+        if ($this->mode != backup::MODE_COPY) {
+            throw new restore_controller_exception('cannot_prepare_copy_wrong_mode');
+        }
+
+        $this->progress->start_progress('Prepare Copy');
+
+        // If no exceptions were thrown, then we are in the proper format.
+        $this->format = backup::FORMAT_MOODLE;
+
+        // Load plan, apply security and set status based on interactivity.
+        $this->load_plan();
+
+        $this->set_status(backup::STATUS_NEED_PRECHECK);
+        $this->progress->end_progress();
+    }
+
 // Protected API starts here
 
     protected function calculate_restoreid() {
index 0488185..f638be0 100644 (file)
@@ -60,6 +60,20 @@ class core_backup_controller_testcase extends advanced_testcase {
         $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
     }
 
+    /**
+     * Test set copy method.
+     */
+    public function test_base_controller_set_copy() {
+        $this->expectException(\backup_controller_exception::class);
+        $copy = new \stdClass();
+
+        // Set up controller as a non-copy operation.
+        $bc = new \backup_controller(backup::TYPE_1COURSE, $this->courseid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_GENERAL, $this->userid, backup::RELEASESESSION_YES);
+
+        $bc->set_copy($copy);
+    }
+
     /*
      * test base_setting class
      */
@@ -103,6 +117,19 @@ class core_backup_controller_testcase extends advanced_testcase {
         $this->assertEquals($bc->get_include_files(), 0);
     }
 
+    /**
+     * Test set kept roles method.
+     */
+    public function test_backup_controller_set_kept_roles() {
+        $this->expectException(\backup_controller_exception::class);
+
+        // Set up controller as a non-copy operation.
+        $bc = new \backup_controller(backup::TYPE_1COURSE, $this->courseid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_GENERAL, $this->userid, backup::RELEASESESSION_YES);
+
+        $bc->set_kept_roles(array(1, 3, 5));
+    }
+
     /**
      * Tests the restore_controller.
      */
@@ -150,6 +177,29 @@ class core_backup_controller_testcase extends advanced_testcase {
         $this->assertTrue($alltrue);
     }
 
+    /**
+     * Test prepare copy method.
+     */
+    public function test_restore_controller_prepare_copy() {
+        $this->expectException(\restore_controller_exception::class);
+
+        global $CFG;
+
+        // Make a backup.
+        make_backup_temp_directory('');
+        $bc = new backup_controller(backup::TYPE_1ACTIVITY, $this->moduleid, backup::FORMAT_MOODLE,
+            backup::INTERACTIVE_NO, backup::MODE_IMPORT, $this->userid);
+        $backupid = $bc->get_backupid();
+        $bc->execute_plan();
+        $bc->destroy();
+
+        // Set up restore.
+        $rc = new restore_controller($backupid, $this->courseid,
+            backup::INTERACTIVE_NO, backup::MODE_SAMESITE, $this->userid,
+            backup::TARGET_EXISTING_ADDING);
+        $rc->prepare_copy();
+    }
+
     /**
      * Test restore of deadlock causing backup.
      */
diff --git a/backup/copy.php b/backup/copy.php
new file mode 100644 (file)
index 0000000..6243090
--- /dev/null
@@ -0,0 +1,95 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * This script is used to configure and execute the course copy proccess.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+
+defined('MOODLE_INTERNAL') || die();
+
+$courseid = required_param('id', PARAM_INT);
+$returnto = optional_param('returnto', 'course', PARAM_ALPHANUM); // Generic navigation return page switch.
+$returnurl = optional_param('returnurl', '', PARAM_LOCALURL); // A return URL. returnto must also be set to 'url'.
+
+$url = new moodle_url('/backup/copy.php', array('id' => $courseid));
+$course = get_course($courseid);
+$coursecontext = context_course::instance($course->id);
+
+// Security and access checks.
+require_login($course, false);
+$copycaps = \core_course\management\helper::get_course_copy_capabilities();
+require_all_capabilities($copycaps, $coursecontext);
+
+if ($returnurl != '') {
+    $returnurl = new moodle_url($returnurl);
+} else if ($returnto == 'catmanage') {
+    // Redirect to category management page.
+    $returnurl = new moodle_url('/course/management.php', array('categoryid' => $course->category));
+} else {
+    // Redirect back to course page if we came from there.
+    $returnurl = new moodle_url('/course/view.php', array('id' => $courseid));
+}
+
+// Setup the page.
+$title = get_string('copycoursetitle', 'backup', $course->shortname);
+$heading = get_string('copycourseheading', 'backup');
+$PAGE->set_url($url);
+$PAGE->set_pagelayout('admin');
+$PAGE->set_title($title);
+$PAGE->set_heading($heading);
+
+// Get data ready for mform.
+$mform = new \core_backup\output\copy_form(
+    $url,
+    array('course' => $course, 'returnto' => $returnto, 'returnurl' => $returnurl));
+
+if ($mform->is_cancelled()) {
+    // The form has been cancelled, take them back to what ever the return to is.
+    redirect($returnurl);
+
+} else if ($mdata = $mform->get_data()) {
+
+    // Process the form and create the copy task.
+    $backupcopy = new \core_backup\copy\copy($mdata);
+    $backupcopy->create_copy();
+
+    if (!empty($mdata->submitdisplay)) {
+        // Redirect to the copy progress overview.
+        $progressurl = new moodle_url('/backup/copyprogress.php', array('id' => $courseid));
+        redirect($progressurl);
+    } else {
+        // Redirect to the course view page.
+        $coursesurl = new moodle_url('/course/view.php', array('id' => $courseid));
+        redirect($coursesurl);
+    }
+
+} else {
+    // This branch is executed if the form is submitted but the data doesn't validate,
+    // or on the first display of the form.
+
+    // Build the page output.
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading($title);
+    $mform->display();
+    echo $OUTPUT->footer();
+}
diff --git a/backup/copyprogress.php b/backup/copyprogress.php
new file mode 100644 (file)
index 0000000..d43db02
--- /dev/null
@@ -0,0 +1,59 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * This script is used to configure and execute the course copy proccess.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once('../config.php');
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+defined('MOODLE_INTERNAL') || die();
+
+$courseid = required_param('id', PARAM_INT);
+
+$url = new moodle_url('/backup/copyprogress.php', array('id' => $courseid));
+$course = get_course($courseid);
+$coursecontext = context_course::instance($course->id);
+
+// Security and access checks.
+require_login($course, false);
+$copycaps = \core_course\management\helper::get_course_copy_capabilities();
+require_all_capabilities($copycaps, $coursecontext);
+
+// Setup the page.
+$title = get_string('copyprogresstitle', 'backup');
+$PAGE->set_url($url);
+$PAGE->set_pagelayout('admin');
+$PAGE->set_title($title);
+$PAGE->set_heading($title);
+$PAGE->requires->js_call_amd('core_backup/async_backup', 'asyncCopyAllStatus');
+
+// Build the page output.
+echo $OUTPUT->header();
+echo $OUTPUT->heading_with_help(get_string('copyprogressheading', 'backup'), 'copyprogressheading', 'backup');
+echo $OUTPUT->container_start();
+$renderer = $PAGE->get_renderer('core', 'backup');
+echo $renderer->copy_progress_viewer($USER->id, $courseid);
+echo $OUTPUT->container_end();
+
+echo $OUTPUT->footer();
index dd70910..e05b9db 100644 (file)
@@ -28,6 +28,7 @@ defined('MOODLE_INTERNAL') || die;
 
 require_once("$CFG->libdir/externallib.php");
 require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
 
 /**
  * Backup external functions.
@@ -67,10 +68,6 @@ class core_backup_external extends external_api {
      * @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');
-
         // Release session lock.
         \core\session\manager::write_close();
 
@@ -224,7 +221,12 @@ class core_backup_external extends external_api {
                 );
 
         // Context validation.
-        $context = context::instance_by_id($contextid);
+        if ($contextid == 0) {
+            $copyrec = \async_helper::get_backup_record($backupid);
+            $context = context_course::instance($copyrec->itemid);
+        } else {
+            $context = context::instance_by_id($contextid);
+        }
         self::validate_context($context);
         require_capability('moodle/restore:restorecourse', $context);
 
@@ -245,4 +247,163 @@ class core_backup_external extends external_api {
                     'restoreurl' => new external_value(PARAM_URL, 'Restore url'),
                 ), 'Table row data.');
     }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress_parameters() {
+        return new external_function_parameters(
+            array(
+                'copies' => new external_multiple_structure(
+                    new external_single_structure(
+                        array(
+                            'backupid' => new external_value(PARAM_ALPHANUM, 'Backup id'),
+                            'restoreid' => new external_value(PARAM_ALPHANUM, 'Restore id'),
+                            'operation' => new external_value(PARAM_ALPHANUM, 'Operation type'),
+                        ), 'Copy data'
+                    ), 'Copy data'
+                ),
+            )
+        );
+    }
+
+    /**
+     * Get the data to be used when generating the table row for a course copy,
+     * the table row updates via ajax when copy is complete.
+     *
+     * @param array $copies Array of ids.
+     * @return array $results The array of results.
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress($copies) {
+        // Release session lock.
+        \core\session\manager::write_close();
+
+        // Parameter validation.
+        self::validate_parameters(
+            self::get_copy_progress_parameters(),
+            array('copies' => $copies)
+            );
+
+        $results = array();
+
+        foreach ($copies as $copy) {
+
+            if ($copy['operation'] == \backup::OPERATION_BACKUP) {
+                $copyid = $copy['backupid'];
+            } else {
+                $copyid = $copy['restoreid'];
+            }
+
+            $copyrec = \async_helper::get_backup_record($copyid);
+            $context = context_course::instance($copyrec->itemid);
+            self::validate_context($context);
+
+            $copycaps = \core_course\management\helper::get_course_copy_capabilities();
+            require_all_capabilities($copycaps, $context);
+
+            if ($copy['operation'] == \backup::OPERATION_BACKUP) {
+                $result = \backup_controller_dbops::get_progress($copyid);
+                if ($result['status'] == \backup::STATUS_FINISHED_OK) {
+                    $copyid = $copy['restoreid'];
+                }
+            }
+
+            $results[] = \backup_controller_dbops::get_progress($copyid);
+        }
+
+        return $results;
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     * @since Moodle 3.9
+     */
+    public static function get_copy_progress_returns() {
+        return new external_multiple_structure(
+            new external_single_structure(
+                array(
+                    'status'   => new external_value(PARAM_INT, 'Copy Status'),
+                    'progress' => new external_value(PARAM_FLOAT, 'Copy progress'),
+                    'backupid' => new external_value(PARAM_ALPHANUM, 'Copy id'),
+                    'operation' => new external_value(PARAM_ALPHANUM, 'Operation type'),
+                ), 'Copy completion status'
+            ), 'Copy data'
+        );
+    }
+
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.9
+     */
+    public static function submit_copy_form_parameters() {
+        return new external_function_parameters(
+            array(
+                'jsonformdata' => new external_value(PARAM_RAW, 'The data from the create copy form, encoded as a json array')
+            )
+        );
+    }
+
+    /**
+     * Submit the course group form.
+     *
+     * @param string $jsonformdata The data from the form, encoded as a json array.
+     * @return int new group id.
+     */
+    public static function submit_copy_form($jsonformdata) {
+
+        // Release session lock.
+        \core\session\manager::write_close();
+
+        // We always must pass webservice params through validate_parameters.
+        $params = self::validate_parameters(
+            self::submit_copy_form_parameters(),
+            array('jsonformdata' => $jsonformdata)
+            );
+
+        $formdata = json_decode($params['jsonformdata']);
+
+        $data = array();
+        parse_str($formdata, $data);
+
+        $context = context_course::instance($data['courseid']);
+        self::validate_context($context);
+        $copycaps = \core_course\management\helper::get_course_copy_capabilities();
+        require_all_capabilities($copycaps, $context);
+
+        // Submit the form data.
+        $course = get_course($data['courseid']);
+        $mform = new \core_backup\output\copy_form(
+            null,
+            array('course' => $course, 'returnto' => '', 'returnurl' => ''),
+            'post', '', ['class' => 'ignoredirty'], true, $data);
+        $mdata = $mform->get_data();
+
+        if ($mdata) {
+            // Create the copy task.
+            $backupcopy = new \core_backup\copy\copy($mdata);
+            $copyids = $backupcopy->create_copy();
+        } else {
+            throw new moodle_exception('copyformfail', 'backup');
+        }
+
+        return json_encode($copyids);
+    }
+
+    /**
+     * Returns description of method result value.
+     *
+     * @return external_description
+     * @since Moodle 3.9
+     */
+    public static function submit_copy_form_returns() {
+        return new external_value(PARAM_RAW, 'JSON response.');
+    }
 }
index d9060e3..d2ac8c5 100644 (file)
@@ -139,6 +139,11 @@ class backup_course_task extends backup_task {
         // Migrate the already exported inforef entries to final ones
         $this->add_step(new move_inforef_annotations_to_final('migrate_inforef'));
 
+        // Generate the content bank file (conditionally).
+        if ($this->get_setting_value('contentbankcontent')) {
+            $this->add_step(new backup_contentbankcontent_structure_step('course_contentbank', 'contentbank.xml'));
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index 6f69c59..affe6bf 100644 (file)
@@ -69,14 +69,14 @@ class backup_final_task extends backup_task {
         // This step also ensures that the contexts for all the users exist, so next
         // step can be safely executed (join between users and contexts)
         // Not executed if backup is without users of anonymized
-        if ($this->get_setting_value('users') && !$this->get_setting_value('anonymize')) {
+        if (($this->get_setting_value('users') || !empty($this->get_kept_roles())) && !$this->get_setting_value('anonymize')) {
             $this->add_step(new backup_annotate_all_user_files('user_files'));
         }
 
         // Generate the users file (conditionally) with the final annotated users
         // including custom profile fields, preferences, tags, role assignments and
         // overrides
-        if ($this->get_setting_value('users')) {
+        if ($this->get_setting_value('users') || !empty($this->get_kept_roles())) {
             $this->add_step(new backup_users_structure_step('users', 'users.xml'));
         }
 
index ee434c1..4771c39 100644 (file)
@@ -179,5 +179,10 @@ class backup_root_task extends backup_task {
         $customfields = new backup_customfield_setting('customfield', base_setting::IS_BOOLEAN, true);
         $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
         $this->add_setting($customfields);
+
+        // Define content bank content inclusion setting.
+        $contentbank = new backup_contentbankcontent_setting('contentbankcontent', base_setting::IS_BOOLEAN, true);
+        $contentbank->set_ui(new backup_setting_ui_checkbox($contentbank, get_string('rootsettingcontentbankcontent', 'backup')));
+        $this->add_setting($contentbank);
     }
 }
index 9125673..59ea252 100644 (file)
@@ -199,3 +199,9 @@ class backup_activity_included_setting extends activity_backup_setting {}
  * user information or no, depends of @backup_users_setting
  */
 class backup_activity_userinfo_setting extends activity_backup_setting {}
+
+/**
+ * Root setting to control if backup will include content bank content or no
+ */
+class backup_contentbankcontent_setting extends backup_generic_setting {
+}
index ac8c917..c1372cc 100644 (file)
@@ -510,9 +510,11 @@ class backup_enrolments_structure_step extends backup_structure_step {
     }
 
     protected function define_structure() {
+        global $DB;
 
         // To know if we are including users
         $users = $this->get_setting_value('users');
+        $keptroles = $this->task->get_kept_roles();
 
         // Define each element separated
 
@@ -545,10 +547,28 @@ class backup_enrolments_structure_step extends backup_structure_step {
         // Define sources - the instances are restored using the same sortorder, we do not need to store it in xml and deal with it afterwards.
         $enrol->set_source_table('enrol', array('courseid' => backup::VAR_COURSEID), 'sortorder ASC');
 
-        // User enrolments only added only if users included
-        if ($users) {
+        // User enrolments only added only if users included.
+        if (empty($keptroles) && $users) {
             $enrolment->set_source_table('user_enrolments', array('enrolid' => backup::VAR_PARENTID));
             $enrolment->annotate_ids('user', 'userid');
+        } else if (!empty($keptroles)) {
+            list($insql, $inparams) = $DB->get_in_or_equal($keptroles);
+            $params = array(
+                backup::VAR_CONTEXTID,
+                backup::VAR_PARENTID
+            );
+            foreach ($inparams as $inparam) {
+                $params[] = backup_helper::is_sqlparam($inparam);
+            }
+            $enrolment->set_source_sql(
+               "SELECT ue.*
+                  FROM {user_enrolments} ue
+            INNER JOIN {role_assignments} ra ON ue.userid = ra.userid
+                 WHERE ra.contextid = ?
+                       AND ue.enrolid = ?
+                       AND ra.roleid $insql",
+                $params);
+            $enrolment->annotate_ids('user', 'userid');
         }
 
         $enrol->annotate_ids('role', 'roleid');
@@ -1451,7 +1471,6 @@ class backup_users_structure_step extends backup_structure_step {
             // Define id annotations (as final)
             $override->annotate_ids('rolefinal', 'roleid');
         }
-
         // Return root element (users)
         return $users;
     }
@@ -2740,3 +2759,34 @@ class backup_completion_defaults_structure_step extends backup_structure_step {
 
     }
 }
+
+/**
+ * Structure step in charge of constructing the contentbank.xml file for all the contents found in a given context
+ */
+class backup_contentbankcontent_structure_step extends backup_structure_step {
+
+    /**
+     * Define structure for content bank step
+     */
+    protected function define_structure() {
+
+        // Define each element separated.
+        $contents = new backup_nested_element('contents');
+        $content = new backup_nested_element('content', ['id'], [
+            'name', 'contenttype', 'instanceid', 'configdata', 'usercreated', 'usermodified', 'timecreated', 'timemodified']);
+
+        // Build the tree.
+        $contents->add_child($content);
+
+        // Define sources.
+        $content->set_source_table('contentbank_content', ['contextid' => backup::VAR_CONTEXTID]);
+
+        // Define annotations.
+        $content->annotate_ids('user', 'usercreated');
+        $content->annotate_ids('user', 'usermodified');
+        $content->annotate_files('contentbank', 'public', 'id');
+
+        // Return the root element (contents).
+        return $contents;
+    }
+}
index 6bcf826..be4f3f9 100644 (file)
@@ -126,6 +126,11 @@ class restore_course_task extends restore_task {
         // Activity completion defaults.
         $this->add_step(new restore_completion_defaults_structure_step('course_completion_defaults', 'completiondefaults.xml'));
 
+        // Content bank content (conditionally).
+        if ($this->get_setting_value('contentbankcontent')) {
+            $this->add_step(new restore_contentbankcontent_structure_step('course_contentbank', 'contentbank.xml'));
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index 3069a0b..632985f 100644 (file)
@@ -290,5 +290,17 @@ class restore_root_task extends restore_task {
         $customfields = new restore_customfield_setting('customfields', base_setting::IS_BOOLEAN, $defaultvalue);
         $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
         $this->add_setting($customfields);
+
+        // Define Content bank content.
+        $defaultvalue = false;
+        $changeable = false;
+        if (isset($rootsettings['contentbankcontent']) && $rootsettings['contentbankcontent']) { // Only enabled when available.
+            $defaultvalue = true;
+            $changeable = true;
+        }
+        $contents = new restore_contentbankcontent_setting('contentbankcontent', base_setting::IS_BOOLEAN, $defaultvalue);
+        $contents->set_ui(new backup_setting_ui_checkbox($contents, get_string('rootsettingcontentbankcontent', 'backup')));
+        $contents->get_ui()->set_changeable($changeable);
+        $this->add_setting($contents);
     }
 }
index daa3fec..61124de 100644 (file)
@@ -236,3 +236,9 @@ class restore_activity_included_setting extends restore_activity_generic_setting
  * user information or no, depends of @restore_users_setting
  */
 class restore_activity_userinfo_setting extends restore_activity_generic_setting {}
+
+/**
+ * root setting to control if restore will create content bank content or no
+ */
+class restore_contentbankcontent_setting extends restore_generic_setting {
+}
index 10199e0..223b849 100644 (file)
@@ -3984,6 +3984,60 @@ class restore_activity_grade_history_structure_step extends restore_structure_st
     }
 }
 
+/**
+ * This structure steps restores the content bank content
+ */
+class restore_contentbankcontent_structure_step extends restore_structure_step {
+
+    /**
+     * Define structure for content bank step
+     */
+    protected function define_structure() {
+
+        $paths = [];
+        $paths[] = new restore_path_element('contentbankcontent', '/contents/content');
+
+        return $paths;
+    }
+
+    /**
+     * Define data processed for content bank
+     *
+     * @param mixed  $data
+     */
+    public function process_contentbankcontent($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $oldid = $data->id;
+
+        $params = [
+            'name'           => $data->name,
+            'contextid'      => $this->task->get_contextid(),
+            'contenttype'    => $data->contenttype,
+            'instanceid'     => $data->instanceid,
+            'timecreated'    => $data->timecreated,
+        ];
+        $exists = $DB->record_exists('contentbank_content', $params);
+        if (!$exists) {
+            $params['configdata'] = $data->configdata;
+            $params['usercreated'] = $this->get_mappingid('user', $data->usercreated);
+            $params['usermodified'] = $this->get_mappingid('user', $data->usermodified);
+            $params['timemodified'] = time();
+            $newitemid = $DB->insert_record('contentbank_content', $params);
+            $this->set_mapping('contentbank_content', $oldid, $newitemid, true);
+        }
+    }
+
+    /**
+     * Define data processed after execute for content bank
+     */
+    protected function after_execute() {
+        // Add related files.
+        $this->add_related_files('contentbank', 'public', 'contentbank_content');
+    }
+}
+
 /**
  * This structure steps restores one instance + positions of one block
  * Note: Positions corresponding to one existing context are restored
index 6649934..b4fed99 100644 (file)
@@ -1063,4 +1063,32 @@ class core_backup_moodle2_testcase extends advanced_testcase {
             }
         }
     }
+
+    /**
+     * Test the content bank content through a backup and restore.
+     */
+    public function test_contentbank_content_backup() {
+        global $DB, $USER, $CFG;
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $cbgenerator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+
+        // Create course and add content bank content.
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/filltheblanks.h5p';
+        $contents = $cbgenerator->generate_contentbank_data('contenttype_h5p', 2, $USER->id, $context, true, $filepath);
+        $this->assertEquals(2, $DB->count_records('contentbank_content'));
+
+        // Do backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+
+        // Confirm that values were transferred correctly into content bank on new course.
+        $newcontext = context_course::instance($newcourseid);
+
+        $this->assertEquals(4, $DB->count_records('contentbank_content'));
+        $this->assertEquals(2, $DB->count_records('contentbank_content', ['contextid' => $newcontext->id]));
+    }
 }
diff --git a/backup/tests/course_copy_test.php b/backup/tests/course_copy_test.php
new file mode 100644 (file)
index 0000000..906dbfe
--- /dev/null
@@ -0,0 +1,636 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Course copy tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @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');
+
+/**
+ * Course copy tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_backup_course_copy_testcase extends advanced_testcase {
+
+    /**
+     *
+     * @var \stdClass Course used for testing.
+     */
+    protected $course;
+
+    /**
+     *
+     * @var int User used to perform backups.
+     */
+    protected $userid;
+
+    /**
+     *
+     * @var array Ids of users in test course.
+     */
+    protected $courseusers;
+
+    /**
+     *
+     * @var array Names of the created activities.
+     */
+    protected $activitynames;
+
+    /**
+     * Set up tasks for all tests.
+     */
+    protected function setUp() {
+        global $DB, $CFG, $USER;
+
+        $this->resetAfterTest(true);
+
+        $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!'));
+
+        // Create some users.
+        $user1 = $generator->create_user();
+        $user2 = $generator->create_user();
+        $user3 = $generator->create_user();
+        $user4 = $generator->create_user();
+        $this->courseusers = array(
+            $user1->id, $user2->id, $user3->id, $user4->id
+        );
+
+        // Enrol users into the course.
+        $generator->enrol_user($user1->id, $course->id, 'student');
+        $generator->enrol_user($user2->id, $course->id, 'editingteacher');
+        $generator->enrol_user($user3->id, $course->id, 'manager');
+        $generator->enrol_user($user4->id, $course->id, 'editingteacher');
+        $generator->enrol_user($user4->id, $course->id, 'manager');
+
+        $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));
+
+        // Add some user data to the course.
+        $discussion = $generator->get_plugin_generator('mod_forum')->create_discussion(['course' => $course->id,
+            'forum' => $forum->id, 'userid' => $user1->id, 'timemodified' => time(),
+            'name' => 'Frog']);
+        $generator->get_plugin_generator('mod_forum')->create_post(['discussion' => $discussion->id, 'userid' => $user1->id]);
+
+        $this->course  = $course;
+        $this->userid = $USER->id; // Admin.
+        $this->activitynames = array(
+            $forum->name,
+            $forum2->name,
+            $assignrow->name
+        );
+
+        // Set the user doing the backup to be a manager in the course.
+        // By default Managers can restore courses AND users, teachers can only do users.
+        $this->setUser($user3);
+
+        // Disable all loggers.
+        $CFG->backup_error_log_logger_level = backup::LOG_NONE;
+        $CFG->backup_output_indented_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+        $CFG->backup_database_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
+    }
+
+    /**
+     * Test creating a course copy.
+     */
+    public function test_create_copy() {
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $result = $coursecopy->create_copy();
+
+        // Load the controllers, to extract the data we need.
+        $bc = \backup_controller::load_controller($result['backupid']);
+        $rc = \restore_controller::load_controller($result['restoreid']);
+
+        // Check the backup controller.
+        $this->assertEquals($result, $bc->get_copy()->copyids);
+        $this->assertEquals(backup::MODE_COPY, $bc->get_mode());
+        $this->assertEquals($this->course->id, $bc->get_courseid());
+        $this->assertEquals(backup::TYPE_1COURSE, $bc->get_type());
+
+        // Check the restore controller.
+        $newcourseid = $rc->get_courseid();
+        $newcourse = get_course($newcourseid);
+
+        $this->assertEquals($result, $rc->get_copy()->copyids);
+        $this->assertEquals(get_string('copyingcourse', 'backup'), $newcourse->fullname);
+        $this->assertEquals(get_string('copyingcourseshortname', 'backup'), $newcourse->shortname);
+        $this->assertEquals(backup::MODE_COPY, $rc->get_mode());
+        $this->assertEquals($newcourseid, $rc->get_courseid());
+
+        // Check the created ad-hoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $this->assertEquals($result, (array)$task->get_custom_data());
+        $this->assertFalse($task->is_blocking());
+
+        \core\task\manager::adhoc_task_complete($task);
+    }
+
+    /**
+     * Test getting the current copies.
+     */
+    public function test_get_copies() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $formdata2 = clone($formdata);
+        $formdata2->shortname = 'tree';
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $result = $coursecopy->create_copy();
+
+        // Backup, awaiting.
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_AWAITING, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $copies[0]->operation);
+
+        $bc = \backup_controller::load_controller($result['backupid']);
+
+        // Backup, in progress.
+        $bc->set_status(\backup::STATUS_EXECUTING);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_EXECUTING, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $copies[0]->operation);
+
+        // Restore, ready to process.
+        $bc->set_status(\backup::STATUS_FINISHED_OK);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEquals($result['backupid'], $copies[0]->backupid);
+        $this->assertEquals($result['restoreid'], $copies[0]->restoreid);
+        $this->assertEquals(\backup::STATUS_REQUIRE_CONV, $copies[0]->status);
+        $this->assertEquals(\backup::OPERATION_RESTORE, $copies[0]->operation);
+
+        // No records.
+        $bc->set_status(\backup::STATUS_FINISHED_ERR);
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEmpty($copies);
+
+        $coursecopy2 = new \core_backup\copy\copy($formdata2);
+        $result2 = $coursecopy2->create_copy();
+        // Set the second copy to be complete.
+        $bc = \backup_controller::load_controller($result2['backupid']);
+        $bc->set_status(\backup::STATUS_FINISHED_OK);
+        // Set the restore to be finished.
+        $rc = \backup_controller::load_controller($result2['restoreid']);
+        $rc->set_status(\backup::STATUS_FINISHED_OK);
+
+        // No records.
+        $copies = \core_backup\copy\copy::get_copies($USER->id);
+        $this->assertEmpty($copies);
+    }
+
+    /**
+     * Test getting the current copies for specific course.
+     */
+    public function test_get_copies_course() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $coursecopy->create_copy();
+
+        // No copies match this course id.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, ($this->course->id + 1));
+        $this->assertEmpty($copies);
+    }
+
+    /**
+     * Test getting the current copies if course has been deleted.
+     */
+    public function test_get_copies_course_deleted() {
+        global $USER;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = '';
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create some copies.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $coursecopy->create_copy();
+
+        delete_course($this->course->id, false);
+
+        // No copies match this course id as it has been deleted.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, ($this->course->id));
+        $this->assertEmpty($copies);
+    }
+
+    /*
+     * Test course copy.
+     */
+    public function test_course_copy() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postbackuprec = $DB->get_record('backup_controllers', array('backupid' => $copyids['backupid']));
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check backup was completed successfully.
+        $this->assertEquals(backup::STATUS_FINISHED_OK, $postbackuprec->status);
+        $this->assertEquals(1.0, $postbackuprec->progress);
+
+        // Check restore was completed successfully.
+        $this->assertEquals(backup::STATUS_FINISHED_OK, $postrestorerec->status);
+        $this->assertEquals(1.0, $postrestorerec->progress);
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        foreach ($this->courseusers as $user) {
+            $this->assertEquals($user, $users[$user]->id);
+        }
+
+        $this->assertEquals(count($this->courseusers), count($users));
+        $this->assertEquals(2, count($discussions));
+    }
+
+    /*
+     * Test course copy, not including any users (or data).
+     */
+    public function test_course_copy_no_users() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 0;
+        $formdata->role_3 = 0;
+        $formdata->role_5 = 0;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user that made them wasn't included.
+        $this->assertEquals(0, count($discussions));
+
+        // There should only be one user in the new course, and that's the user who did the copy.
+        $this->assertEquals(1, count($users));
+        $this->assertEquals($this->courseusers[2], $users[$this->courseusers[2]]->id);
+
+    }
+
+    /*
+     * Test course copy, including students and their data.
+     */
+    public function test_course_copy_students_data() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 0;
+        $formdata->role_3 = 0;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user that made them wasn't included.
+        $this->assertEquals(2, count($discussions));
+
+        // There should only be two users in the new course. The copier and one student.
+        $this->assertEquals(2, count($users));
+        $this->assertEquals($this->courseusers[2], $users[$this->courseusers[2]]->id);
+        $this->assertEquals($this->courseusers[0], $users[$this->courseusers[0]]->id);
+    }
+
+    /*
+     * Test course copy, not including any users (or data).
+     */
+    public function test_course_copy_no_data() {
+        global $DB;
+
+        // Mock up the form data.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+        $formdata->visible = 0;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 1582386400;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 0;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        // Create the course copy records and associated ad-hoc task.
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copyids = $coursecopy->create_copy();
+
+        $courseid = $this->course->id;
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        $postrestorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check the restored course itself.
+        $coursecontext = context_course::instance($postrestorerec->itemid);
+        $users = get_enrolled_users($coursecontext);
+
+        get_fast_modinfo($postrestorerec->itemid, 0, true);
+        $modinfo = get_fast_modinfo($postrestorerec->itemid);
+        $forums = $modinfo->get_instances_of('forum');
+        $forum = reset($forums);
+        $discussions = forum_get_discussions($forum);
+        $course = $modinfo->get_course();
+
+        $this->assertEquals($formdata->startdate, $course->startdate);
+        $this->assertEquals($formdata->enddate, $course->enddate);
+        $this->assertEquals('copy course', $course->fullname);
+        $this->assertEquals('copy course short',  $course->shortname);
+        $this->assertEquals(0,  $course->visible);
+        $this->assertEquals(123,  $course->idnumber);
+
+        foreach ($modinfo->get_cms() as $cm) {
+            $this->assertContains($cm->get_formatted_name(), $this->activitynames);
+        }
+
+        // Should be no discussions as the user data wasn't included.
+        $this->assertEquals(0, count($discussions));
+
+        // There should only be all users in the new course.
+        $this->assertEquals(count($this->courseusers), count($users));
+    }
+
+    /*
+     * Test instantiation with incomplete formdata.
+     */
+    public function test_malformed_instantiation() {
+        // Mock up the form data, missing things so we get an exception.
+        $formdata = new \stdClass;
+        $formdata->courseid = $this->course->id;
+        $formdata->fullname = 'copy course';
+        $formdata->shortname = 'copy course short';
+        $formdata->category = 1;
+
+        // Expect and exception as form data is incomplete.
+        $this->expectException(\moodle_exception::class);
+        new \core_backup\copy\copy($formdata);
+    }
+}
\ No newline at end of file
diff --git a/backup/tests/externallib_test.php b/backup/tests/externallib_test.php
new file mode 100644 (file)
index 0000000..8cb6e4d
--- /dev/null
@@ -0,0 +1,182 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Backup webservice tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @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 . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/backup/externallib.php');
+
+/**
+ * Backup webservice tests.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backup_external_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Set up tasks for all tests.
+     */
+    protected function setUp() {
+        global $CFG;
+
+        $this->resetAfterTest(true);
+
+        // Disable all loggers.
+        $CFG->backup_error_log_logger_level = backup::LOG_NONE;
+        $CFG->backup_output_indented_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level = backup::LOG_NONE;
+        $CFG->backup_database_logger_level = backup::LOG_NONE;
+        $CFG->backup_file_logger_level_extra = backup::LOG_NONE;
+    }
+
+    /**
+     * Test getting course copy progress.
+     */
+    public function test_get_copy_progress() {
+        global $USER;
+
+        $this->setAdminUser();
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $courseid = $course->id;
+
+        // Mock up the form data for use in tests.
+        $formdata = new \stdClass;
+        $formdata->courseid = $courseid;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = 1582376400;
+        $formdata->enddate = 0;
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $coursecopy = new \core_backup\copy\copy($formdata);
+        $copydetails = $coursecopy->create_copy();
+        $copydetails['operation'] = \backup::OPERATION_BACKUP;
+
+        $params = array('copies' => $copydetails);
+        $returnvalue = core_backup_external::get_copy_progress($params);
+
+        // We need to execute the return values cleaning process to simulate the web service server.
+        $returnvalue = external_api::clean_returnvalue(core_backup_external::get_copy_progress_returns(), $returnvalue);
+
+        $this->assertEquals(\backup::STATUS_AWAITING, $returnvalue[0]['status']);
+        $this->assertEquals(0, $returnvalue[0]['progress']);
+        $this->assertEquals($copydetails['backupid'], $returnvalue[0]['backupid']);
+        $this->assertEquals(\backup::OPERATION_BACKUP, $returnvalue[0]['operation']);
+
+        // We are expecting trace output during this test.
+        $this->expectOutputRegex("/$courseid/");
+
+        // Execute adhoc task and create the copy.
+        $now = time();
+        $task = \core\task\manager::get_next_adhoc_task($now);
+        $this->assertInstanceOf('\\core\\task\\asynchronous_copy_task', $task);
+        $task->execute();
+        \core\task\manager::adhoc_task_complete($task);
+
+        // Check the copy progress now.
+        $params = array('copies' => $copydetails);
+        $returnvalue = core_backup_external::get_copy_progress($params);
+
+        $returnvalue = external_api::clean_returnvalue(core_backup_external::get_copy_progress_returns(), $returnvalue);
+
+        $this->assertEquals(\backup::STATUS_FINISHED_OK, $returnvalue[0]['status']);
+        $this->assertEquals(1, $returnvalue[0]['progress']);
+        $this->assertEquals($copydetails['restoreid'], $returnvalue[0]['backupid']);
+        $this->assertEquals(\backup::OPERATION_RESTORE, $returnvalue[0]['operation']);
+
+    }
+
+    /**
+     * Test ajax submission of course copy process.
+     */
+    public function test_submit_copy_form() {
+        global $DB;
+
+        $this->setAdminUser();
+
+        // Create a course with some availability data set.
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+        $courseid = $course->id;
+
+        // Moodle form requires this for validation.
+        $sesskey = sesskey();
+        $_POST['sesskey'] = $sesskey;
+
+        // Mock up the form data for use in tests.
+        $formdata = new \stdClass;
+        $formdata->courseid = $courseid;
+        $formdata->returnto = '';
+        $formdata->returnurl = '';
+        $formdata->sesskey = $sesskey;
+        $formdata->_qf__core_backup_output_copy_form = 1;
+        $formdata->fullname = 'foo';
+        $formdata->shortname = 'bar';
+        $formdata->category = 1;
+        $formdata->visible = 1;
+        $formdata->startdate = array('day' => 5, 'month' => 5, 'year' => 2020, 'hour' => 0, 'minute' => 0);
+        $formdata->idnumber = 123;
+        $formdata->userdata = 1;
+        $formdata->role_1 = 1;
+        $formdata->role_3 = 3;
+        $formdata->role_5 = 5;
+
+        $urlform = http_build_query($formdata, '', '&'); // Take the form data and url encode it.
+        $jsonformdata = json_encode($urlform); // Take form string and JSON encode.
+
+        $returnvalue = core_backup_external::submit_copy_form($jsonformdata);
+
+        $returnjson = external_api::clean_returnvalue(core_backup_external::submit_copy_form_returns(), $returnvalue);
+        $copyids = json_decode($returnjson, true);
+
+        $backuprec = $DB->get_record('backup_controllers', array('backupid' => $copyids['backupid']));
+        $restorerec = $DB->get_record('backup_controllers', array('backupid' => $copyids['restoreid']));
+
+        // Check backup was completed successfully.
+        $this->assertEquals(backup::STATUS_AWAITING, $backuprec->status);
+        $this->assertEquals(0, $backuprec->progress);
+        $this->assertEquals('backup', $backuprec->operation);
+
+        // Check restore was completed successfully.
+        $this->assertEquals(backup::STATUS_REQUIRE_CONV, $restorerec->status);
+        $this->assertEquals(0, $restorerec->progress);
+        $this->assertEquals('restore', $restorerec->operation);
+    }
+}
\ No newline at end of file
index ce9c1ba..9e09f55 100644 (file)
@@ -564,7 +564,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_general_histories'          => 'grade_histories',
                         'backup_general_questionbank'       => 'questionbank',
                         'backup_general_groups'             => 'groups',
-                        'backup_general_competencies'       => 'competencies'
+                        'backup_general_competencies'       => 'competencies',
+                        'backup_general_contentbankcontent' => 'contentbankcontent',
                 );
                 self::apply_admin_config_defaults($controller, $settings, true);
                 break;
@@ -577,7 +578,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_import_calendarevents'     => 'calendarevents',
                         'backup_import_questionbank'       => 'questionbank',
                         'backup_import_groups'             => 'groups',
-                        'backup_import_competencies'       => 'competencies'
+                        'backup_import_competencies'       => 'competencies',
+                        'backup_import_contentbankcontent' => 'contentbankcontent',
                 );
                 self::apply_admin_config_defaults($controller, $settings, true);
                 if ((!$controller->get_interactive()) &&
@@ -608,7 +610,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_auto_histories'          => 'grade_histories',
                         'backup_auto_questionbank'       => 'questionbank',
                         'backup_auto_groups'             => 'groups',
-                        'backup_auto_competencies'       => 'competencies'
+                        'backup_auto_competencies'       => 'competencies',
+                        'backup_auto_contentbankcontent' => 'contentbankcontent'
                 );
                 self::apply_admin_config_defaults($controller, $settings, false);
                 break;
index 6649777..148655b 100644 (file)
@@ -157,7 +157,8 @@ abstract class restore_controller_dbops extends restore_dbops {
             'restore_general_histories'          => 'grade_histories',
             'restore_general_questionbank'       => 'questionbank',
             'restore_general_groups'             => 'groups',
-            'restore_general_competencies'       => 'competencies'
+            'restore_general_competencies'       => 'competencies',
+            'restore_general_contentbankcontent' => 'contentbankcontent'
         );
         self::apply_admin_config_defaults($controller, $settings, true);
 
index 309c478..b57c4b5 100644 (file)
@@ -64,7 +64,7 @@ class async_helper  {
     public function __construct($type, $id) {
         $this->type = $type;
         $this->backupid = $id;
-        $this->backuprec = $this->get_backup_record($id);
+        $this->backuprec = self::get_backup_record($id);
         $this->user = $this->get_user();
     }
 
@@ -76,7 +76,7 @@ class async_helper  {
      * @param int $id The backup id to get.
      * @return object $backuprec The backup controller record.
      */
-    private function get_backup_record($id) {
+    static public function get_backup_record($id) {
         global $DB;
 
         $backuprec = $DB->get_record('backup_controllers', array('backupid' => $id), '*', MUST_EXIST);
@@ -215,18 +215,21 @@ class async_helper  {
         require_once($CFG->dirroot . '/backup/util/interfaces/checksumable.class.php');
         require_once($CFG->dirroot . '/backup/backup.class.php');
 
-        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);
+        $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
+        );
+
+        $asyncrecord= $DB->get_record_select('backup_controllers', $select, $params);
+
+        if ((self::is_async_enabled() && $asyncrecord) || ($asyncrecord && $asyncrecord->purpose == backup::MODE_COPY)) {
+            $asyncpending = true;
         }
         return $asyncpending;
     }
index 70b50a2..c27ccf2 100644 (file)
@@ -145,4 +145,88 @@ class core_backup_async_helper_testcase extends \core_privacy\tests\provider_tes
         $this->assertEquals(1, count($result));
         $this->assertEquals('backup.mbz', $result[0][0]);
     }
+
+    /**
+     * Tests getting the backup record.
+     */
+    public function test_get_backup_record() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $backupid = $bc->get_backupid();
+        $copyrec = \async_helper::get_backup_record($backupid);
+
+        $this->assertEquals($backupid, $copyrec->backupid);
+
+    }
+
+    /**
+     * Tests is async pending conditions.
+     */
+    public function test_is_async_pending() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        set_config('enableasyncbackup', '0');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there are no backups and async backup is false.
+        $this->assertFalse($ispending);
+
+        // Create the initial backupcontoller.
+        new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_ASYNC, $USER->id, \backup::RELEASESESSION_YES);
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there as async backup is false.
+        $this->assertFalse($ispending);
+
+        set_config('enableasyncbackup', '1');
+        // Should be true as there as async backup is true and there is a pending backup.
+        $this->assertFalse($ispending);
+    }
+
+    /**
+     * Tests is async pending conditions for course copies.
+     */
+    public function test_is_async_pending_copy() {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $course = $generator->create_course();
+
+        set_config('enableasyncbackup', '0');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be false as there are no copies and async backup is false.
+        $this->assertFalse($ispending);
+
+        // Create the initial backupcontoller.
+        new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be True as this a copy operation.
+        $this->assertTrue($ispending);
+
+        set_config('enableasyncbackup', '1');
+        $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
+
+        // Should be true as there as async backup is true and there is a pending copy.
+        $this->assertTrue($ispending);
+    }
+
 }
index 0080a0e..9df1964 100644 (file)
@@ -97,6 +97,7 @@ require_once($CFG->dirroot . '/backup/util/ui/backup_moodleform.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui_stage.class.php');
 require_once($CFG->dirroot . '/backup/util/ui/backup_ui_setting.class.php');
+require_once($CFG->dirroot . '/backup/util/ui/classes/copy/copy.php');
 
 // And some moodle stuff too
 require_once($CFG->dirroot.'/course/lib.php');
index e3db201..a3dc2b0 100644 (file)
@@ -33,6 +33,12 @@ class backup_plan extends base_plan implements loggable {
     protected $basepath;   // Fullpath to dir where backup is created
     protected $excludingdactivities;
 
+    /**
+     * The role ids to keep in a copy operation.
+     * @var array
+     */
+    protected $keptroles = array();
+
     /**
      * Constructor - instantiates one object of this class
      */
@@ -104,6 +110,26 @@ class backup_plan extends base_plan implements loggable {
         $this->excludingdactivities = true;
     }
 
+    /**
+     * Sets the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @param array $roleids
+     */
+    public function set_kept_roles(array $roleids): void {
+        $this->keptroles = $roleids;
+    }
+
+    /**
+     * Get the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @return array
+     */
+    public function get_kept_roles(): array {
+        return $this->keptroles;
+    }
+
     public function log($message, $level, $a = null, $depth = null, $display = false) {
         backup_helper::log($message, $level, $a, $depth, $display, $this->get_logger());
     }
index d6313a1..818b315 100644 (file)
@@ -46,6 +46,16 @@ abstract class backup_task extends base_task {
     public function is_excluding_activities() {
         return $this->plan->is_excluding_activities();
     }
+
+    /**
+     * Get the user roles that should be kept in the destination course
+     * for a course copy operation.
+     *
+     * @return array
+     */
+    public function get_kept_roles(): array {
+        return $this->plan->get_kept_roles();
+    }
 }
 
 /*
index 202c88c..15d852a 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js and b/backup/util/ui/amd/build/async_backup.min.js differ
index 2dd8491..42387a1 100644 (file)
Binary files a/backup/util/ui/amd/build/async_backup.min.js.map and b/backup/util/ui/amd/build/async_backup.min.js.map differ
index b44e025..59a4bc9 100644 (file)
@@ -48,23 +48,25 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
     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.
+    var allcopyintervalid; //  The id of the setInterval function.
     var timeout = 2000; // Timeout for ajax requests.
 
     /**
      * Helper function to update UI components.
      *
      * @param {string} backupid The id to match elements on.
+     * @param {string} type The type of operation, backup or restore.
      * @param {number} percentage The completion percentage to apply.
      */
-    function updateElement(backupid, percentage) {
+    function updateElement(backupid, type, percentage) {
         var percentagewidth = Math.round(percentage) + '%';
-        var elementbar = $('#' + backupid + '_bar');
+        var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
         var percentagetext = percentage.toFixed(2) + '%';
 
         // Set progress bar percentage indicators
-        elementbar.attr('aria-valuenow', percentagewidth);
-        elementbar.css('width', percentagewidth);
-        elementbar.text(percentagetext);
+        elementbar.setAttribute('aria-valuenow', percentagewidth);
+        elementbar.style.width = percentagewidth;
+        elementbar.innerHTML = percentagetext;
     }
 
     /**
@@ -160,6 +162,56 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         });
     }
 
+    /**
+     * Update copy table row when an course copy completes.
+     *
+     * @param {string} backupid The id to match elements on.
+     */
+    function updateCopyTableRow(backupid) {
+        var elementbar = document.querySelectorAll("[data-restoreid=" + CSS.escape(backupid) + "]")[0];
+        var restorecourse = elementbar.closest('tr').children[1];
+        var coursename = restorecourse.innerHTML;
+        var courselink = document.createElement('a');
+        var elementbarparent = elementbar.closest('td');
+        var operation = elementbarparent.previousElementSibling;
+
+        // Replace the prgress bar.
+        Str.get_string('complete').then(function(content) {
+            operation.innerHTML = content;
+            return;
+        }).catch(function() {
+            notification.exception(new Error('Failed to load string: complete'));
+            return;
+        });
+
+        Templates.render('core/async_copy_complete_cell', {}).then(function(html, js) {
+            Templates.replaceNodeContents(elementbarparent, html, js);
+            return;
+        }).fail(function() {
+            notification.exception(new Error('Failed to load table cell'));
+            return;
+        });
+
+        // Update the destination course name to a link to that course.
+        ajax.call([{
+            methodname: 'core_backup_get_async_backup_links_restore',
+            args: {
+                'backupid': backupid,
+                'contextid': 0
+            },
+        }])[0].done(function(response) {
+            courselink.setAttribute('href', response.restoreurl);
+            courselink.innerHTML = coursename;
+            restorecourse.innerHTML = null;
+            restorecourse.appendChild(courselink);
+
+            return;
+        }).fail(function() {
+            notification.exception(new Error('Failed to update table row'));
+            return;
+        });
+    }
+
     /**
      * Update the Moodle user interface with the progress of
      * the backup process.
@@ -168,7 +220,8 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
      */
     function updateProgress(progress) {
         var percentage = progress.progress * 100;
-        var elementbar = $('#' + backupid + '_bar');
+        var type = 'backup';
+        var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
         var elementstatus = $('#' + backupid + '_status');
         var elementdetail = $('#' + backupid + '_detail');
         var elementbutton = $('#' + backupid + '_button');
@@ -176,16 +229,16 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
 
         if (progress.status == STATUS_EXECUTING) {
             // Process is in progress.
-            // Add in progress class color to bar
-            elementbar.addClass('bg-success');
+            // Add in progress class color to bar.
+            elementbar.classList.add('bg-success');
 
-            updateElement(backupid, percentage);
+            updateElement(backupid, type, percentage);
 
-            // Change heading
+            // Change heading.
             var strProcessing = 'async' + typeid + 'processing';
             Str.get_string(strProcessing, 'backup').then(function(title) {
                 elementstatus.text(title);
-                return title;
+                return;
             }).catch(function() {
                 notification.exception(new Error('Failed to load string: backup ' + strProcessing));
             });
@@ -193,15 +246,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         } else if (progress.status == STATUS_FINISHED_ERR) {
             // Process completed with error.
 
-            // Add in fail class color to bar
-            elementbar.addClass('bg-danger');
+            // Add in fail class color to bar.
+            elementbar.classList.add('bg-danger');
 
-            // Remove in progress class color to bar
-            elementbar.removeClass('bg-success');
+            // Remove in progress class color to bar.
+            elementbar.classList.remove('bg-success');
 
-            updateElement(backupid, 100);
+            updateElement(backupid, type, 100);
 
-            // Change heading and text
+            // Change heading and text.
             var strStatus = 'async' + typeid + 'error';
             var strStatusDetail = 'async' + typeid + 'errordetail';
             stringRequests = [
@@ -212,7 +265,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                 elementstatus.text(strings[0]);
                 elementdetail.text(strings[1]);
 
-                return strings;
+                return;
             })
             .catch(function() {
                 notification.exception(new Error('Failed to load string'));
@@ -229,15 +282,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
             // Process completed successfully.
 
             // Add in progress class color to bar
-            elementbar.addClass('bg-success');
+            elementbar.classList.add('bg-success');
 
-            updateElement(backupid, 100);
+            updateElement(backupid, type, 100);
 
             // Change heading and text
             var strComplete = 'async' + typeid + 'complete';
             Str.get_string(strComplete, 'backup').then(function(title) {
                 elementstatus.text(title);
-                return title;
+                return;
             }).catch(function() {
                 notification.exception(new Error('Failed to load string: backup ' + strComplete));
             });
@@ -262,7 +315,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                         elementbutton.text(strings[1]);
                         elementbutton.attr('href', response.restoreurl);
 
-                        return strings;
+                        return;
                     })
                     .catch(function() {
                         notification.exception(new Error('Failed to load string'));
@@ -282,7 +335,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
                     elementbutton.text(strings[1]);
                     elementbutton.attr('href', restoreurl);
 
-                    return strings;
+                    return;
                 })
                 .catch(function() {
                     notification.exception(new Error('Failed to load string'));
@@ -301,7 +354,7 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
 
     /**
      * Update the Moodle user interface with the progress of
-     * all the pending processes.
+     * all the pending processes for backup and restore operations.
      *
      * @param {object} progress The progress and status of the process.
      */
@@ -309,37 +362,37 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         progress.forEach(function(element) {
             var percentage = element.progress * 100;
             var backupid = element.backupid;
-            var elementbar = $('#' + backupid + '_bar');
             var type = element.operation;
+            var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
 
             if (element.status == STATUS_EXECUTING) {
                 // Process is in element.
 
                 // Add in element class color to bar
-                elementbar.addClass('bg-success');
+                elementbar.classList.add('bg-success');
 
-                updateElement(backupid, percentage);
+                updateElement(backupid, type, 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');
+                elementbar.classList.add('bg-danger');
+                elementbar.classList.add('complete');
 
                 // Remove in element class color to bar
-                $('#' + backupid + '_bar').removeClass('bg-success');
+                elementbar.classList.remove('bg-success');
 
-                updateElement(backupid, 100);
+                updateElement(backupid, type, 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');
+                elementbar.classList.add('bg-success');
+                elementbar.classList.add('complete');
 
-                updateElement(backupid, 100);
+                updateElement(backupid, type, 100);
 
                 // We have a successful backup. Update the UI with download and file details.
                 if (type == 'backup') {
@@ -353,6 +406,65 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         });
     }
 
+    /**
+     * Update the Moodle user interface with the progress of
+     * all the pending processes for copy operations.
+     *
+     * @param {object} progress The progress and status of the process.
+     */
+    function updateProgressCopy(progress) {
+        progress.forEach(function(element) {
+            var percentage = element.progress * 100;
+            var backupid = element.backupid;
+            var type = element.operation;
+            var elementbar = document.querySelectorAll("[data-" + type + "id=" + CSS.escape(backupid) + "]")[0];
+
+            if (type == 'restore') {
+                 let restorecell = elementbar.closest('tr').children[3];
+                 Str.get_string('restore').then(function(content) {
+                     restorecell.innerHTML = content;
+                     return;
+                 }).catch(function() {
+                     notification.exception(new Error('Failed to load string: restore'));
+                 });
+            }
+
+            if (element.status == STATUS_EXECUTING) {
+                // Process is in element.
+
+                // Add in element class color to bar
+                elementbar.classList.add('bg-success');
+
+                updateElement(backupid, type, percentage);
+
+            } else if (element.status == STATUS_FINISHED_ERR) {
+                // Process completed with error.
+
+                // Add in fail class color to bar
+                elementbar.classList.add('bg-danger');
+                elementbar.classList.add('complete');
+
+                // Remove in element class color to bar
+                elementbar.classList.remove('bg-success');
+
+                updateElement(backupid, type, 100);
+
+            } else if ((element.status == STATUS_FINISHED_OK) && (type == 'restore')) {
+                // Process completed successfully.
+
+                // Add in element class color to bar
+                elementbar.classList.add('bg-success');
+                elementbar.classList.add('complete');
+
+                updateElement(backupid, type, 100);
+
+                // We have a successful copy. Update the UI link to copied course.
+                updateCopyTableRow(backupid);
+            }
+
+        });
+    }
+
     /**
      * Get the progress of the backup process via ajax.
      */
@@ -407,6 +519,42 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         }
     }
 
+    /**
+     * Get the progress of all copy processes via ajax.
+     */
+    function getAllCopyProgress() {
+        var copyids = [];
+        var progressbars = $('.progress').find('.progress-bar').not('.complete');
+
+        progressbars.each(function() {
+            let progressvars = {
+                    'backupid': this.dataset.backupid,
+                    'restoreid': this.dataset.restoreid,
+                    'operation': this.dataset.operation,
+            };
+            copyids.push(progressvars);
+        });
+
+        if (copyids.length > 0) {
+            ajax.call([{
+                // Get the copy progress via webservice.
+                methodname: 'core_backup_get_copy_progress',
+                args: {
+                    'copies': copyids
+                },
+            }], true, true, false, timeout)[0].done(function(response) {
+                updateProgressCopy(response);
+                checkdelay = checkdelayoriginal;
+                allcopyintervalid = updateInterval(allcopyintervalid, getAllCopyProgress, checkdelayoriginal);
+            }).fail(function() {
+                checkdelay = checkdelay * checkdelaymultipler;
+                allcopyintervalid = updateInterval(allcopyintervalid, getAllCopyProgress, checkdelay);
+            });
+        } else {
+            clearInterval(allcopyintervalid); // No more progress bars to update, stop checking.
+        }
+    }
+
     /**
      * Get status updates for all backups.
      *
@@ -418,6 +566,15 @@ define(['jquery', 'core/ajax', 'core/str', 'core/notification', 'core/templates'
         allbackupintervalid = setInterval(getAllBackupProgress, checkdelay);
     };
 
+    /**
+     * Get status updates for all course copies.
+     *
+     * @public
+     */
+    Asyncbackup.asyncCopyAllStatus = function() {
+        allcopyintervalid = setInterval(getAllCopyProgress, checkdelay);
+    };
+
     /**
      * Get status updates for backup.
      *
diff --git a/backup/util/ui/classes/copy/copy.php b/backup/util/ui/classes/copy/copy.php
new file mode 100644 (file)
index 0000000..3e2d269
--- /dev/null
@@ -0,0 +1,309 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Course copy class.
+ *
+ * Handles procesing data submitted by UI copy form
+ * and sets up the course copy process.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_backup\copy;
+
+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');
+
+/**
+ * Course copy class.
+ *
+ * Handles procesing data submitted by UI copy form
+ * and sets up the course copy process.
+ *
+ * @package    core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class copy  {
+
+    /**
+     * The fields required for copy operations.
+     *
+     * @var array
+     */
+    private $copyfields = array(
+        'courseid',  // Course id integer.
+        'fullname', // Fullname of the destination course.
+        'shortname', // Shortname of the destination course.
+        'category', // Category integer ID that contains the destination course.
+        'visible', // Integer to detrmine of the copied course will be visible.
+        'startdate', // Integer timestamp of the start of the destination course.
+        'enddate', // Integer timestamp of the end of the destination course.
+        'idnumber', // ID of the destination course.
+        'userdata', // Integer to determine if the copied course will contain user data.
+    );
+
+    /**
+     * Data required for course copy operations.
+     *
+     * @var array
+     */
+    private $copydata = array();
+
+    /**
+     * List of role ids to keep enrolments for in the destination course.
+     *
+     * @var array
+     */
+    private $roles = array();
+
+    /**
+     * Constructor for the class.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     */
+    public function __construct(\stdClass $formdata) {
+        $this->copydata = $this->get_copy_data($formdata);
+        $this->roles = $this->get_enrollment_roles($formdata);
+    }
+
+    /**
+     * Extract the enrolment roles to keep in the copied course
+     * from the raw submitted form data.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     * @return array $keptroles The roles to keep.
+     */
+    private final function get_enrollment_roles(\stdClass $formdata): array {
+        $keptroles = array();
+
+        foreach ($formdata as $key => $value) {
+            if ((substr($key, 0, 5 ) === 'role_') && ($value != 0)) {
+                $keptroles[] = $value;
+            }
+        }
+
+        return $keptroles;
+    }
+
+    /**
+     *  Take the validated form data and extract the required information for copy operations.
+     *
+     * @param \stdClass $formdata Data from the validated course copy form.
+     * @throws \moodle_exception
+     * @return \stdClass $copydata Data required for course copy operations.
+     */
+    private final function get_copy_data(\stdClass $formdata): \stdClass {
+        $copydata = new \stdClass();
+
+        foreach ($this->copyfields as $field) {
+            if (isset($formdata->{$field})) {
+                $copydata->{$field} = $formdata->{$field};
+            } else {
+                throw new \moodle_exception('copy_class_field_not_found');
+            }
+        }
+
+        return $copydata;
+    }
+
+    /**
+     * Creates a course copy.
+     * Sets up relevant controllers and adhoc task.
+     *
+     * @return array $copyids THe backup and restore controller ids.
+     */
+    public function create_copy(): array {
+        global $USER;
+        $copyids = array();
+
+        // Create the initial backupcontoller.
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $this->copydata->courseid, \backup::FORMAT_MOODLE,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $copyids['backupid'] = $bc->get_backupid();
+
+        // Create the initial restore contoller.
+        list($fullname, $shortname) = \restore_dbops::calculate_course_names(
+            0, get_string('copyingcourse', 'backup'), get_string('copyingcourseshortname', 'backup'));
+        $newcourseid = \restore_dbops::create_new_course($fullname, $shortname, $this->copydata->category);
+        $rc = new \restore_controller($copyids['backupid'], $newcourseid,
+            \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id,
+            \backup::TARGET_NEW_COURSE);
+        $copyids['restoreid'] = $rc->get_restoreid();
+
+        // Configure the controllers based on the submitted data.
+        $copydata = $this->copydata;
+        $copydata->copyids = $copyids;
+        $copydata->keptroles = $this->roles;
+        $bc->set_copy($copydata);
+        $bc->set_status(\backup::STATUS_AWAITING);
+        $bc->get_status();
+
+        $rc->set_copy($copydata);
+        $rc->save_controller();
+
+        // Create the ad-hoc task to perform the course copy.
+        $asynctask = new \core\task\asynchronous_copy_task();
+        $asynctask->set_blocking(false);
+        $asynctask->set_custom_data($copyids);
+        \core\task\manager::queue_adhoc_task($asynctask);
+
+        // Clean up the controller.
+        $bc->destroy();
+
+        return $copyids;
+    }
+
+    /**
+     * Filters an array of copy records by course ID.
+     *
+     * @param array $copyrecords
+     * @param int $courseid
+     * @return array $copies Filtered array of records.
+     */
+    static private function filter_copies_course(array $copyrecords, int $courseid): array {
+        $copies = array();
+
+        foreach ($copyrecords as $copyrecord) {
+            if ($copyrecord->operation == \backup::OPERATION_RESTORE) { // Restore records.
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        continue;
+                } else {
+                    $rc = \restore_controller::load_controller($copyrecord->restoreid);
+                    if ($rc->get_copy()->courseid == $courseid) {
+                        $copies[] = $copyrecord;
+                    }
+                }
+            } else { // Backup records.
+                if ($copyrecord->itemid == $courseid) {
+                    $copies[] = $copyrecord;
+                }
+            }
+        }
+        return $copies;
+    }
+
+    /**
+     * Get the in progress course copy operations for a user.
+     *
+     * @param int $userid User id to get the course copies for.
+     * @param int $courseid The optional source course id to get copies for.
+     * @return array $copies Details of the inprogress copies.
+     */
+    static public function get_copies(int $userid, int $courseid=0): array {
+        global $DB;
+        $copies = array();
+        $params = array($userid, \backup::EXECUTION_DELAYED, \backup::MODE_COPY);
+        $sql = 'SELECT bc.backupid, bc.itemid, bc.operation, bc.status, bc.timecreated
+                  FROM {backup_controllers} bc
+            INNER JOIN {course} c ON bc.itemid = c.id
+                 WHERE bc.userid = ?
+                       AND bc.execution = ?
+                       AND bc.purpose = ?
+              ORDER BY bc.timecreated DESC';
+
+        $copyrecords = $DB->get_records_sql($sql, $params);
+
+        foreach ($copyrecords as $copyrecord) {
+            $copy = new \stdClass();
+            $copy->itemid = $copyrecord->itemid;
+            $copy->time = $copyrecord->timecreated;
+            $copy->operation = $copyrecord->operation;
+            $copy->status = $copyrecord->status;
+            $copy->backupid = null;
+            $copy->restoreid = null;
+
+            if ($copyrecord->operation == \backup::OPERATION_RESTORE) {
+                $copy->restoreid = $copyrecord->backupid;
+                // If record is complete or complete with errors, it means the backup also completed.
+                // It also means there are no controllers. In this case just skip and move on.
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        continue;
+                } else if ($copyrecord->status > \backup::STATUS_REQUIRE_CONV) {
+                    // If record is a restore and it's in progress (>200), it means the backup is finished.
+                    // In this case return the restore.
+                    $rc = \restore_controller::load_controller($copyrecord->backupid);
+                    $course = get_course($rc->get_copy()->courseid);
+
+                    $copy->source = $course->shortname;
+                    $copy->sourceid = $course->id;
+                    $copy->destination = $rc->get_copy()->shortname;
+                    $copy->backupid = $rc->get_copy()->copyids['backupid'];
+                    $rc->destroy();
+
+                } else if ($copyrecord->status == \backup::STATUS_REQUIRE_CONV) {
+                    // If record is a restore and it is waiting (=200), load the controller
+                    // and check the status of the backup.
+                    // If the backup has finished successfully we have and edge case. Process as per in progress restore.
+                    // If the backup has any other code it will be handled by backup processing.
+                    $rc = \restore_controller::load_controller($copyrecord->backupid);
+                    $bcid = $rc->get_copy()->copyids['backupid'];
+                    if (empty($copyrecords[$bcid])) {
+                        continue;
+                    }
+                    $backuprecord = $copyrecords[$bcid];
+                    $backupstatus = $backuprecord->status;
+                    if ($backupstatus == \backup::STATUS_FINISHED_OK) {
+                        $course = get_course($rc->get_copy()->courseid);
+
+                        $copy->source = $course->shortname;
+                        $copy->sourceid = $course->id;
+                        $copy->destination = $rc->get_copy()->shortname;
+                        $copy->backupid = $rc->get_copy()->copyids['backupid'];
+                    } else {
+                        continue;
+                    }
+                }
+            } else { // Record is a backup.
+                $copy->backupid = $copyrecord->backupid;
+                if ($copyrecord->status == \backup::STATUS_FINISHED_OK
+                    || $copyrecord->status == \backup::STATUS_FINISHED_ERR) {
+                        // If successfully finished then skip it. Restore procesing will look after it.
+                        // If it has errored then we can't go any further.
+                        continue;
+                } else {
+                    // If is in progress then process it.
+                    $bc = \backup_controller::load_controller($copyrecord->backupid);
+                    $course = get_course($bc->get_courseid());
+
+                    $copy->source = $course->shortname;
+                    $copy->sourceid = $course->id;
+                    $copy->destination = $bc->get_copy()->shortname;
+                    $copy->restoreid = $bc->get_copy()->copyids['restoreid'];
+                }
+            }
+
+            $copies[] = $copy;
+        }
+
+        // Extra processing to filter records for a given course.
+        if ($courseid != 0 ) {
+            $copies = self::filter_copies_course($copies, $courseid);
+        }
+
+        return $copies;
+    }
+}
diff --git a/backup/util/ui/classes/output/copy_form.php b/backup/util/ui/classes/output/copy_form.php
new file mode 100644 (file)
index 0000000..ff7a55d
--- /dev/null
@@ -0,0 +1,235 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Course copy form class.
+ *
+ * @package     core_backup
+ * @copyright   2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author      Matt Porritt <mattp@catalyst-au.net>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_backup\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once("$CFG->libdir/formslib.php");
+
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/util/includes/restore_includes.php');
+
+/**
+ * Course copy form class.
+ *
+ * @package     core_backup
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class copy_form extends \moodleform {
+
+    /**
+     * Build form for the course copy settings.
+     *
+     * {@inheritDoc}
+     * @see \moodleform::definition()
+     */
+    public function definition() {
+        global $CFG, $OUTPUT, $USER;
+
+        $mform = $this->_form;
+        $course = $this->_customdata['course'];
+        $coursecontext = \context_course::instance($course->id);
+        $courseconfig = get_config('moodlecourse');
+        $returnto = $this->_customdata['returnto'];
+        $returnurl = $this->_customdata['returnurl'];
+
+        if (empty($course->category)) {
+            $course->category = $course->categoryid;
+        }
+
+        // Course ID.
+        $mform->addElement('hidden', 'courseid', $course->id);
+        $mform->setType('courseid', PARAM_INT);
+
+        // Return to type.
+        $mform->addElement('hidden', 'returnto', null);
+        $mform->setType('returnto', PARAM_ALPHANUM);
+        $mform->setConstant('returnto', $returnto);
+
+        // Notifications of current copies.
+        $copies = \core_backup\copy\copy::get_copies($USER->id, $course->id);
+        if (!empty($copies)) {
+            $progresslink = new \moodle_url('/backup/copyprogress.php?', array('id' => $course->id));
+            $notificationmsg = get_string('copiesinprogress', 'backup', $progresslink->out());
+            $notification = $OUTPUT->notification($notificationmsg, 'notifymessage');
+            $mform->addElement('html', $notification);
+        }
+
+        // Return to URL.
+        $mform->addElement('hidden', 'returnurl', null);
+        $mform->setType('returnurl', PARAM_LOCALURL);
+        $mform->setConstant('returnurl', $returnurl);
+
+        // Form heading.
+        $mform->addElement('html', \html_writer::div(get_string('copycoursedesc', 'backup'), 'form-description mb-3'));
+
+        // Course fullname.
+        $mform->addElement('text', 'fullname', get_string('fullnamecourse'), 'maxlength="254" size="50"');
+        $mform->addHelpButton('fullname', 'fullnamecourse');
+        $mform->addRule('fullname', get_string('missingfullname'), 'required', null, 'client');
+        $mform->setType('fullname', PARAM_TEXT);
+
+        // Course shortname.
+        $mform->addElement('text', 'shortname', get_string('shortnamecourse'), 'maxlength="100" size="20"');
+        $mform->addHelpButton('shortname', 'shortnamecourse');
+        $mform->addRule('shortname', get_string('missingshortname'), 'required', null, 'client');
+        $mform->setType('shortname', PARAM_TEXT);
+
+        // Course category.
+        $displaylist = \core_course_category::make_categories_list(\core_course\management\helper::get_course_copy_capabilities());
+        if (!isset($displaylist[$course->category])) {
+            // Always keep current category.
+            $displaylist[$course->category] = \core_course_category::get($course->category, MUST_EXIST, true)->get_formatted_name();
+        }
+        $mform->addElement('select', 'category', get_string('coursecategory'), $displaylist);
+        $mform->addHelpButton('category', 'coursecategory');
+
+        // Course visibility.
+        $choices = array();
+        $choices['0'] = get_string('hide');
+        $choices['1'] = get_string('show');
+        $mform->addElement('select', 'visible', get_string('coursevisibility'), $choices);
+        $mform->addHelpButton('visible', 'coursevisibility');
+        $mform->setDefault('visible', $courseconfig->visible);
+        if (!has_capability('moodle/course:visibility', $coursecontext)) {
+            $mform->hardFreeze('visible');
+            $mform->setConstant('visible', $course->visible);
+        }
+
+        // Course start date.
+        $mform->addElement('date_time_selector', 'startdate', get_string('startdate'));
+        $mform->addHelpButton('startdate', 'startdate');
+        $date = (new \DateTime())->setTimestamp(usergetmidnight(time()));
+        $date->modify('+1 day');
+        $mform->setDefault('startdate', $date->getTimestamp());
+
+        // Course enddate.
+        $mform->addElement('date_time_selector', 'enddate', get_string('enddate'), array('optional' => true));
+        $mform->addHelpButton('enddate', 'enddate');
+
+        if (!empty($CFG->enablecourserelativedates)) {
+            $attributes = [
+                'aria-describedby' => 'relativedatesmode_warning'
+            ];
+            if (!empty($course->id)) {
+                $attributes['disabled'] = true;
+            }
+            $relativeoptions = [
+                0 => get_string('no'),
+                1 => get_string('yes'),
+            ];
+            $relativedatesmodegroup = [];
+            $relativedatesmodegroup[] = $mform->createElement('select', 'relativedatesmode', get_string('relativedatesmode'),
+                $relativeoptions, $attributes);
+            $relativedatesmodegroup[] = $mform->createElement('html', \html_writer::span(get_string('relativedatesmode_warning'),
+                '', ['id' => 'relativedatesmode_warning']));
+            $mform->addGroup($relativedatesmodegroup, 'relativedatesmodegroup', get_string('relativedatesmode'), null, false);
+            $mform->addHelpButton('relativedatesmodegroup', 'relativedatesmode');
+        }
+
+        // Course id number.
+        $mform->addElement('text', 'idnumber', get_string('idnumbercourse'), 'maxlength="100"  size="10"');
+        $mform->addHelpButton('idnumber', 'idnumbercourse');
+        $mform->setType('idnumber', PARAM_RAW);
+        if (!empty($course->id) and !has_capability('moodle/course:changeidnumber', $coursecontext)) {
+            $mform->hardFreeze('idnumber');
+            $mform->setConstants('idnumber', $course->idnumber);
+        }
+
+        // Keep source course user data.
+        $requiredcapabilities = array(
+            'moodle/restore:createuser', 'moodle/backup:userinfo', 'moodle/restore:userinfo'
+        );
+        if (has_all_capabilities($requiredcapabilities, $coursecontext)) {
+            $dataarray = array();
+            $dataarray[] = $mform->createElement('advcheckbox', 'userdata',
+                get_string('enable'), '', array('group' => 1), array(0, 1));
+            $mform->addGroup($dataarray, 'dataarray', get_string('userdata', 'backup'), ' ', false);
+            $mform->addHelpButton('dataarray', 'userdata', 'backup');
+        }
+
+        // Keep manual enrolments.
+        // Only get roles actually used in this course.
+        $roles = role_fix_names(get_roles_used_in_context($coursecontext, false), $coursecontext);
+
+        // Only add the option if there are roles in this course.
+        if (!empty($roles) && has_capability('moodle/restore:createuser', $coursecontext)) {
+            $rolearray = array();
+            foreach ($roles as $role) {
+                $roleid = 'role_' . $role->id;
+                $rolearray[] = $mform->createElement('advcheckbox', $roleid,
+                    $role->localname, '', array('group' => 2), array(0, $role->id));
+            }
+
+            $mform->addGroup($rolearray, 'rolearray', get_string('keptroles', 'backup'), ' ', false);
+            $mform->addHelpButton('rolearray', 'keptroles', 'backup');
+            $this->add_checkbox_controller(2);
+        }
+
+        $buttonarray = array();
+        $buttonarray[] = $mform->createElement('submit', 'submitreturn', get_string('copyreturn', 'backup'));
+        $buttonarray[] = $mform->createElement('submit', 'submitdisplay', get_string('copyview', 'backup'));
+        $buttonarray[] = $mform->createElement('cancel');
+        $mform->addGroup($buttonarray, 'buttonar', '', ' ', false);
+
+    }
+
+    /**
+     * Validation of the form.
+     *
+     * @param array $data
+     * @param array $files
+     * @return array the errors that were found
+     */
+    public function validation($data, $files) {
+        global $DB;
+        $errors = parent::validation($data, $files);
+
+        // Add field validation check for duplicate shortname.
+        $courseshortname = $DB->get_record('course', array('shortname' => $data['shortname']), 'fullname', IGNORE_MULTIPLE);
+        if ($courseshortname) {
+            $errors['shortname'] = get_string('shortnametaken', '', $courseshortname->fullname);
+        }
+
+        // Add field validation check for duplicate idnumber.
+        if (!empty($data['idnumber'])) {
+            $courseidnumber = $DB->get_record('course', array('idnumber' => $data['idnumber']), 'fullname', IGNORE_MULTIPLE);
+            if ($courseidnumber) {
+                $errors['idnumber'] = get_string('courseidnumbertaken', 'error', $courseidnumber->fullname);
+            }
+        }
+
+        // Validate the dates (make sure end isn't greater than start).
+        if ($errorcode = course_validate_dates($data)) {
+            $errors['enddate'] = get_string($errorcode, 'error');
+        }
+
+        return $errors;
+    }
+
+}
index d9bdbe1..d5b44ce 100644 (file)
@@ -572,11 +572,15 @@ class core_backup_renderer extends plugin_renderer_base {
      * @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.
+    public function get_status_display($statuscode, $backupid, $restoreid=null, $operation='backup') {
+        if ($statuscode == backup::STATUS_AWAITING
+            || $statuscode == backup::STATUS_EXECUTING
+            || $statuscode == backup::STATUS_REQUIRE_CONV) {  // In progress.
             $progresssetup = array(
-                    'backupid' => $backupid,
-                    'width' => '100'
+                'backupid' => $backupid,
+                'restoreid' => $restoreid,
+                'operation' => $operation,
+                'width' => '100'
             );
             $status = $this->render_from_template('core/async_backup_progress', $progresssetup);
         } else if ($statuscode == backup::STATUS_FINISHED_ERR) { // Error.
@@ -963,7 +967,7 @@ class core_backup_renderer extends plugin_renderer_base {
 
             $restorename = \async_helper::get_restore_name($context);
             $timecreated = $restore->timecreated;
-            $status = $this->get_status_display($restore->status, $restore->backupid);
+            $status = $this->get_status_display($restore->status, $restore->backupid, $restore->backupid, null, 'restore');
 
             $tablerow = array($restorename, userdate($timecreated), $status);
             $tabledata[] = $tablerow;
@@ -974,6 +978,50 @@ class core_backup_renderer extends plugin_renderer_base {
 
         return $html;
     }
+
+    /**
+     * Get markup to render table for all of a users course copies.
+     *
+     * @param int $userid The Moodle user id.
+     * @param int $courseid The id of the course to get the backups for.
+     * @return string $html The table HTML.
+     */
+    public function copy_progress_viewer(int $userid, int $courseid): string {
+        $tablehead = array(
+            get_string('copysource', 'backup'),
+            get_string('copydest', 'backup'),
+            get_string('time'),
+            get_string('copyop', 'backup'),
+            get_string('status', 'backup')
+        );
+
+        $table = new html_table();
+        $table->attributes['class'] = 'backup-files-table generaltable';
+        $table->head = $tablehead;
+
+        $tabledata = array();
+
+        // Get all in progress course copies for this user.
+        $copies = \core_backup\copy\copy::get_copies($userid, $courseid);
+
+        foreach ($copies as $copy) {
+            $sourceurl = new \moodle_url('/course/view.php', array('id' => $copy->sourceid));
+
+            $tablerow = array(
+                html_writer::link($sourceurl, $copy->source),
+                $copy->destination,
+                userdate($copy->time),
+                get_string($copy->operation),
+                $this->get_status_display($copy->status, $copy->backupid, $copy->restoreid, $copy->operation)
+            );
+            $tabledata[] = $tablerow;
+        }
+
+        $table->data = $tabledata;
+        $html = html_writer::table($table);
+
+        return $html;
+    }
 }
 
 /**
diff --git a/backup/util/ui/tests/behat/import_contentbank_content.feature b/backup/util/ui/tests/behat/import_contentbank_content.feature
new file mode 100644 (file)
index 0000000..68f2fb3
--- /dev/null
@@ -0,0 +1,45 @@
+@core @core_backup @core_contentbank
+Feature: Import course content bank content
+  In order to import content from a course contentbank
+  As a teacher
+  I need to confirm that errors will not happen
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | teacher1 | C2 | editingteacher |
+    And the following "contentbank content" exist:
+      | course| contenttype     | user     | contentname       |
+      | C1    | contenttype_h5p | teacher1 | ipsums.h5p        |
+    And I log in as "teacher1"
+
+  Scenario: Import content bank content to another course
+    Given I am on "Course 2" course homepage
+    And I click on "Content bank" "link"
+    And I should not see "ipsums.h5p"
+    When I import "Course 1" course into "Course 2" course using this options:
+    And I click on "Content bank" "link"
+    Then I should see "ipsums.h5p"
+    And I am on "Course 1" course homepage
+    And I click on "Content bank" "link"
+    And I should see "ipsums.h5p"
+
+  Scenario: User could configure not to import content bank
+    Given I am on "Course 2" course homepage
+    And I click on "Content bank" "link"
+    And I should not see "ipsums.h5p"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include content bank content | 0 |
+    And I click on "Content bank" "link"
+    Then I should not see "ipsums.h5p"
+    And I am on "Course 1" course homepage
+    And I click on "Content bank" "link"
+    And I should see "ipsums.h5p"
index dd45eed..873434b 100644 (file)
@@ -221,6 +221,11 @@ class block_base {
     public function get_content_for_output($output) {
         global $CFG;
 
+        // We can exit early if the current user doesn't have the capability to view the block.
+        if (!has_capability('moodle/block:view', $this->context)) {
+            return null;
+        }
+
         $bc = new block_contents($this->html_attributes());
         $bc->attributes['data-block'] = $this->name();
         $bc->blockinstanceid = $this->instance->id;
index b569e37..f55aa90 100644 (file)
@@ -87,7 +87,8 @@ class api {
             if ($completionexpectedtime !== null) {
                 // Calendar event exists so update it.
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->timestart = $completionexpectedtime;
                 $event->timesort = $completionexpectedtime;
                 $event->visible = instance_is_visible($modulename, $instance);
@@ -104,7 +105,8 @@ class api {
             // Event doesn't exist so create one.
             if ($completionexpectedtime !== null) {
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->courseid = $instance->course;
                 $event->groupid = 0;
                 $event->userid = 0;
diff --git a/contentbank/amd/build/search.min.js b/contentbank/amd/build/search.min.js
new file mode 100644 (file)
index 0000000..9090c9b
Binary files /dev/null and b/contentbank/amd/build/search.min.js differ
diff --git a/contentbank/amd/build/search.min.js.map b/contentbank/amd/build/search.min.js.map
new file mode 100644 (file)
index 0000000..a3ddca1
Binary files /dev/null and b/contentbank/amd/build/search.min.js.map differ
diff --git a/contentbank/amd/build/selectors.min.js b/contentbank/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..c7322b9
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js differ
diff --git a/contentbank/amd/build/selectors.min.js.map b/contentbank/amd/build/selectors.min.js.map
new file mode 100644 (file)
index 0000000..99b3b56
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js.map differ
diff --git a/contentbank/amd/src/search.js b/contentbank/amd/src/search.js
new file mode 100644 (file)
index 0000000..bd5cb55
--- /dev/null
@@ -0,0 +1,160 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Search methods for finding contents in the content bank.
+ *
+ * @module     core_contentbank/search
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import selectors from 'core_contentbank/selectors';
+import {get_string as getString} from 'core/str';
+import Pending from 'core/pending';
+import {debounce} from 'core/utils';
+
+/**
+ * Set up the search.
+ *
+ * @method init
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    const root = $(selectors.elements.main);
+    registerListenerEvents(root);
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Register contentbank search related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {Object} root The root element for the contentbank.
+ */
+const registerListenerEvents = (root) => {
+
+    const searchInput = root.find(selectors.elements.searchinput)[0];
+
+    root.on('click', selectors.actions.search, function(e) {
+        e.preventDefault();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    root.on('click', selectors.actions.clearSearch, function(e) {
+        e.preventDefault();
+        searchInput.value = "";
+        searchInput.focus();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    // The search input is also triggered.
+    searchInput.addEventListener('input', debounce(() => {
+        // Display the search results.
+        toggleSearchResultsView(root, searchInput.value);
+    }, 300));
+
+};
+
+/**
+ * Toggle (display/hide) the search results depending on the value of the search query.
+ *
+ * @method toggleSearchResultsView
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchQuery The search query.
+ */
+const toggleSearchResultsView = async(body, searchQuery) => {
+    const clearSearchButton = body.find(selectors.elements.clearsearch)[0];
+    const searchIcon = body.find(selectors.elements.searchicon)[0];
+
+    const navbarBreadcrumb = body.find(selectors.elements.cbnavbarbreadcrumb)[0];
+    const navbarTotal = body.find(selectors.elements.cbnavbartotalsearch)[0];
+    // Update the results.
+    const filteredContents = filterContents(body, searchQuery);
+    if (searchQuery.length > 0) {
+        // As the search query is present, search results should be displayed.
+
+        // Display the "clear" search button in the activity chooser search bar.
+        searchIcon.classList.add('d-none');
+        clearSearchButton.classList.remove('d-none');
+
+        // Change the cb-navbar to display total items found.
+        navbarBreadcrumb.classList.add('d-none');
+        navbarTotal.innerHTML = await getString('itemsfound', 'core_contentbank', filteredContents.length);
+        navbarTotal.classList.remove('d-none');
+    } else {
+        // As search query is not present, the search results should be removed.
+
+        // Hide the "clear" search button in the activity chooser search bar.
+        clearSearchButton.classList.add('d-none');
+        searchIcon.classList.remove('d-none');
+
+        // Display again the breadcrumb in the navbar.
+        navbarBreadcrumb.classList.remove('d-none');
+        navbarTotal.classList.add('d-none');
+    }
+};
+
+/**
+ * Return the list of contents which have a name that matches the given search term.
+ *
+ * @method filterContents
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchTerm The search term to match.
+ * @return {Array}
+ */
+const filterContents = (body, searchTerm) => {
+    const contents = Array.from(body.find(selectors.elements.cbfile));
+    const searchResults = [];
+    contents.forEach((content) => {
+        const contentName = content.getAttribute('data-file');
+        if (searchTerm === '' || contentName.toLowerCase().includes(searchTerm.toLowerCase())) {
+            // The content matches the search criteria so it should be displayed and hightlighted.
+            searchResults.push(content);
+            const contentNameElement = content.querySelector(selectors.regions.cbcontentname);
+            contentNameElement.innerHTML = highlight(contentName, searchTerm);
+            content.classList.remove('d-none');
+        } else {
+            content.classList.add('d-none');
+        }
+    });
+
+    return searchResults;
+};
+
+/**
+ * Highlight a given string in a text.
+ *
+ * @method highlight
+ * @param  {String} text The whole text.
+ * @param  {String} highlightText The piece of text to highlight.
+ * @return {String}
+ */
+const highlight = (text, highlightText) => {
+    let result = text;
+    if (highlightText !== '') {
+        const pos = text.toLowerCase().indexOf(highlightText.toLowerCase());
+        if (pos > -1) {
+            result = text.substr(0, pos) + '<span class="matchtext">' + text.substr(pos, highlightText.length) + '</span>' +
+                text.substr(pos + highlightText.length);
+        }
+    }
+
+    return result;
+};
diff --git a/contentbank/amd/src/selectors.js b/contentbank/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..080f85f
--- /dev/null
@@ -0,0 +1,54 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Define all of the selectors we will be using on the contentbank interface.
+ *
+ * @module     core_contentbank/selectors
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A small helper function to build queryable data selectors.
+ *
+ * @method getDataSelector
+ * @param {String} name
+ * @param {String} value
+ * @return {string}
+ */
+const getDataSelector = (name, value) => {
+    return `[data-${name}="${value}"]`;
+};
+
+export default {
+    regions: {
+        cbcontentname: getDataSelector('region', 'cb-content-name'),
+    },
+    actions: {
+        search: getDataSelector('action', 'searchcontent'),
+        clearSearch: getDataSelector('action', 'clearsearchcontent'),
+    },
+    elements: {
+        cbfile: '.cb-file',
+        cbnavbarbreadcrumb: '.cb-navbar-breadbrumb',
+        cbnavbartotalsearch: '.cb-navbar-totalsearch',
+        clearsearch: '.input-group-append .clear-icon',
+        main: '#region-main',
+        searchicon: '.input-group-append .search-icon',
+        searchinput: '#searchinput',
+    },
+};
index 35499ce..197a36c 100644 (file)
@@ -42,7 +42,7 @@ class contentbank {
      *
      * @return string[] Array of contentbank contenttypes.
      */
-    private function get_enabled_content_types(): array {
+    public function get_enabled_content_types(): array {
         $enabledtypes = \core\plugininfo\contenttype::get_enabled_plugins();
         $types = [];
         foreach ($enabledtypes as $name) {
@@ -159,20 +159,28 @@ class contentbank {
      * Find the contents with %$search% in the contextid defined.
      * If contextid and search are empty, all contents are returned.
      * In all the cases, only the contents for the enabled contentbank-type plugins are returned.
+     * No content-type permissions are validated here. It is the caller responsability to check that the user can access to them.
+     * The only validation done here is, for each content, a call to the method $content->is_view_allowed().
      *
      * @param  string|null $search Optional string to search (for now it will search only into the name).
      * @param  int $contextid Optional contextid to search.
+     * @param  array $contenttypenames Optional array with the list of content-type names to search.
      * @return array The contents for the enabled contentbank-type plugins having $search as name and placed in $contextid.
      */
-    public function search_contents(?string $search = null, ?int $contextid = 0): array {
+    public function search_contents(?string $search = null, ?int $contextid = 0, ?array $contenttypenames = null): array {
         global $DB;
 
         $contents = [];
 
         // Get only contents for enabled content-type plugins.
-        $contenttypes = array_map(function($contenttypename) {
-            return "contenttype_$contenttypename";
-        }, $this->get_enabled_content_types());
+        $contenttypes = [];
+        $enabledcontenttypes = $this->get_enabled_content_types();
+        foreach ($enabledcontenttypes as $contenttypename) {
+            if (empty($contenttypenames) || in_array($contenttypename, $contenttypenames)) {
+                $contenttypes[] = "contenttype_$contenttypename";
+            }
+        }
+
         if (empty($contenttypes)) {
             // Early return if there are no content-type plugins enabled.
             return $contents;
@@ -193,7 +201,7 @@ class contentbank {
             $params['name'] = '%' . $DB->sql_like_escape($search) . '%';
         }
 
-        $records = $DB->get_records_select('contentbank_content', $sql, $params);
+        $records = $DB->get_records_select('contentbank_content', $sql, $params, 'name ASC');
         foreach ($records as $record) {
             $contentclass = "\\$record->contenttype\\content";
             $content = new $contentclass($record);
index 7f7fbf5..79d658b 100644 (file)
@@ -159,37 +159,34 @@ abstract class contenttype {
     /**
      * Returns the URL where the content will be visualized.
      *
-     * @param stdClass $record  The content to be displayed.
+     * @param  content $content The content to be displayed.
      * @return string           URL where to visualize the given content.
      */
-    public function get_view_url(\stdClass $record): string {
-        return new moodle_url('/contentbank/view.php', ['id' => $record->id]);
+    public function get_view_url(content $content): string {
+        return new moodle_url('/contentbank/view.php', ['id' => $content->get_id()]);
     }
 
     /**
      * Returns the HTML content to add to view.php visualizer.
      *
-     * @param stdClass $record  The content to be displayed.
+     * @param  content $content The content to be displayed.
      * @return string           HTML code to include in view.php.
      */
-    public function get_view_content(\stdClass $record): string {
+    public function get_view_content(content $content): string {
         // Trigger an event for viewing this content.
         $event = contentbank_content_viewed::create_from_record($record);
         $event->trigger();
-
-        // Main contenttype class can visualize the content, but plugins could overwrite visualization.
-        return '';
     }
 
     /**
      * Returns the HTML code to render the icon for content bank contents.
      *
-     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @param  content $content The content to be displayed.
      * @return string               HTML code to render the icon
      */
-    public function get_icon(string $contentname): string {
+    public function get_icon(content $content): string {
         global $OUTPUT;
-        return $OUTPUT->pix_icon('f/unknown-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+        return $OUTPUT->image_url('f/unknown-64', 'moodle')->out(false);
     }
 
     /**
index ac1a855..2ea5c4d 100644 (file)
@@ -72,22 +72,22 @@ class bankcontent implements renderable, templatable {
      * @return stdClass
      */
     public function export_for_template(renderer_base $output): stdClass {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('core_contentbank/search', 'init');
+
         $data = new stdClass();
         $contentdata = array();
         foreach ($this->contents as $content) {
             $record = $content->get_content();
-            $managerclass = $content->get_content_type().'\\contenttype';
-            if (class_exists($managerclass)) {
-                $manager = new $managerclass($this->context);
-                if ($manager->can_access()) {
-                    $name = $content->get_name();
-                    $contentdata[] = array(
-                        'name' => $name,
-                        'link' => $manager->get_view_url($record),
-                        'icon' => $manager->get_icon($name)
-                    );
-                }
-            }
+            $contenttypeclass = $content->get_content_type().'\\contenttype';
+            $contenttype = new $contenttypeclass($this->context);
+            $name = $content->get_name();
+            $contentdata[] = array(
+                'name' => $name,
+                'link' => $contenttype->get_view_url($content),
+                'icon' => $contenttype->get_icon($content)
+            );
         }
         $data->contents = $contentdata;
         $data->tools = $this->toolbar;
index 806205d..d48941d 100644 (file)
@@ -25,7 +25,6 @@
 namespace contenttype_h5p;
 
 use core\event\contentbank_content_viewed;
-use stdClass;
 use html_writer;
 
 /**
@@ -57,15 +56,14 @@ class contenttype extends \core_contentbank\contenttype {
     /**
      * Returns the HTML content to add to view.php visualizer.
      *
-     * @param stdClass $record  Th content to be displayed.
+     * @param  content $content The content to be displayed.
      * @return string            HTML code to include in view.php.
      */
-    public function get_view_content(\stdClass $record): string {
+    public function get_view_content(\core_contentbank\content $content): string {
         // Trigger an event for viewing this content.
-        $event = contentbank_content_viewed::create_from_record($record);
+        $event = contentbank_content_viewed::create_from_record($content->get_content());
         $event->trigger();
 
-        $content = new content($record);
         $fileurl = $content->get_file_url();
         $html = html_writer::tag('h2', $content->get_name());
         $html .= \core_h5p\player::display($fileurl, new \stdClass(), true);
@@ -75,12 +73,32 @@ class contenttype extends \core_contentbank\contenttype {
     /**
      * Returns the HTML code to render the icon for H5P content types.
      *
-     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @param  content $content The content to be displayed.
      * @return string            HTML code to render the icon
      */
-    public function get_icon(string $contentname): string {
-        global $OUTPUT;
-        return $OUTPUT->pix_icon('f/h5p-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+    public function get_icon(\core_contentbank\content $content): string {
+        global $OUTPUT, $DB;
+
+        $iconurl = $OUTPUT->image_url('f/h5p-64', 'moodle')->out(false);
+        $file = $content->get_file();
+        if (!empty($file)) {
+            $h5p = \core_h5p\api::get_content_from_pathnamehash($file->get_pathnamehash());
+            if (!empty($h5p)) {
+                \core_h5p\local\library\autoloader::register();
+                if ($h5plib = $DB->get_record('h5p_libraries', ['id' => $h5p->mainlibraryid])) {
+                    $h5pfilestorage = new \core_h5p\file_storage();
+                    $h5picon = $h5pfilestorage->get_icon_url(
+                            $h5plib->id,
+                            $h5plib->machinename,
+                            $h5plib->majorversion,
+                            $h5plib->minorversion);
+                    if (!empty($h5picon)) {
+                        $iconurl = $h5picon;
+                    }
+                }
+            }
+        }
+        return $iconurl;
     }
 
     /**
index ad9c5c5..8639d35 100644 (file)
@@ -34,7 +34,6 @@ Feature: H5P file upload to content bank for admins
     And I click on "Select this file" "button"
     And I click on "Save changes" "button"
     And I wait until the page is ready
-    And I click on "filltheblanks.h5p" "link"
     And I switch to "h5p-player" class iframe
     And I switch to "h5p-iframe" class iframe
     Then I should see "Of which countries"
index 3b9e3e3..c708516 100644 (file)
@@ -105,4 +105,46 @@ class contenttype_h5p_contenttype_plugin_testcase extends advanced_testcase {
         $this->assertFalse($coursetype->can_upload());
         $this->assertFalse($systemtype->can_upload());
     }
+
+    /**
+     * Tests get_icon result.
+     *
+     * @covers ::get_icon
+     */
+    public function test_get_icon() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $systemcontext = context_system::instance();
+        $this->setAdminUser();
+        $contenttype = new contenttype_h5p\contenttype($systemcontext);
+
+        // Add an H5P fill the blanks file to the content bank.
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/filltheblanks.h5p';
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, 0, $systemcontext, true, $filepath);
+        $filltheblanks = array_shift($contents);
+
+        // Add an H5P find the words file to the content bank.
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/find-the-words.h5p';
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, 0, $systemcontext, true, $filepath);
+        $findethewords = array_shift($contents);
+
+        // Check before deploying the icon for both contents is the same: default one.
+        // Because we don't know specific H5P content type yet.
+        $defaulticon = $contenttype->get_icon($filltheblanks);
+        $this->assertEquals($defaulticon, $contenttype->get_icon($findethewords));
+        $this->assertContains('h5p', $defaulticon);
+
+        // Deploy one of the contents though the player to create the H5P DB entries and know specific content type.
+        $h5pplayer = new \core_h5p\player($findethewords->get_file_url(), new \stdClass(), true);
+        $h5pplayer->add_assets_to_page();
+        $h5pplayer->output();
+
+        // Once the H5P has been deployed, we know the specific H5P content type, so the icon returned is not default one.
+        $findicon = $contenttype->get_icon($findethewords);
+        $this->assertNotEquals($defaulticon, $findicon);
+        $this->assertContains('find', $findicon, '', true);
+    }
 }
index bbf9786..f4f101b 100644 (file)
@@ -46,9 +46,19 @@ $PAGE->set_title($title);
 $PAGE->set_heading($title);
 $PAGE->set_pagetype('contenbank');
 
-// Get all contents managed by active plugins to render.
+// Get all contents managed by active plugins where the user has permission to render them.
 $cb = new \core_contentbank\contentbank();
-$foldercontents = $cb->search_contents($search, $contextid);
+$contenttypes = [];
+$enabledcontenttypes = $cb->get_enabled_content_types();
+foreach ($enabledcontenttypes as $contenttypename) {
+    $contenttypeclass = "\\contenttype_$contenttypename\\contenttype";
+    $contenttype = new $contenttypeclass($context);
+    if ($contenttype->can_access()) {
+        $contenttypes[] = $contenttypename;
+    }
+}
+
+$foldercontents = $cb->search_contents($search, $contextid, $contenttypes);
 
 // Get the toolbar ready.
 $toolbar = array ();
index 2212b81..8c43626 100644 (file)
             {
                 "name": "accordion.h5p",
                 "link": "http://something/contentbank/contenttype/h5p/view.php?url=http://something/pluginfile.php/1/contentbank/public/accordion.h5p",
-                "icon" : "<img class='icon iconsize-big' alt='accordion.h5p' aria-hidden='true' src='http://something/theme/image.php/boost/core/1581597850/f/h5p-64'>"
+                "icon" : "http://something/theme/image.php/boost/core/1581597850/f/h5p-64"
             },
             {
                 "name": "resume.pdf",
-                "icon": "<img class='icon iconsize-big' alt='resume.pdf' aria-hidden='true' src='http://something/theme/image.php/boost/core/1584597850/f/pdf-64'>"
+                "icon": "http://something/theme/image.php/boost/core/1584597850/f/pdf-64"
             }
         ],
         "tools": [
     }
 
 }}
-{{>core_contentbank/toolbar}}
+<div class="d-flex justify-content-between flex-column flex-sm-row">
+    <div class="cb-search-container mb-2">
+        {{>core_contentbank/bankcontent/search}}
+    </div>
+    <div class="cb-toolbar-container mb-2">
+        {{>core_contentbank/bankcontent/toolbar}}
+    </div>
+</div>
 <div class="content-bank-container pb-3 border">
     <div class="content-bank">
         <div class="cb-navbar bg-light p-2 border-bottom">
-            {{#pix}} i/folder {{/pix}}
+            <div class="cb-navbar-breadbrumb">
+                {{#pix}} i/folder {{/pix}}
+            </div>
+            <div class="cb-navbar-totalsearch d-none">
+            </div>
         </div>
         <div class="cb-content-wrapper d-flex flex-wrap p-2">
         {{#contents}}
-            <div class="cb-file position-relative mb-2">
+            <div class="cb-file position-relative mb-2" data-file="{{{name}}}">
                 <div class="p-2">
                     <div class="cb-thumbnail mb-1 text-center">
-                        {{{ icon }}}
+                        <img class="icon iconsize-big" alt="{{{name}}}" title="{{{name}}}" src="{{{ icon }}}">
                     </div>
 
                     {{#link}}
                         <a href="{{{ link }}}" class="stretched-link" title="{{{name}}}">
                     {{/link}}
-                            <span class="cb-name word-break-all clamp-2 text-center" >
+                            <span class="cb-name word-break-all clamp-2 text-center" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
                     {{#link}}
diff --git a/contentbank/templates/bankcontent/search.mustache b/contentbank/templates/bankcontent/search.mustache
new file mode 100644 (file)
index 0000000..8d02863
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core_contentbank/bankcontent/search
+
+    Example context (json):
+    {}
+
+}}
+<div class="searchbar input-group" role="search">
+    <label for="searchinput">
+        <span class="sr-only">{{#str}} searchcontentbankbyname, contentbank {{/str}}</span>
+    </label>
+    <input type="text"
+           id="searchinput"
+           class="form-control searchinput border-right-0"
+           placeholder="{{#str}} search, core {{/str}}"
+           name="search"
+           autocomplete="off"
+    >
+    <div class="input-group-append">
+        <div class="input-group-text bg-transparent">
+            <div class="search-icon">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="searchcontent"
+                    aria-label="{{#str}} search, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} a/search, core {{/pix}}</span>
+                </button>
+            </div>
+            <div class="clear-icon d-none">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="clearsearchcontent"
+                    aria-label="{{#str}} clearsearch, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} e/cancel_solid_circle, core {{/pix}}</span>
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
similarity index 91%
rename from contentbank/templates/toolbar.mustache
rename to contentbank/templates/bankcontent/toolbar.mustache
index 9ab8024..88f4a4c 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core_contentbank/toolbar
+    @template core_contentbank/bankcontent/toolbar
 
     Example context (json):
     {
@@ -38,7 +38,7 @@
         {{#tools}}
             {{#link}}<a href="{{{ link }}}" title="{{{ name }}}">{{/link}}
                 <div class="cb-tool icon-no-margin btn btn-secondary btn-lg">
-                    {{#pix}} {{{ icon }}} {{/pix}}
+                    {{#pix}} {{{ icon }}} {{/pix}} <span class="sr-only">{{{ name }}}</span>
                 </div>
             {{#link}}</a>{{/link}}
         {{/tools}}
index b49a79c..9b142b7 100644 (file)
@@ -26,14 +26,12 @@ Feature: Delete H5P file from the content bank
     And I click on "Save changes" "button"
 
   Scenario: Admins can delete content from the content bank
-    Given I should see "filltheblanks.h5p"
-    And I follow "filltheblanks.h5p"
-    When I open the action menu in "region-main-settings-menu" "region"
-    Then I should see "Delete"
-    And I choose "Delete" in the open action menu
+    Given I open the action menu in "region-main-settings-menu" "region"
+    And I should see "Delete"
+    When I choose "Delete" in the open action menu
     And I should see "Are you sure you want to delete the content 'filltheblanks.h5p'"
     And I click on "Cancel" "button" in the "Delete content" "dialogue"
-    And I should see "filltheblanks.h5p"
+    Then I should see "filltheblanks.h5p"
     And I open the action menu in "region-main-settings-menu" "region"
     And I choose "Delete" in the open action menu
     And I click on "Delete" "button" in the "Delete content" "dialogue"
@@ -68,8 +66,5 @@ Feature: Delete H5P file from the content bank
     And I click on "find-the-words.h5p" "link"
     And I click on "Select this file" "button"
     And I click on "Save changes" "button"
-    And I should see "filltheblanks.h5p"
-    And I should see "find-the-words.h5p"
-    And I follow "find-the-words.h5p"
     And I open the action menu in "region-main-settings-menu" "region"
     And I should see "Delete"
diff --git a/contentbank/tests/behat/search_content.feature b/contentbank/tests/behat/search_content.feature
new file mode 100644 (file)
index 0000000..940415f
--- /dev/null
@@ -0,0 +1,51 @@
+@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+Feature: Search content in the content bank
+  In order to find easily content in the content bank
+  As an admin
+  I need to be able to search content in the content bank
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextid | contenttype       | user  | contentname          |
+        | 1         | contenttype_h5p   | admin | santjordi.h5p        |
+        | 1         | contenttype_h5p   | admin | santjordi_rose.h5p   |
+        | 1         | contenttype_h5p   | admin | SantJordi_book       |
+        | 1         | contenttype_h5p   | admin | Dragon_santjordi.h5p |
+        | 1         | contenttype_h5p   | admin | princess.h5p         |
+        | 1         | contenttype_h5p   | admin | mathsbook.h5p        |
+        | 1         | contenttype_h5p   | admin | historybook.h5p      |
+        | 1         | contenttype_h5p   | admin | santvicenc.h5p       |
+
+  Scenario: Admins can search content in the content bank
+    Given I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "santjordi.h5p"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    When I set the field "Search" to "book"
+    Then "Clear search input" "button" should exist
+    And I should see "3 items found"
+    And I should see "SantJordi_book"
+    And I should see "mathsbook.h5p"
+    And I should see "historybook.h5p"
+    And I set the field "Search" to "sant"
+    And "Clear search input" "button" should exist
+    And I should see "5 items found"
+    And I set the field "Search" to "santjordi"
+    And I should see "4 items found"
+    And I should see "santjordi.h5p"
+    And I should see "santjordi_rose.h5p"
+    And I should see "SantJordi_book"
+    And I should see "Dragon_santjordi.h5p"
+    And I click on "Clear search input" "button"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    And I set the field "Search" to ".h5p"
+    And "Clear search input" "button" should exist
+    And I should see "7 items found"
+    And I set the field "Search" to "friend"
+    And I should see "0 items found"
index 2fcd66c..75d44c9 100644 (file)
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
-
 use stdClass;
 use context_system;
 use contenttype_testable\contenttype as contenttype;
+
 /**
  * Test for content bank contenttype class.
  *
@@ -46,6 +41,16 @@ use contenttype_testable\contenttype as contenttype;
  */
 class core_contenttype_content_testcase extends \advanced_testcase {
 
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
     /**
      * Tests for behaviour of get_name().
      *
index e420456..1999559 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-defined('MOODLE_INTERNAL') || die();
+namespace core_contentbank;
 
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+use advanced_testcase;
+use context_course;
+use context_system;
 
 /**
  * Test for extensions manager.
@@ -38,6 +39,16 @@ require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.p
  * @coversDefaultClass \core_contentbank\contentbank
  */
 class core_contentbank_testcase extends advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+    }
+
     /**
      * Data provider for test_get_extension_supporter.
      *
@@ -62,7 +73,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension(string $filename, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
 
         $extension = $cb->get_extension($filename);
         $this->assertEquals($expected, $extension);
@@ -93,7 +104,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_admins(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $expectedsupporters = [$extension => $expected];
 
         $systemcontext = context_system::instance();
@@ -117,7 +128,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_users(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $systemcontext = context_system::instance();
 
         // Set a user with no permissions.
@@ -142,7 +153,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_teachers(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $expectedsupporters = [$extension => $expected];
 
         $course = $this->getDataGenerator()->create_course();
@@ -168,7 +179,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $systemcontext = context_system::instance();
         $this->setAdminUser();
 
@@ -185,7 +196,8 @@ class core_contentbank_testcase extends advanced_testcase {
      * @param  int $expectedresult Expected result.
      * @param  array $contexts List of contexts where to create content.
      */
-    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = []): void {
+    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = [],
+            array $contenttypes = null): void {
         global $DB;
 
         $this->resetAfterTest();
@@ -218,8 +230,8 @@ class core_contentbank_testcase extends advanced_testcase {
         }
 
         // Search for some content.
-        $cb = new \core_contentbank\contentbank();
-        $contents = $cb->search_contents($search, $contextid);
+        $cb = new contentbank();
+        $contents = $cb->search_contents($search, $contextid, $contenttypes);
 
         $this->assertCount($expectedresult, $contents);
         if (!empty($contents) && !empty($search)) {
@@ -321,6 +333,13 @@ class core_contentbank_testcase extends advanced_testcase {
                 0,
                 []
             ],
+            'Search with unexisting content-type' => [
+                null,
+                'course',
+                0,
+                ['system', 'category', 'course'],
+                ['contenttype_unexisting'],
+            ],
         ];
     }
 
@@ -350,7 +369,7 @@ class core_contentbank_testcase extends advanced_testcase {
         $fs = get_file_storage();
         $dummyh5pfile = $fs->create_file_from_string($dummyh5p, 'Dummy H5Pcontent');
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $content = $cb->create_content_from_file($systemcontext, $USER->id, $dummyh5pfile);
 
         $this->assertEquals('contenttype_h5p', $content->get_content_type());
index e74bd1b..982d2f1 100644 (file)
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
-
 use stdClass;
 use context_system;
 use contenttype_testable\contenttype as contenttype;
@@ -64,6 +58,16 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
     /** @var contenttype The contenttype instance. */
     protected $contenttype;
 
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
     /**
      * Tests get_contenttype_name result.
      *
@@ -102,7 +106,10 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
 
         $systemcontext = \context_system::instance();
         $testable = new contenttype($systemcontext);
-        $icon = $testable->get_icon('new content');
+        $record = new stdClass();
+        $record->name = 'New content';
+        $content = $testable->create_content($record);
+        $icon = $testable->get_icon($content);
         $this->assertContains('archive', $icon);
     }
 
index fd35f45..262e7e6 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core_contentbank;
+namespace core_contentbank\external;
 
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
 
-use core_contentbank\external\delete_content;
 use dml_missing_record_exception;
 use external_api;
 use externallib_advanced_testcase;
index 4b1bf6b..6a9ea67 100644 (file)
@@ -24,7 +24,7 @@
  * @since      Moodle 3.9
  */
 
-namespace core_contentbank;
+namespace core_contentbank\external;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -33,9 +33,6 @@ require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.p
 require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
 
-use core_contentbank\external\delete_content;
-use core_contentbank\external\external;
-use core_contentbank\external\rename_content;
 use external_api;
 
 /**
index 850d4ef..f279481 100644 (file)
@@ -40,11 +40,11 @@ class contenttype extends \core_contentbank\contenttype {
     /**
      * Returns the URL where the content will be visualized.
      *
-     * @param stdClass $record  Th content to be displayed.
+     * @param  content $content The content to delete.
      * @return string            URL where to visualize the given content.
      */
-    public function get_view_url(\stdClass $record): string {
-        $fileurl = $this->get_file_url($record->id);
+    public function get_view_url(\core_contentbank\content $content): string {
+        $fileurl = $this->get_file_url($content->get_id());
         $url = $fileurl."?forcedownload=1";
 
         return $url;
@@ -53,13 +53,13 @@ class contenttype extends \core_contentbank\contenttype {
     /**
      * Returns the HTML code to render the icon for content bank contents.
      *
-     * @param string $contentname   The contentname to add as alt value to the icon.
+     * @param  content $content The content to delete.
      * @return string               HTML code to render the icon
      */
-    public function get_icon(string $contentname): string {
+    public function get_icon(\core_contentbank\content $content): string {
         global $OUTPUT;
 
-        return $OUTPUT->pix_icon('f/archive-64', $contentname, 'moodle', ['class' => 'iconsize-big']);
+        return $OUTPUT->image_url('f/archive-64', 'moodle')->out(false);
     }
 
     /**
index 09c3f0f..e737117 100644 (file)
@@ -25,8 +25,6 @@
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
 use stdClass;
 use context_system;
 use context_coursecat;
index 0a88a69..371d27f 100644 (file)
@@ -80,6 +80,8 @@ if ($mform->is_cancelled()) {
         $file = reset($files);
         $content = $cb->create_content_from_file($context, $USER->id, $file);
         file_save_draft_area_files($formdata->file, $contextid, 'contentbank', 'public', $content->get_id());
+        $viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
+        redirect($viewurl);
     }
     redirect($returnurl);
 }
index d34205c..c95d7fd 100644 (file)
@@ -118,7 +118,7 @@ if ($errormsg !== '') {
     echo $OUTPUT->notification($statusmsg, 'notifysuccess');
 }
 if ($contenttype->can_access()) {
-    echo $contenttype->get_view_content($record);
+    echo $contenttype->get_view_content($content);
 }
 
 echo $OUTPUT->box_end();
index 3bf1b45..b3f553f 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 90b5d87..78345bc 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
diff --git a/course/amd/build/copy_modal.min.js b/course/amd/build/copy_modal.min.js
new file mode 100644 (file)
index 0000000..e67d350
Binary files /dev/null and b/course/amd/build/copy_modal.min.js differ
diff --git a/course/amd/build/copy_modal.min.js.map b/course/amd/build/copy_modal.min.js.map
new file mode 100644 (file)
index 0000000..3ec3b14
Binary files /dev/null and b/course/amd/build/copy_modal.min.js.map differ
index ae2b3bb..abfc5d3 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index acc2129..355a979 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index d3b83d0..a66560e 100644 (file)
@@ -78,7 +78,6 @@ const registerListenerEvents = (courseId) => {
     events.forEach((event) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
-                const data = await fetchModuleData();
                 // We need to know who called this.
                 // Standard courses use the ID in the main section info.
                 const sectionDiv = e.target.closest(selectors.elements.section);
@@ -86,11 +85,31 @@ const registerListenerEvents = (courseId) => {
                 const button = e.target.closest(selectors.elements.sectionmodchooser);
                 // If we don't have a section ID use the fallback ID.
                 const caller = sectionDiv || button;
-                const favouriteFunction = partiallyAppliedFavouriteManager(data, caller.dataset.sectionid);
+
+                // We want to show the modal instantly but loading whilst waiting for our data.
+                let bodyPromiseResolver;
+                const bodyPromise = new Promise(resolve => {
+                    bodyPromiseResolver = resolve;
+                });
+
+                const sectionModal = buildModal(bodyPromise);
+
+                // Now we have a modal we should start fetching data.
+                const data = await fetchModuleData();
+
+                // Apply the section id to all the module instance links.
                 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);
-                const sectionModal = await modalBuilder(builtModuleData);
 
-                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData, favouriteFunction);
+                ChooserDialogue.displayChooser(
+                    sectionModal,
+                    builtModuleData,
+                    partiallyAppliedFavouriteManager(data, caller.dataset.sectionid),
+                );
+
+                bodyPromiseResolver(await Templates.render(
+                    'core_course/activitychooser',
+                    templateDataBuilder(builtModuleData)
+                ));
             }
         });
     });
@@ -102,7 +121,7 @@ const registerListenerEvents = (courseId) => {
  *
  * @method sectionIdMapper
  * @param {Object} webServiceData Our original data from the Web service call
- * @param {Array} id The ID of the section we need to append to the links
+ * @param {Number} id The ID of the section we need to append to the links
  * @return {Array} [modules] with URL's built
  */
 const sectionIdMapper = (webServiceData, id) => {
@@ -114,15 +133,6 @@ const sectionIdMapper = (webServiceData, id) => {
     return newData.content_items;
 };
 
-/**
- * Build a modal on demand to save page load times
- *
- * @method modalBuilder
- * @param {Array} data our array of modules with section ID's applied in the URL field
- * @return {Object} Our modal that we are going to show the user
- */
-const modalBuilder = data => buildModal(templateDataBuilder(data));
-
 /**
  * Given an array of modules we want to figure out where & how to place them into our template object
  *
@@ -158,18 +168,22 @@ const templateDataBuilder = (data) => {
  * Given an object we want to build a modal ready to show
  *
  * @method buildModal
- * @param {Object} data The template data which contains arrays of modules
- * @return {Object} The modal for the calling section with everything already set up
+ * @param {Promise} bodyPromise
+ * @return {Object} The modal ready to display immediately and render body in later.
  */
-const buildModal = data => {
+const buildModal = bodyPromise => {
     return ModalFactory.create({
         type: ModalFactory.types.DEFAULT,
         title: getString('addresourceoractivity'),
-        body: Templates.render('core_course/activitychooser', data),
+        body: bodyPromise,
         large: true,
         templateContext: {
             classes: 'modchooser'
         }
+    })
+    .then(modal => {
+        modal.show();
+        return modal;
     });
 };
 
@@ -240,6 +254,7 @@ const partiallyAppliedFavouriteManager = (moduleData, sectionId) => {
             if (favourite) {
                 result.favourite = true;
 
+                // eslint-disable-next-line camelcase
                 newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
 
                 const builtFaves = sectionIdMapper(newFaves, sectionId);
diff --git a/course/amd/src/copy_modal.js b/course/amd/src/copy_modal.js
new file mode 100644 (file)
index 0000000..dc1644c
--- /dev/null
@@ -0,0 +1,190 @@
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * This module provides the course copy modal from the course and
+ * category management screen.
+ *
+ * @module     course
+ * @package    core
+ * @copyright  2020 onward The Moodle Users Association <https://moodleassociation.org/>
+ * @author     Matt Porritt <mattp@catalyst-au.net>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @since      3.9
+ */
+
+define(['jquery', 'core/str', 'core/modal_factory', 'core/modal_events',
+        'core/ajax', 'core/fragment', 'core/notification', 'core/config'],
+        function($, Str, ModalFactory, ModalEvents, ajax, Fragment, Notification, Config) {
+
+    /**
+     * Module level variables.
+     */
+    var CopyModal = {};
+    var contextid;
+    var course;
+    var modalObj;
+    var spinner = '<p class="text-center">'
+        + '<i class="fa fa-spinner fa-pulse fa-2x fa-fw"></i>'
+        + '</p>';
+
+    /**
+     * Creates the modal for the course copy form
+     *
+     * @private
+     */
+    function createModal() {
+        // Get the Title String.
+        Str.get_string('loading').then(function(title) {
+            // Create the Modal.
+            ModalFactory.create({
+                type: ModalFactory.types.DEFAULT,
+                title: title,
+                body: spinner,
+                large: true
+            })
+            .done(function(modal) {
+                modalObj = modal;
+                // Explicitly handle form click events.
+                modalObj.getRoot().on('click', '#id_submitreturn', processModalForm);
+                modalObj.getRoot().on('click', '#id_submitdisplay', function(e) {
+                    e.formredirect = true;
+                    processModalForm(e);
+
+                });
+                modalObj.getRoot().on('click', '#id_cancel', function(e) {
+                    e.preventDefault();
+                    modalObj.setBody(spinner);
+                    modalObj.hide();
+                });
+            });
+            return;
+        }).catch(function() {
+            Notification.exception(new Error('Failed to load string: loading'));
+        });
+    }
+
+    /**
+     * Updates the body of the modal window.
+     *
+     * @param {Object} formdata
+     * @private
+     */
+    function updateModalBody(formdata) {
+        if (typeof formdata === "undefined") {
+            formdata = {};
+        }
+
+        var params = {
+                'jsonformdata': JSON.stringify(formdata),
+                'courseid': course.id
+        };
+
+        modalObj.setBody(spinner);
+        Str.get_string('copycoursetitle', 'backup', course.shortname).then(function(title) {
+            modalObj.setTitle(title);
+            modalObj.setBody(Fragment.loadFragment('course', 'new_base_form', contextid, params));
+            return;
+        }).catch(function() {
+            Notification.exception(new Error('Failed to load string: copycoursetitle'));
+        });
+    }
+
+    /**
+     * Updates Moodle form with selected information.
+     *
+     * @param {Object} e
+     * @private
+     */
+    function processModalForm(e) {
+        e.preventDefault(); // Stop modal from closing.
+
+        // Form data.
+        var copyform = modalObj.getRoot().find('form').serialize();
+        var formjson = JSON.stringify(copyform);
+
+        // Handle invalid form fields for better UX.
+        var invalid = $.merge(
+                modalObj.getRoot().find('[aria-invalid="true"]'),
+                modalObj.getRoot().find('.error')
+        );
+
+        if (invalid.length) {
+            invalid.first().focus();
+            return;
+        }
+
+        // Submit form via ajax.
+        ajax.call([{
+            methodname: 'core_backup_submit_copy_form',
+            args: {
+                jsonformdata: formjson
+            },
+        }])[0].done(function() {
+            // For submission succeeded.
+            modalObj.setBody(spinner);
+            modalObj.hide();
+
+            if (e.formredirect == true) {
+                // We are redirecting to copy progress display.
+                let redirect = Config.wwwroot + "/backup/copyprogress.php?id=" + course.id;
+                window.location.assign(redirect);
+            }
+
+        }).fail(function() {
+            // Form submission failed server side, redisplay with errors.
+            updateModalBody(copyform);
+        });
+    }
+
+    /**
+     * Initialise the class.
+     *
+     * @param {Object} context
+     * @public
+     */
+    CopyModal.init = function(context) {
+        contextid = context;
+        // Setup the initial Modal.
+        createModal();
+
+        // Setup the click handlers on the copy buttons.
+        $('.action-copy').on('click', function(e) {
+            e.preventDefault(); // Stop. Hammer time.
+            let url = new URL(this.getAttribute('href'));
+            let params = new URLSearchParams(url.search);
+            let courseid = params.get('id');
+
+            ajax.call([{ // Get the course information.
+                methodname: 'core_course_get_courses',
+                args: {
+                    'options': {'ids': [courseid]},
+                },
+            }])[0].done(function(response) {
+                // We have the course info get the modal content.
+                course = response[0];
+                updateModalBody();
+
+            }).fail(function() {
+                Notification.exception(new Error('Failed to load course'));
+            });
+
+            modalObj.show();
+        });
+
+    };
+
+    return CopyModal;
+});
index 6a476cf..42a19b3 100644 (file)
@@ -42,6 +42,7 @@ import {debounce} from 'core/utils';
 const showModuleHelp = (carousel, moduleData) => {
     const help = carousel.find(selectors.regions.help)[0];
     help.innerHTML = '';
+    help.classList.add('m-auto');
 
     // Add a spinner.
     const spinnerPromise = addIconToContainer(help);
@@ -483,6 +484,37 @@ const searchModules = (modules, searchTerm) => {
     return searchResults;
 };
 
+/**
+ * Set up our tabindex information across the chooser.
+ *
+ * @method setupKeyboardAccessibility
+ * @param {Promise} modal Our created modal for the section
+ * @param {Map} mappedModules A map of all of the built module information
+ */
+const setupKeyboardAccessibility = (modal, mappedModules) => {
+    modal.getModal()[0].tabIndex = -1;
+
+    modal.getBodyPromise().then(body => {
+        $(selectors.elements.tab).on('shown.bs.tab', (e) => {
+            const activeSectionId = e.target.getAttribute("href");
+            const activeSectionChooserOptions = body[0]
+                .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
+            const firstChooserOption = activeSectionChooserOptions
+                .querySelector(selectors.regions.chooserOption.container);
+            const prevActiveSectionId = e.relatedTarget.getAttribute("href");
+            const prevActiveSectionChooserOptions = body[0]
+                .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
+
+            // Disable the focus of every chooser option in the previous active section.
+            disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
+            // Enable the focus of the first chooser option in the current active section.
+            toggleFocusableChooserOption(firstChooserOption, true);
+            initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);
+        });
+        return;
+    }).catch(Notification.exception);
+};
+
 /**
  * Disable the focus of all chooser options in a specific container (section).
  *
@@ -500,13 +532,11 @@ const disableFocusAllChooserOptions = (sectionChooserOptions) => {
  * Display the module chooser.
  *
  * @method displayChooser
- * @param {HTMLElement} origin The calling button
- * @param {Object} modal Our created modal for the section
+ * @param {Promise} modalPromise Our created modal for the section
  * @param {Array} sectionModules An array of all of the built module information
  * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-export const displayChooser = (origin, modal, sectionModules, partialFavourite) => {
-
+export const displayChooser = (modalPromise, sectionModules, partialFavourite) => {
     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
     const mappedModules = new Map();
     sectionModules.forEach((module) => {
@@ -514,39 +544,18 @@ export const displayChooser = (origin, modal, sectionModules, partialFavourite)
     });
 
     // Register event listeners.
-    registerListenerEvents(modal, mappedModules, partialFavourite);
+    modalPromise.then(modal => {
+        registerListenerEvents(modal, mappedModules, partialFavourite);
 
-    // We want to focus on the action select when the dialog is closed.
-    modal.getRoot().on(ModalEvents.hidden, () => {
-        modal.destroy();
-    });
+        // We want to focus on the first chooser option element as soon as the modal is opened.
+        setupKeyboardAccessibility(modal, mappedModules);
 
-    // We want to focus on the first chooser option element as soon as the modal is opened.
-    modal.getRoot().on(ModalEvents.shown, () => {
-        modal.getModal()[0].tabIndex = -1;
-
-        modal.getBodyPromise()
-        .then(body => {
-            $(selectors.elements.tab).on('shown.bs.tab', (e) => {
-                const activeSectionId = e.target.getAttribute("href");
-                const activeSectionChooserOptions = body[0]
-                    .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
-                const firstChooserOption = activeSectionChooserOptions
-                    .querySelector(selectors.regions.chooserOption.container);
-                const prevActiveSectionId = e.relatedTarget.getAttribute("href");
-                const prevActiveSectionChooserOptions = body[0]
-                    .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
-
-                // Disable the focus of every chooser option in the previous active section.
-                disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
-                // Enable the focus of the first chooser option in the current active section.
-                toggleFocusableChooserOption(firstChooserOption, true);
-                initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);
-            });
-            return;
-        })
-        .catch(Notification.exception);
-    });
+        // We want to focus on the action select when the dialog is closed.
+        modal.getRoot().on(ModalEvents.hidden, () => {
+            modal.destroy();
+        });
 
-    modal.show();
+        return modal;
+    })
+    .catch();
 };
index d055ddc..c4b9535 100644 (file)
@@ -375,6 +375,14 @@ class helper {
                 'attributes' => array('class' => 'action-edit')
             );
         }
+        // Copy.
+        if (self::can_copy_course($course->id)) {
+            $actions[] = array(
+                'url' => new \moodle_url('/backup/copy.php', array('id' => $course->id, 'returnto' => 'catmanage')),
+                'icon' => new \pix_icon('t/copy', \get_string('copycourse')),
+                'attributes' => array('class' => 'action-copy')
+            );
+        }
         // Delete.
         if ($course->can_delete()) {
             $actions[] = array(
@@ -996,4 +1004,24 @@ class helper {
             return array($parent);
         }
     }
+
+    /**
+     * Get an array of the capabilities required to copy a course.
+     *
+     * @return array
+     */
+    public static function get_course_copy_capabilities(): array {
+        return array('moodle/backup:backupcourse', 'moodle/restore:restorecourse', 'moodle/course:view', 'moodle/course:create');
+    }
+
+    /**
+     * Returns true if the current user can copy this course.
+     *
+     * @param int $courseid
+     * @return bool
+     */
+    public static function can_copy_course(int $courseid): bool {
+        $coursecontext = \context_course::instance($courseid);
+        return has_all_capabilities(self::get_course_copy_capabilities(), $coursecontext);
+    }
 }
index 7268824..5e783d5 100644 (file)
@@ -4028,7 +4028,6 @@ function course_get_user_administration_options($course, $context) {
     $isfrontpage = $course->id == SITEID;
     $completionenabled = $CFG->enablecompletion && $course->enablecompletion;
     $hascompletiontabs = count(core_completion\manager::get_available_completion_tabs($course, $context)) > 0;
-
     $options = new stdClass;
     $options->update = has_capability('moodle/course:update', $context);
     $options->editcompletion = $CFG->enablecompletion &&
@@ -4039,6 +4038,7 @@ function course_get_user_administration_options($course, $context) {
     $options->reports = has_capability('moodle/site:viewreports', $context);
     $options->backup = has_capability('moodle/backup:backupcourse', $context);
     $options->restore = has_capability('moodle/restore:restorecourse', $context);
+    $options->copy = \core_course\management\helper::can_copy_course($course->id);
     $options->files = ($course->legacyfiles == 2 && has_capability('moodle/course:managefiles', $context));
 
     if (!$isfrontpage) {
@@ -4944,3 +4944,40 @@ function course_get_course_dates_for_user_ids(stdClass $course, array $userids):
 function course_get_course_dates_for_user_id(stdClass $course, int $userid): array {
     return (course_get_course_dates_for_user_ids($course, [$userid]))[$userid];
 }
+
+/**
+ * Renders the course copy form for the modal on the course management screen.
+ *
+ * @param array $args
+ * @return string $o Form HTML.
+ */
+function course_output_fragment_new_base_form($args) {
+
+    $serialiseddata = json_decode($args['jsonformdata'], true);
+    $formdata = [];
+    if (!empty($serialiseddata)) {
+        parse_str($serialiseddata, $formdata);
+    }
+
+    $context = context_course::instance($args['courseid']);
+    $copycaps = \core_course\management\helper::get_course_copy_capabilities();
+    require_all_capabilities($copycaps, $context);
+
+    $course = get_course($args['courseid']);
+    $mform = new \core_backup\output\copy_form(
+        null,
+        array('course' => $course, 'returnto' => '', 'returnurl' => ''),
+        'post', '', ['class' => 'ignoredirty'], true, $formdata);
+
+    if (!empty($serialiseddata)) {
+        // If we were passed non-empty form data we want the mform to call validation functions and show errors.
+        $mform->is_validated();
+    }
+
+    ob_start();
+    $mform->display();
+    $o = ob_get_contents();
+    ob_end_clean();
+
+    return $o;
+}
index ad74a3e..39ad5a7 100644 (file)
@@ -107,6 +107,7 @@ $PAGE->set_url($url);
 $PAGE->set_pagelayout('admin');
 $PAGE->set_title($strmanagement);
 $PAGE->set_heading($pageheading);
+$PAGE->requires->js_call_amd('core_course/copy_modal', 'init', array($context->id));
 
 // This is a system level page that operates on other contexts.
 require_login();
index 96902d1..7b2fccc 100644 (file)
@@ -2283,7 +2283,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $adminoptions->{$option['name']} = $option['available'];
             }
             if ($course['id'] == SITEID) {
-                $this->assertCount(16, $course['options']);
+                $this->assertCount(17, $course['options']);
                 $this->assertFalse($adminoptions->update);
                 $this->assertFalse($adminoptions->filters);
                 $this->assertFalse($adminoptions->reports);
@@ -2298,8 +2298,9 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertFalse($adminoptions->reset);
                 $this->assertFalse($adminoptions->roles);
                 $this->assertFalse($adminoptions->editcompletion);
+                $this->assertFalse($adminoptions->copy);
             } else {
-                $this->assertCount(14, $course['options']);
+                $this->assertCount(15, $course['options']);
                 $this->assertFalse($adminoptions->update);
                 $this->assertFalse($adminoptions->filters);
                 $this->assertFalse($adminoptions->reports);
@@ -2314,6 +2315,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
                 $this->assertFalse($adminoptions->reset);
                 $this->assertFalse($adminoptions->roles);
                 $this->assertFalse($adminoptions->editcompletion);
+                $this->assertFalse($adminoptions->copy);
             }
         }
     }
index 6d65bc9..611ed6c 100644 (file)
     $PAGE->set_heading($course->fullname);
     echo $OUTPUT->header();
 
-    if ($USER->editing == 1 && !empty($CFG->enableasyncbackup)) {
+    if ($USER->editing == 1) {
 
         // MDL-65321 The backup libraries are quite heavy, only require the bare minimum.
         require_once($CFG->dirroot . '/backup/util/helper/async_helper.class.php');
index b86acad..81c7625 100644 (file)
@@ -487,4 +487,19 @@ class api {
         $pathnamehash = $fs->get_pathname_hash($contextid, $component, $filearea, $itemid, $filepath, $filename);
         return $pathnamehash;
     }
+
+    /**
+     * Returns the H5P content object corresponding to an H5P content file.
+     *
+     * @param string $pathnamehash The pathnamehash of the file associated to an H5P content.
+     *
+     * @return null|\stdClass H5P content object or null if not found.
+     */
+    public static function get_content_from_pathnamehash(string $pathnamehash): ?\stdClass {
+        global $DB;
+
+        $h5p = $DB->get_record('h5p', ['pathnamehash' => $pathnamehash]);
+
+        return ($h5p) ? $h5p : null;
+    }
 }
index 9ad479f..eed3774 100644 (file)
@@ -378,15 +378,10 @@ class editor {
         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-editor.js' . $cachebuster), true);
         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-init.js' . $cachebuster), true);
 
-        // Add translations.
+        // Load editor translations.
         $language = framework::get_language();
-        $languagescript = "language/{$language}.js";
-
-        if (!file_exists("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript))) {
-            $languagescript = 'language/en.js';
-        }
-        $PAGE->requires->js(autoloader::get_h5p_editor_library_url($languagescript . $cachebuster),
-            true);
+        $editorstrings = $this->get_editor_translations($language);
+        $PAGE->requires->data_for_js('H5PEditor.language.core', $editorstrings, false);
 
         // Add JavaScript settings.
         $root = $CFG->wwwroot;
@@ -423,6 +418,47 @@ class editor {
         $PAGE->requires->data_for_js('H5PIntegration', $settings, true);
     }
 
+    /**
+     * Get editor translations for the defined language.
+     * Check if the editor strings have been translated in Moodle.
+     * If the strings exist, they will override the existing ones in the JS file.
+     *
+     * @param string $language The language for the translations to be returned.
+     * @return array The editor string translations.
+     */
+    private function get_editor_translations(string $language): array {
+        global $CFG;
+
+        // Add translations.
+        $languagescript = "language/{$language}.js";
+
+        if (!file_exists("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript))) {
+            $languagescript = 'language/en.js';
+        }
+
+        // Check if the editor strings have been translated in Moodle.
+        // If the strings exist, they will override the existing ones in the JS file.
+
+        // Get existing strings from current JS language file.
+        $langcontent = file_get_contents("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript));
+
+        // Get only the content between { } (for instance, ; at the end of the file has to be removed).
+        $langcontent = substr($langcontent, 0, strpos($langcontent, '}', -0) + 1);
+        $langcontent = substr($langcontent, strpos($langcontent, '{'));
+
+        // Parse the JS language content and get a PHP array.
+        $editorstrings = helper::parse_js_array($langcontent);
+        foreach ($editorstrings as $key => $value) {
+            $stringkey = 'editor:'.strtolower(trim($key));
+            $value = autoloader::get_h5p_string($stringkey, $language);
+            if (!empty($value)) {
+                $editorstrings[$key] = $value;
+            }
+        }
+
+        return $editorstrings;
+    }
+
     /**
      * Preprocess the data sent through the form to the H5P JS Editor Library.
      *
index bac7950..c4b575c 100644 (file)
@@ -62,7 +62,14 @@ class editor_framework implements H5peditorStorage {
         $librarykey = helper::get_cache_librarykey(core::record_to_string($library));
         $cachekey = "{$librarykey}/{$lang}";
         $translation = $langcache->get($cachekey);
-        if ($translation) {
+
+        if ($translation !== false) {
+            // When there is no translation we store it in the cache as `null`.
+            // This API requires it be returned as `false`.
+            if ($translation === null) {
+                return false;
+            }
+
             return $translation;
         }
 
@@ -88,14 +95,19 @@ class editor_framework implements H5peditorStorage {
 
         $result = $DB->get_record_sql($sql, $params);
 
-        if (!empty($result)) {
-            // If the JS language file exists, its content should be returned.
-            $fs = get_file_storage();
-            $file = $fs->get_file_by_hash($result->pathnamehash);
-            $translation = $file->get_content();
+        if (empty($result)) {
+            // Save the fact that there is no translation into the cache.
+            // The cache API cannot handle setting a literal `false` value so conver to `null` instead.
+            $langcache->set($cachekey, null);
+
+            return false;
         }
 
-        // Save translation into the cache (even if there is no translation for this language).
+        // Save translation into the cache, and return its content.
+        $fs = get_file_storage();
+        $file = $fs->get_file_by_hash($result->pathnamehash);
+        $translation = $file->get_content();
+
         $langcache->set($cachekey, $translation);
 
         return $translation;
index 41b1c9d..93780fa 100644 (file)
@@ -82,7 +82,6 @@ class helper {
         return false;
     }
 
-
     /**
      * Get the error messages stored in our H5P framework.
      *
@@ -410,4 +409,24 @@ class helper {
         // Remove whitespaces and replace '.' to '_'.
         return str_replace('.', '_', str_replace(' ', '', $library));
     }
+
+    /**
+     * Parse a JS array to a PHP array.
+     *
+     * @param  string $jscontent The JS array to parse to PHP array.
+     * @return array The JS array converted to PHP array.
+     */
+    public static function parse_js_array(string $jscontent): array {
+        $jsarray = preg_split('/,\n\s+/', substr($jscontent, 0, -1));
+        $jsarray = preg_replace('~{?\\n~', '', $jsarray);
+
+        $strings = [];
+        foreach ($jsarray as $key => $value) {
+            $splitted = explode(":", $value, 2);
+            $value = preg_replace("/^['|\"](.*)['|\"]$/", "$1", trim($splitted[1], ' ,'));
+            $strings[ trim($splitted[0]) ] = str_replace("\'", "'", $value);
+        }
+
+        return $strings;
+    }
 }
index 27b5f4e..0f7324b 100644 (file)
@@ -149,6 +149,17 @@ class autoloader {
         return component_class_callback(self::get_handler_classname(), 'get_h5p_editor_library_base', [$filepath]);
     }
 
+    /**
+     * Returns a localized string, if it exists in the h5plib plugin and the value it's different from the English version.
+     *
+     * @param string $identifier The key identifier for the localized string
+     * @param string $language Language to get the localized string.
+     * @return string|null The localized string or null if it doesn't exist in this H5P library plugin.
+     */
+    public static function get_h5p_string(string $identifier, string $language): ?string {
+        return component_class_callback(self::get_handler_classname(), 'get_h5p_string', [$identifier, $language]);
+    }
+
     /**
      * Register the H5P autoloader.
      */
index ca59674..c9c5389 100644 (file)
@@ -123,6 +123,31 @@ abstract class handler {
         return new \moodle_url(static::get_h5p_editor_library_base($filepath), $params);
     }
 
+    /**
+     * Returns a localized string, if it exists in the h5plib plugin and the value it's different from the English version.
+     *
+     * @param string $identifier The key identifier for the localized string
+     * @param string $language Language to get the localized string.
+     * @return string|null The localized string or null if it doesn't exist in this H5P library plugin.
+     */
+    public static function get_h5p_string(string $identifier, string $language): ?string {
+        $value = null;
+        $h5pversion = static::get_h5p_version();
+        $component = 'h5plib_v' . $h5pversion;
+        if (get_string_manager()->string_exists($identifier, $component)) {
+            $defaultmoodlelang = 'en';
+            // In Moodle, all the English strings always will exist because they have to be declared in order to let users
+            // to translate them. That's why, this method will only replace existing key if the value is different from
+            // the English version and the current language is not English.
+            $string = new \lang_string($identifier, $component);
+            if ($language === $defaultmoodlelang || $string->out($language) !== $string->out($defaultmoodlelang)) {
+                $value = $string->out($language);
+            }
+        }
+
+        return $value;
+    }
+
     /**
      * Return the list of classes with their location within the joubel directory.
      *
index 34f3c9f..e030a16 100644 (file)
@@ -8,6 +8,13 @@ Import procedure:
 - Copy all the files from the folder repository in this directory.
 - In the method ns.LibrarySelector.prototype.appendTo (scripts/h5peditor-library-selector.js),
   comment the line "this.$selector.appendTo($element);" to avoid the display of the Hub Selector.
+- Review strings in joubel/editor/language/en.js and compare them with
+existing ones in lang/en/h5plib_vXXX.php: add the new ones and remove the
+unexisting ones. Remember to use the AMOS script commands, such CPY, to copy
+all the existing strings from the previous version. As you'll see, all the
+strings in en.js have been converted following these rules:
+  * Prefix  "editor:" has been added.
+  * Keys have been lowercased.
 
 Removed:
  * composer.json
index 222f128..10e1f71 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['editor:atoz'] = 'A to Z';
+$string['editor:add'] = 'Add';
+$string['editor:addauthor'] = 'Save author';
+$string['editor:addentity'] = 'Add :entity';
+$string['editor:addfile'] = 'Add file';
+$string['editor:addnewchange'] = 'Add new change';
+$string['editor:addtitle'] = 'Add title';
+$string['editor:addvideodescription'] = 'H5P supports all external video sources formatted as mp4, webm or ogv, like Vimeo Pro, and has support for YouTube links.';
+$string['editor:avtablistlabel'] = 'Insert using';
+$string['editor:belowmin'] = 'The :property value is below the minimum of :min.';
+$string['editor:cancel'] = 'Cancel';
+$string['editor:cancellabel'] = 'Cancel';
+$string['editor:changefile'] = 'Change file';
+$string['editor:changelanguage'] = 'Change language to :language?';
+$string['editor:changelibrary'] = 'Change content type?';
+$string['editor:changelogdescription'] = 'Some licenses require that changes made to the original work, or derivatives are logged and displayed. You may log your changes here for licensing reasons or just to allow yourself and others to keep track of the changes made to this content.';
+$string['editor:close'] = 'Close';
+$string['editor:commonfields'] = 'Text overrides and translations';
+$string['editor:commonfieldsdescription'] = 'Here you can edit settings or translate texts used in this content.';
+$string['editor:confirmchangelibrary'] = 'By doing this you will lose all work done with the current content type. Are you sure you wish to change content type?';
+$string['editor:confirmdeletechangelog'] = 'Are you sure you would like to delete this change log entry?';
+$string['editor:confirmimageremoval'] = 'This will remove your image. Are you sure you wish to proceed?';
+$string['editor:confirmpastebuttontext'] = 'Replace content';
+$string['editor:confirmpastecontent'] = 'By doing this you will replace the current content with the content from your clipboard. The current content will be lost. Are you sure you wish to continue?';
+$string['editor:confirmremoval'] = 'Are you sure you wish to remove this :type?';
+$string['editor:confirmremoveauthor'] = 'Are you sure you would like to remove this author?';
+$string['editor:contenttypebackbuttonlabel'] = 'Back';
+$string['editor:contenttypecacheoutdated'] = 'Content type list outdated';
+$string['editor:contenttypecacheoutdateddesc'] = 'Your site is having difficulties connecting to H5P.org to check for content type updates. You may not be able to update or install new content types.';
+$string['editor:contenttypedemobuttonlabel'] = 'Content Demo';
+$string['editor:contenttypedetailbuttonlabel'] = 'Details';
+$string['editor:contenttypegetbuttonlabel'] = 'Get';
+$string['editor:contenttypeiconalttext'] = 'Icon';
+$string['editor:contenttypeinstallbuttonlabel'] = 'Install';
+$string['editor:contenttypeinstallerror'] = ':contentType could not be installed. Contact your administrator.';
+$string['editor:contenttypeinstallsuccess'] = ':contentType successfully installed!';
+$string['editor:contenttypeinstallingbuttonlabel'] = 'Installing';
+$string['editor:contenttypelicensepaneltitle'] = 'License';
+$string['editor:contenttypenotinstalled'] = 'Content type not installed';
+$string['editor:contenttypenotinstalleddesc'] = 'You do not have permission to install content types.';
+$string['editor:contenttypeowner'] = 'By :owner';
+$string['editor:contenttyperestricted'] = 'Restricted content type';
+$string['editor:contenttyperestricteddesc'] = 'The use of this content type has been restricted by an administrator.';
+$string['editor:contenttypesearchfieldplaceholder'] = 'Search for Content Types';
+$string['editor:contenttypesectionall'] = 'All Content Types';
+$string['editor:contenttypeunsupportedapiversioncontent'] = 'Contact your system administrator to provide you with the necessary updates';
+$string['editor:contenttypeunsupportedapiversiontitle'] = 'This content type requires a newer core version';
+$string['editor:contenttypeupdateavailable'] = 'Update available';
+$string['editor:contenttypeupdatebuttonlabel'] = 'Update';
+$string['editor:contenttypeupdatesuccess'] = ':contentType successfully updated!';
+$string['editor:contenttypeupdatingbuttonlabel'] = 'Updating';
+$string['editor:contenttypeusebuttonlabel'] = 'Use';
+$string['editor:contributetranslations'] = 'If you want to complete the translation for :language you can learn about <a href=":url" target="_new">contributing translations to H5P</a>';
+$string['editor:copiedbutton'] = 'Copied';
+$string['editor:copiedtoclipboard'] = 'Content is copied to the clipboard';
+$string['editor:copybutton'] = 'Copy';
+$string['editor:copytoclipboard'] = 'Copy H5P content to the clipboard';
+$string['editor:createcontenttablabel'] = 'Create Content';
+$string['editor:currentmenuselected'] = 'current selection';
+$string['editor:editcopyright'] = 'Edit copyright';
+$string['editor:editimage'] = 'Edit image';
+$string['editor:editmode'] = 'Editing mode';
+$string['editor:enteraudiotitle'] = 'Paste link or other audio source URL';
+$string['editor:enteraudiourl'] = 'Enter audio source URL';
+$string['editor:enterfullscreenbuttonlabel'] = 'Enter fullscreen';
+$string['editor:entervideotitle'] = 'Paste YouTube link or other video source URL';
+$string['editor:entervideourl'] = 'Enter video source URL or YouTube link';
+$string['editor:errorcalculatingmaxscore'] = 'Could not calculate the max score for this content. The max score is assumed to be 0. Contact your administrator if this isn’t correct.';
+$string['editor:errorcommunicatinghubcontent'] = 'An error occured. Please try again.';
+$string['editor:errorcommunicatinghubtitle'] = 'Not able to communicate with hub.';
+$string['editor:errorheader'] = 'An error occured';
+$string['editor:errornotsupported'] = 'Parameters contain %used which is not supported.';
+$string['editor:errorparamsbroken'] = 'Parameters are broken.';
+$string['editor:errortoohighversion'] = 'Parameters contain %used while only %supported or earlier are supported.';
+$string['editor:example'] = 'Example';
+$string['editor:exceedsmax'] = 'The :property value exceeds the maximum of :max.';
+$string['editor:exitfullscreenbuttonlabel'] = 'Exit fullscreen';
+$string['editor:expandcollapse'] = 'Expand/Collapse';
+$string['editor:filetolarge'] = 'The file you are trying to upload might be too large.';
+$string['editor:fillinthefieldsbelow'] = 'Fill in the fields below';
+$string['editor:gethelp'] = 'Get help';
+$string['editor:h5pfileuploadservererrorcontent'] = 'An unexpected error occured. Check your server error log for\' +  \' more details.';
+$string['editor:h5pfileuploadservererrortitle'] = 'The H5P file could not be uploaded';
+$string['editor:h5pfilevalidationfailedcontent'] = 'Make sure the uploaded H5P contains valid H5P content. H5P\' +  \' files containing only libraries should be uploaded through the H5P Libraries page.';
+$string['editor:h5pfilevalidationfailedtitle'] = 'Could not validate H5P file.';
+$string['editor:h5pfilewrongextensioncontent'] = 'Only files with the .h5p extension are allowed.';
+$string['editor:h5pfilewrongextensiontitle'] = 'The selected file could not be uploaded';
+$string['editor:height'] = 'height';
+$string['editor:hide'] = 'Hide';
+$string['editor:hideimportantinstructions'] = 'Hide important instructions';
+$string['editor:hubpanellabel'] = 'Select content type';
+$string['editor:illegaldecimalnumber'] = ':property can only contain numbers with max :decimals decimals.';
+$string['editor:imagelightboxprogress'] = ':num of :total';
+$string['editor:imagelightboxtitle'] = 'Images';
+$string['editor:importantinstructions'] = 'Important instructions';
+$string['editor:insert'] = 'Insert';
+$string['editor:invalidformat'] = 'Field value contains an invalid format or characters that are forbidden.';
+$string['editor:language'] = 'Language';
+$string['editor:librarymissing'] = 'Missing required library %lib.';
+$string['editor:licensecandistribute'] = 'Can distribute';
+$string['editor:licensecanholdliable'] = 'Can hold liable';
+$string['editor:licensecanmodify'] = 'Can modify';
+$string['editor:licensecansublicense'] = 'Can sublicense';
+$string['editor:licensecanusecommercially'] = 'Can use commercially';
+$string['editor:licensecannotholdliable'] = 'Cannot hold liable';
+$string['editor:licensedescription'] = 'Some of the features of this license are indicated below. Click the info icon above to read the original license text.';
+$string['editor:licensefetchdetailsfailed'] = 'Failed fetching license details';
+$string['editor:licensemodalsubtitle'] = 'Select a license to view information about proper usage';
+$string['editor:licensemodaltitle'] = 'License Details';
+$string['editor:licensemustincludecopyright'] = 'Must include copyright';
+$string['editor:licensemustincludelicense'] = 'Must include license';
+$string['editor:licenseunspecified'] = 'Unspecified';
+$string['editor:listbelowmin'] = 'The list needs at least :min items for the content to function properly.';
+$string['editor:listexceedsmax'] = 'The list exceeds the maximum of :max items.';
+$string['editor:listlabel'] = 'List';
+$string['editor:loading'] = 'Loading, please wait...';
+$string['editor:loadingimageeditor'] = 'Loading image editor, please wait...';
+$string['editor:logthischange'] = 'Log this change';
+$string['editor:loggedchanges'] = 'Logged changes';
+$string['editor:maxscoresemanticsmissing'] = 'Could not find the expected semantics in the content.';
+$string['editor:metadata'] = 'Metadata';
+$string['editor:metadatasharingandlicensinginfo'] = 'Metadata (sharing and licensing info)';
+$string['editor:missingproperty'] = 'Field :index is missing its :property property.';
+$string['editor:missingtranslation'] = '[Missing translation :key]';
+$string['editor:newchangehasbeenlogged'] = 'New change has been logged';
+$string['editor:newestfirst'] = 'Newest First';
+$string['editor:nextimage'] = 'Next image';
+$string['editor:nochangeshavebeenlogged'] = 'No changes have been logged';
+$string['editor:nocontenttypesavailable'] = 'No content types are available';
+$string['editor:nocontenttypesavailabledesc'] = 'Your site is having difficulties connecting to H5P.org and listing the available content types.';
+$string['editor:nofollow'] = 'Cannot follow field ":path".';
+$string['editor:nolanguagessupported'] = 'No languages supported';
+$string['editor:noresultsfound'] = 'No results found';
+$string['editor:noresultsfounddesc'] = 'There is no content type that matches your search criteria.';
+$string['editor:nosemantics'] = 'Error, could not load the content type form.';
+$string['editor:notalltextschanged'] = 'Not all texts were changed, there is only partial coverage for :language.';
+$string['editor:notimagefield'] = '":path" is not an image.';
+$string['editor:notimageordimensionsfield'] = '":path" is not an image or dimensions field.';
+$string['editor:numresults'] = ':num results';
+$string['editor:numberfield'] = 'number field';
+$string['editor:ok'] = 'OK';
+$string['editor:onlynumbers'] = 'The :property value can only contain numbers.';
+$string['editor:or'] = 'or';
+$string['editor:orderitemdown'] = 'Order item down';
+$string['editor:orderitemup'] = 'Order item up';
+$string['editor:outofstep'] = 'The :property value can only be changed in steps of :step.';
+$string['editor:pasteandreplacebutton'] = 'Paste & Replace';
+$string['editor:pasteandreplacefromclipboard'] = 'Replace existing content with H5P Content from the clipboard';
+$string['editor:pastebutton'] = 'Paste';
+$string['editor:pastecontent'] = 'Replace Content';
+$string['editor:pastecontentnotsupported'] = 'The content in the H5P clipboard is not supported in this context';
+$string['editor:pastecontentrestricted'] = 'The content in the clipboard has been restricted on this site';
+$string['editor:pasteerror'] = 'Cannot paste from clipboard';
+$string['editor:pastefromclipboard'] = 'Paste H5P content from the clipboard';
+$string['editor:pastenocontent'] = 'No H5P content on the clipboard';
+$string['editor:pastetoonew'] = 'The content in the H5P clipboard is of a higher version (:clip) than what is supported in this context (:local), if possible try to have this content upgraded first, and then try pasting the content here again.';
+$string['editor:pastetooold'] = 'The content in the H5P clipboard is of a lower version (:clip) than what is supported in this context (:local), if possible try to have the content you want to paste upgraded, copy it again and try pasting it here.';
+$string['editor:popularfirst'] = 'Popular First';
+$string['editor:previousimage'] = 'Previous image';
+$string['editor:proceedbuttonlabel'] = 'Proceed to save';
+$string['editor:readless'] = 'Read less';
+$string['editor:readmore'] = 'Read more';
+$string['editor:recentlyusedfirst'] = 'Recently Used First';
+$string['editor:reloadbuttonlabel'] = 'Reload';
+$string['editor:removefile'] = 'Remove file';
+$string['editor:removeimage'] = 'Remove image';
+$string['editor:removeitem'] = 'Remove item';
+$string['editor:requiredproperty'] = 'The :property is required and must have a value.';
+$string['editor:resettooriginallabel'] = 'Reset to original';
+$string['editor:savelabel'] = 'Save';
+$string['editor:savemetadata'] = 'Save metadata';
+$string['editor:screenshots'] = 'Screenshots';
+$string['editor:scriptmissing'] = 'Could not load upgrades script for %lib.';
+$string['editor:searchresults'] = 'Search Results';
+$string['editor:selectfiletoupload'] = 'Select file to upload';
+$string['editor:selectlibrary'] = 'Select the library you wish to use for your content.';
+$string['editor:semanticserror'] = 'Semantics error: :error';
+$string['editor:show'] = 'Show';
+$string['editor:showimportantinstructions'] = 'Show instructions';
+$string['editor:tabtitlebasicfileupload'] = 'File Upload';
+$string['editor:tabtitleinputlinkurl'] = 'Link/URL';
+$string['editor:textfield'] = 'text field';
+$string['editor:thecontenttype'] = 'the content type';
+$string['editor:thiswillpotentially'] = 'This will potentially reset all the text and translations. You can\'t undo this. The content itself will not be changed. Do you want to proceed?';
+$string['editor:title'] = 'Title';
+$string['editor:toolong'] = 'Field value is too long, should contain :max letters or less.';
+$string['editor:tryagain'] = 'Try again';
+$string['editor:tutorial'] = 'Tutorial';
+$string['editor:unabletointerpreterror'] = 'Unable to interpret response.';
+$string['editor:unabletointerpretsolution'] = 'Please check your error log.';
+$string['editor:unknownfieldpath'] = 'Unable to find ":path".';
+$string['editor:unknownfileuploaderror'] = 'Unknown file upload error';
+$string['editor:unknownlibrary'] = 'Unfortunately, the selected content type \'%lib\' isn\'t installed on this system.';
+$string['editor:untitled'] = 'Untitled :libraryTitle';
+$string['editor:uploadaudiotitle'] = 'Upload audio file';
+$string['editor:uploaderror'] = 'File Upload Error';
+$string['editor:uploadfilebuttonchangelabel'] = 'Change file';
+$string['editor:uploadfilebuttonlabel'] = 'Upload a file';
+$string['editor:uploadinstructionscontent'] = 'You may start with examples from <a href="https://h5p.org/content-types-and-applications" target="blank">H5P.org</a>.';
+$string['editor:uploadinstructionstitle'] = 'Upload an H5P file.';
+$string['editor:uploadplaceholder'] = 'No file chosen';
+$string['editor:uploadsuccess'] = ':title was successfully uploaded!';
+$string['editor:uploadtablabel'] = 'Upload';
+$string['editor:uploadvideotitle'] = 'Upload video file';
+$string['editor:uploading'] = 'Uploading, please wait...';
+$string['editor:uploadingthrobber'] = 'Now uploading...';
+$string['editor:usedforsearchingreportsandcopyrightinformation'] = 'Used for searching, reports and copyright information';
+$string['editor:videoquality'] = 'Video quality label';
+$string['editor:videoqualitydefaultlabel'] = 'Quality :index';
+$string['editor:videoqualitydescription'] = 'This label helps the user identify the current quality of the video. E.g. 1080p, 720p, HD or Mobile';
+$string['editor:warningchangebrowsingtoseeresults'] = 'Click <em>All</em> to get the list of all the content types you can install.';
+$string['editor:warningnocontenttypesinstalled'] = 'You don\'t have any content types installed.';
+$string['editor:warningupdateavailablebody'] = 'Update to the latest version for an improved experience.';
+$string['editor:warningupdateavailabletitle'] = 'A new version of :contentType is available.';
+$string['editor:width'] = 'width';
 $string['pluginname'] = 'H5P framework v1.24';
 $string['pluginname_help'] = 'H5P framework version 1.24.';
 $string['privacy:metadata'] = 'The H5P framework v1.24 does not store any personal data.';
index fb007bf..dff6022 100644 (file)
@@ -214,15 +214,6 @@ class editor_testcase extends advanced_testcase {
             $item = $h5pcorepath . $item . $cachebuster;
         });
 
-        // Add translation script.
-        $language = framework::get_language();
-        $languagescript = "language/{$language}.js";
-
-        if (!file_exists($CFG->dirroot . autoloader::get_h5p_editor_library_base($languagescript))) {
-            $languagescript = 'language/en.js';
-        }
-        $expectedjs[] = autoloader::get_h5p_editor_library_url($languagescript . $cachebuster)->out();
-
         $expectedjs[] = (new \moodle_url('/h5p/js/h5p_overrides.js' . $cachebuster))->out();
         $expectedjs[] = autoloader::get_h5p_editor_library_url('scripts/h5peditor-editor.js' . $cachebuster)->out();
         $expectedjs[] = autoloader::get_h5p_editor_library_url('scripts/h5peditor-init.js' . $cachebuster)->out();
diff --git a/h5p/tests/local/library/handler_test.php b/h5p/tests/local/library/handler_test.php
new file mode 100644 (file)
index 0000000..5d92f50
--- /dev/null
@@ -0,0 +1,79 @@
+<?php
+// 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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Testing the H5P library handler.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p\local\library;
+
+use advanced_testcase;
+
+/**
+ * Test class covering the H5P library handler.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class handler_testcase extends advanced_testcase {
+
+    /**
+     * Test the behaviour of get_h5p_string().
+     *
+     * @dataProvider get_h5p_string_provider
+     * @param  string $identifier      The key identifier for the localized string.
+     * @param  string $expectedresult  Expected result.
+     * @param  string $lang            Language to get the localized string.
+     */
+    public function test_get_h5p_string(string $identifier, ?string $expectedresult, ?string $lang = 'en'): void {
+        $result = autoloader::get_h5p_string($identifier, $lang);
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Data provider for test_get_h5p_string().
+     *
+     * @return array
+     */
+    public function get_h5p_string_provider(): array {
+        return [
+            'Existing string in h5plib plugin' => [
+                'editor:add',
+                'Add',
+            ],
+            'Unexisting translation for an existing string in h5plib plugin (es)' => [
+                'editor:add',
+                null,
+                'es',
+            ],
+            'Unexisting string in h5plib plugin' => [
+                'unexistingstring',
+                null,
+            ],
+            'Unexisting translation for an unexisting string in h5plib plugin (es)' => [
+                'unexistingstring',
+                null,
+                'es',
+            ],
+        ];
+    }
+}
index 0c08c50..9ad8559 100644 (file)
@@ -127,6 +127,7 @@ $string['configgeneralblocks'] = 'Sets the default for including blocks in a bac
 $string['configgeneralcalendarevents'] = 'Sets the default for including calendar events in a backup.';
 $string['configgeneralcomments'] = 'Sets the default for including comments in a backup.';
 $string['configgeneralcompetencies'] = 'Sets the default for including competencies in a backup.';
+$string['configgeneralcontentbankcontent'] = 'Sets the default for including content bank content in a backup.';
 $string['configgeneralfiles'] = 'Sets the default for including files in a backup. Please note: Disabling this setting will result in a backup which only includes references to files. This is not a problem if the backup is restored on the same site and the files have not been deleted according to the setting \'Clean up trash pool files\' (filescleanupperiod).';
 $string['configgeneralfilters'] = 'Sets the default for including filters in a backup.';
 $string['configgeneralhistories'] = 'Sets the default for including user history within a backup.';
@@ -143,6 +144,7 @@ $string['configrestoreblocks'] = 'Sets the default for restoring blocks.';
 $string['configrestorecalendarevents'] = 'Sets the default for restoring calendar events.';
 $string['configrestorecomments'] = 'Sets the default for restoring comments.';
 $string['configrestorecompetencies'] = 'Sets the default for restoring competencies.';
+$string['configrestorecontentbankcontent'] = 'Sets the default for restoring content bank content.';
 $string['configrestoreenrolments'] = 'Sets the default for restoring enrolment methods.';
 $string['configrestorefilters'] = 'Sets the default for restoring filters.';
 $string['configrestorehistories'] = 'Sets the default for restoring user history if it was included in the backup.';
@@ -160,6 +162,21 @@ $string['confirmcancelyes'] = 'Cancel backup';
 $string['confirmcancelno'] = 'Do not cancel';
 $string['confirmnewcoursecontinue'] = 'New course warning';
 $string['confirmnewcoursecontinuequestion'] = 'A temporary (hidden) course will be created by the course restoration process. To abort restoration click cancel. Do not close the browser while restoring.';
+$string['copiesinprogress'] = 'This course has copies in progress. <a href="{$a}">View in progress copies.</a>';
+$string['copycoursedesc'] = 'This course will be duplicated and put into the given course category.';
+$string['copycourseheading'] = 'Copy a course';
+$string['copycoursetitle'] = 'Copy course: {$a}';
+$string['copydest'] = 'Destination';
+$string['copyingcourse'] = 'Course copying in progress';
+$string['copyingcourseshortname'] = 'copying';
+$string['copyformfail'] = 'Ajax submission of course copy form has failed.';
+$string['copyop'] = 'Current operation';
+$string['copyprogressheading'] = 'Course copies in progress';
+$string['copyprogressheading_help'] = 'This table shows the status of all of your unfinished course copies.';
+$string['copyprogresstitle'] = 'Course copy progress';
+$string['copyreturn'] = 'Copy and return';
+$string['copysource'] = 'Source';
+$string['copyview'] = 'Copy and view';
 $string['coursecategory'] = 'Category the course will be restored into';
 $string['courseid'] = 'Original ID';
 $string['coursesettings'] = 'Course settings';
@@ -202,6 +219,7 @@ $string['generalblocks'] = 'Include blocks';
 $string['generalcalendarevents'] = 'Include calendar events';
 $string['generalcomments'] = 'Include comments';
 $string['generalcompetencies'] = 'Include competencies';
+$string['generalcontentbankcontent'] = 'Include content bank content';
 $string['generalenrolments'] = 'Include enrolment methods';
 $string['generalfiles'] = 'Include files';
 $string['generalfilters'] = 'Include filters';
@@ -253,6 +271,8 @@ $string['lockedbyconfig'] = 'This setting has been locked by the default backup
 $string['lockedbyhierarchy'] = 'Locked by dependencies';
 $string['loglifetime'] = 'Keep logs for';
 $string['managefiles'] = 'Manage backup files';
+$string['keptroles'] = 'Keep enrolments of role';
+$string['keptroles_help'] = 'Select which roles and the users with those roles in the source course that you want to keep enrolments for in the new course. Any users with those roles will be copied into the new course.';
 $string['missingfilesinpool'] = 'Some files could not be saved during the backup, and so it will not be possible to restore them.';
 $string['moodleversion'] = 'Moodle version';
 $string['moreresults'] = 'There are too many results, enter a more specific search.';
@@ -333,6 +353,7 @@ $string['rootsettingfilters'] = 'Include filters';
 $string['rootsettingfiles'] = 'Include files';
 $string['rootsettingcomments'] = 'Include comments';
 $string['rootsettingcalendarevents'] = 'Include calendar events';
+$string['rootsettingcontentbankcontent'] = 'Include content bank content';
 $string['rootsettinguserscompletion'] = 'Include user completion details';
 $string['rootsettingquestionbank'] = 'Include question bank';
 $string['rootsettinglogs'] = 'Include course logs';
@@ -368,6 +389,7 @@ $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['successfulcopy'] = 'Copy successful';
 $string['successfulrestore'] = 'Restore successful';
 $string['timetaken'] = 'Time taken';
 $string['title'] = 'Title';
@@ -375,6 +397,8 @@ $string['totalcategorysearchresults'] = 'Total categories: {$a}';
 $string['totalcoursesearchresults'] = 'Total courses: {$a}';
 $string['undefinedrolemapping'] = 'Role mapping undefined for \'{$a}\' archetype.';
 $string['unnamedsection'] = 'Unnamed section';
+$string['userdata'] = 'Keep user data';
+$string['userdata_help'] = 'When enabled user generated data in the source course will be copied into the new course. This includes forum posts, assignment submissions, etc.';
 $string['userinfo'] = 'Userinfo';
 $string['module'] = 'Module';
 $string['morecoursesearchresults'] = 'More than {$a} courses found, showing first {$a} results';
index 06958d6..e111a7c 100644 (file)
@@ -37,6 +37,7 @@ $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
+$string['itemsfound'] = '{$a} items found';
 $string['name'] = 'Content';
 $string['nopermissiontodelete'] = 'You do not have permission to delete content.';
 $string['nopermissiontomanage'] = 'You do not have permission to manage content.';
@@ -50,6 +51,7 @@ $string['privacy:metadata:contentbankcontent'] = 'Stores the content of the cont
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
+$string['searchcontentbankbyname'] = 'Search for content by name';
 $string['timecreated'] = 'Time created';
 $string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index 4885cc4..6ba4010 100644 (file)
@@ -289,6 +289,7 @@ $string['cookiesenabledonlysession_help'] = 'This site uses one session cookie,
 $string['cookiesnotenabled'] = 'Unfortunately, cookies are currently not enabled in your browser';
 $string['copy'] = 'copy';
 $string['copyasnoun'] = 'copy';
+$string['copycourse'] = 'Copy course';
 $string['copyingcoursefiles'] = 'Copying course files';
 $string['copyingsitefiles'] = 'Copying site files used in course';
 $string['copyinguserfiles'] = 'Copying user files';
index e9d2fc7..14e3f57 100644 (file)
@@ -232,6 +232,7 @@ $string['validationmsg_onedir'] = 'Invalid structure of the ZIP package.';
 $string['validationmsg_onedir_help'] = 'The ZIP package must contain just one root directory that holds the plugin code. The name of that root directory must match the name of the plugin.';
 $string['validationmsg_pathwritable'] = 'Write access check';
 $string['validationmsg_pluginversion'] = 'Plugin version';
+$string['validationmsg_pluginversiontoolow'] = 'A higher version of this plugin is already installed';
 $string['validationmsg_release'] = 'Plugin release';
 $string['validationmsg_requiresmoodle'] = 'Required Moodle version';
 $string['validationmsg_rootdir'] = 'Name of the plugin to be installed';
index 1e56b09..eaefd6b 100644 (file)
@@ -861,6 +861,34 @@ function require_capability($capability, context $context, $userid = null, $doan
     }
 }
 
+/**
+ * A convenience function that tests has_capability for a list of capabilities, and displays an error if
+ * the user does not have that capability.
+ *
+ * This is just a utility method that calls has_capability in a loop. Try to put
+ * the capabilities that fewest users are likely to have first in the list for best
+ * performance.
+ *
+ * @category access
+ * @see has_capability()
+ *
+ * @param array $capabilities an array of capability names.
+ * @param context $context the context to check the capability in. You normally get this with context_xxxx::instance().
+ * @param int $userid A user id. By default (null) checks the permissions of the current user.
+ * @param bool $doanything If false, ignore effect of admin role assignment
+ * @param string $errormessage The error string to to user. Defaults to 'nopermissions'.
+ * @param string $stringfile The language file to load the error string from. Defaults to 'error'.
+ * @return void terminates with an error if the user does not have the given capability.
+ */
+function require_all_capabilities(array $capabilities, context $context, $userid = null, $doanything = true,
+                                  $errormessage = 'nopermissions', $stringfile = ''): void {
+    foreach ($capabilities as $capability) {
+        if (!has_capability($capability, $context, $userid, $doanything)) {
+            throw new required_capability_exception($context, $capability, $errormessage, $stringfile);
+        }
+    }
+}
+
 /**
  * Return a nested array showing all role assignments for the user.
  * [ra] => [contextpath][roleid] = roleid
index bf0f119..1b3b1b4 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index a2acd22..6ebad58 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index 94652dc..95f70ab 100644 (file)
Binary files a/lib/amd/build/prefetch.min.js and b/lib/amd/build/prefetch.min.js differ
index ea27ce4..060c3c0 100644 (file)
Binary files a/lib/amd/build/prefetch.min.js.map and b/lib/amd/build/prefetch.min.js.map differ
index e9bf2bf..c907e2b 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index e18317b..202d4bd 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
index 6f512ef..c221d1a 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 898b39a..87973d0 100644 (file)
Binary files a/lib/amd/build/user_date.min.js.map and b/lib/amd/build/user_date.min.js.map differ
index 154f974..d04924f 100644 (file)
@@ -322,6 +322,9 @@ define([
             var contentPromise = null;
             body.css('overflow', 'hidden');
 
+            // Ensure that the `value` is a jQuery Promise.
+            value = $.when(value);
+
             if (value.state() == 'pending') {
                 // We're still waiting for the body promise to resolve so
                 // let's show a loading icon.
index e8e6fb8..b69606f 100644 (file)
@@ -75,11 +75,6 @@ const fetchQueue = () => {
  * Subsequent fetches are immediate.
  */
 const processQueue = () => {
-    if (Config.jsrev <= 0) {
-        // No point pre-fetching when cachejs is disabled as we do not store anything in the cache anyway.
-        return;
-    }
-
     if (prefetchTimer) {
         // There is a live prefetch timer. The initial prefetch has been scheduled but is not complete.
         return;
index 8ac79ab..db830df 100644 (file)
@@ -79,11 +79,6 @@ define([
      * @return {Object} jQuery promise resolved with the template source
      */
     var getTemplatePromiseFromCache = function(searchKey) {
-        // Do not cache anything if templaterev is not valid.
-        if (M.cfg.templaterev <= 0) {
-            return null;
-        }
-
         // First try the cache of promises.
         if (searchKey in templatePromises) {
             return templatePromises[searchKey];
@@ -96,6 +91,11 @@ define([
             return templatePromises[searchKey];
         }
 
+        if (M.cfg.templaterev <= 0) {
+            // Template caching is disabled. Do not store in persistent storage.
+            return null;
+        }
+
         // Now try local storage.
         var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);
         if (cached) {
@@ -183,7 +183,11 @@ define([
                                 // Cache all of the dependent templates because we'll need them to render
                                 // the requested template.
                                 templateCache[tempSearchKey] = data.value;
-                                storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
+
+                                if (M.cfg.templaterev > 0) {
+                                    // The template cache is enabled - set the value there.
+                                    storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);
+                                }
 
                                 if (data.component == component && data.name == name) {
                                     // This is the original template that was requested so remember it to return.
index 20ea44c..95efe3d 100644 (file)
@@ -107,9 +107,14 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      */
     var loadDatesFromServer = function(dates) {
         var args = dates.map(function(data) {
+            var fixDay = data.hasOwnProperty('fixday') ? data.fixday : 1;
+            var fixHour = data.hasOwnProperty('fixhour') ? data.fixhour : 1;
             return {
                 timestamp: data.timestamp,
-                format: data.format
+                format: data.format,
+                type: data.type || '',
+                fixday: fixDay,
+                fixhour: fixHour
             };
         });
 
@@ -155,7 +160,8 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      * Only dates not found in either cache will be sent to the server
      * for transforming.
      *
-     * A request object must have a timestamp key and a format key.
+     * A request object must have a timestamp key and a format key and
+     * optionally may have a type key.
      *
      * E.g.
      * var request = [
@@ -165,7 +171,10 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      *     },
      *     {
      *         timestamp: 1293876000,
-     *         format: '%A, %d %B %Y, %I:%M %p'
+     *         format: '%A, %d %B %Y, %I:%M %p',
+     *         type: 'gregorian',
+     *         fixday: false,
+     *         fixhour: false
      *     }
      * ];
      *
index 488ce71..c6d2aa1 100644 (file)
@@ -155,6 +155,9 @@ class antivirus_clamav_tcpsockethost_setting extends antivirus_clamav_socket_set
         }
         $runningmethod = get_config('antivirus_clamav', 'runningmethod');
         $tcpport = get_config('antivirus_clamav', 'tcpsocketport');
+        if ($tcpport === false) {
+            $tcpport = 3310;
+        }
         if ($runningmethod === 'tcpsocket') {
             return $this->validate_clamav_socket('tcp://' . $data . ':' . $tcpport);
         }
index 624abc4..047cb49 100644 (file)
@@ -28,8 +28,8 @@ defined('MOODLE_INTERNAL') || die();
 
 /** Default socket timeout */
 define('ANTIVIRUS_CLAMAV_SOCKET_TIMEOUT', 10);
-/** Default socket data stream chunk size */
-define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 1024);
+/** Default socket data stream chunk size (32Mb: 32 * 1024 * 1024) */
+define('ANTIVIRUS_CLAMAV_SOCKET_CHUNKSIZE', 33554432);
 
 /**
  * Class implementing ClamAV antivirus.