Merge branch 'MDL-62487' of https://github.com/timhunt/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Mon, 18 May 2020 04:10:02 +0000 (12:10 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Mon, 18 May 2020 04:10:02 +0000 (12:10 +0800)
105 files changed:
admin/tool/monitor/tests/eventobservers_test.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_final_task.class.php
backup/moodle2/backup_stepslib.php
backup/tests/course_copy_test.php [new file with mode: 0644]
backup/tests/externallib_test.php [new file with mode: 0644]
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
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/templates/bankcontent.mustache
contentbank/tests/behat/delete_content.feature
contentbank/tests/contenttype_test.php
contentbank/tests/fixtures/testable_contenttype.php
contentbank/upload.php
contentbank/view.php
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/src/copy_modal.js [new file with mode: 0644]
course/classes/management/helper.php
course/lib.php
course/management.php
course/tests/externallib_test.php
course/view.php
filter/emoticon/db/install.php [new file with mode: 0644]
filter/emoticon/version.php
filter/urltolink/db/install.php [new file with mode: 0644]
filter/urltolink/settings.php
filter/urltolink/version.php
h5p/classes/api.php
lang/en/backup.php
lang/en/moodle.php
lib/accesslib.php
lib/classes/task/asynchronous_copy_task.php [new file with mode: 0644]
lib/db/services.php
lib/editor/atto/settings.php
lib/editor/atto/version.php
lib/navigationlib.php
lib/templates/async_backup_progress.mustache
lib/templates/async_copy_complete_cell.mustache [new file with mode: 0644]
lib/tests/accesslib_test.php
lib/tests/weblib_format_text_test.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/event/report_viewed.php [new file with mode: 0644]
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/local/manager.php
mod/h5pactivity/classes/local/report.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/attempts.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/participants.php [new file with mode: 0644]
mod/h5pactivity/classes/local/report/results.php [new file with mode: 0644]
mod/h5pactivity/classes/output/attempt.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportattempts.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportlink.php [new file with mode: 0644]
mod/h5pactivity/classes/output/reportresults.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/choice.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/fillin.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/longfillin.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/matching.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/other.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/sequencing.php [new file with mode: 0644]
mod/h5pactivity/classes/output/result/truefalse.php [new file with mode: 0644]
mod/h5pactivity/db/access.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/grade.php
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/report.php [new file with mode: 0644]
mod/h5pactivity/templates/attempt.mustache [new file with mode: 0644]
mod/h5pactivity/templates/attempts.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportattempts.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportlink.mustache [new file with mode: 0644]
mod/h5pactivity/templates/reportresults.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/answer.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/header.mustache [new file with mode: 0644]
mod/h5pactivity/templates/result/options.mustache [new file with mode: 0644]
mod/h5pactivity/tests/event/report_viewed_test.php [new file with mode: 0644]
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/manager_test.php
mod/h5pactivity/version.php
mod/h5pactivity/view.php

index a1cb384..f74c10b 100644 (file)
@@ -498,8 +498,8 @@ class tool_monitor_eventobservers_testcase extends advanced_testcase {
 
         $modurl = new moodle_url('/mod/book/view.php', array('id' => $book->cmid));
 
-        $this->assertContains('<h2>'.$event->get_url()->out().'</h2>', $msg->fullmessagehtml);
-        $this->assertContains('<li>'.$modurl->out().'</li>', $msg->fullmessagehtml);
+        $this->assertRegExp('~<h2>.*' . preg_quote($event->get_url()->out(), '~') . '.*</h2>~', $msg->fullmessagehtml);
+        $this->assertRegExp('~<li>.*' . preg_quote($modurl->out(), '~') . '.*</li>~', $msg->fullmessagehtml);
         $this->assertContains('<li><strong>'.$rule->get_name($context).'</strong></li>', $msg->fullmessagehtml);
         $this->assertContains('<li>'.$rule->get_description($context).'</li>', $msg->fullmessagehtml);
         $this->assertContains('<li>'.$rule->get_event_name().'</li>', $msg->fullmessagehtml);
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 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 cf0115a..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;
     }
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 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;
+    }
 }
 
 /**
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 471266e..2ea5c4d 100644 (file)
@@ -85,8 +85,8 @@ class bankcontent implements renderable, templatable {
             $name = $content->get_name();
             $contentdata[] = array(
                 'name' => $name,
-                'link' => $contenttype->get_view_url($record),
-                'icon' => $contenttype->get_icon($name)
+                'link' => $contenttype->get_view_url($content),
+                'icon' => $contenttype->get_icon($content)
             );
         }
         $data->contents = $contentdata;
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 396c297..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": [
@@ -65,7 +65,7 @@
             <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}}
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"
index d5cf377..982d2f1 100644 (file)
@@ -106,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 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 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();
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
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 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 c1877c1..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);
             }
         }
     }
@@ -2383,8 +2385,8 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(2, $result['courses']);
 
         // Check default filters.
-        $this->assertCount(4, $result['courses'][0]['filters']);
-        $this->assertCount(4, $result['courses'][1]['filters']);
+        $this->assertCount(6, $result['courses'][0]['filters']);
+        $this->assertCount(6, $result['courses'][1]['filters']);
 
         $result = core_course_external::get_courses_by_field('category', $category1->id);
         $result = external_api::clean_returnvalue(core_course_external::get_courses_by_field_returns(), $result);
@@ -2426,7 +2428,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
 
         // Check default filters.
         $filters = $result['courses'][0]['filters'];
-        $this->assertCount(4, $filters);
+        $this->assertCount(6, $filters);
         $found = false;
         foreach ($filters as $filter) {
             if ($filter['filter'] == 'mediaplugin' and $filter['localstate'] == TEXTFILTER_OFF) {
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');
diff --git a/filter/emoticon/db/install.php b/filter/emoticon/db/install.php
new file mode 100644 (file)
index 0000000..15f0ebb
--- /dev/null
@@ -0,0 +1,32 @@
+<?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/>.
+
+/**
+ * Emoticon filter post install hook.
+ *
+ * @package    filter_emoticon
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+function xmldb_filter_emoticon_install() {
+    global $CFG;
+    require_once($CFG->libdir . "/filterlib.php");
+
+    filter_set_global_state('emoticon', TEXTFILTER_ON, 0);
+}
index 285688c..cc4b67d 100644 (file)
 /**
  * Version information
  *
- * @package    filter
- * @subpackage emoticon
+ * @package    filter_emoticon
  * @copyright  2010 David Mudrak <david@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2020051200;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2019111200;        // Requires this Moodle version
 $plugin->component = 'filter_emoticon'; // Full name of the plugin (used for diagnostics)
diff --git a/filter/urltolink/db/install.php b/filter/urltolink/db/install.php
new file mode 100644 (file)
index 0000000..01ded27
--- /dev/null
@@ -0,0 +1,32 @@
+<?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/>.
+
+/**
+ * URL to link filter post install hook.
+ *
+ * @package    filter_urltolink
+ * @copyright  2020 Simey Lameze <simey@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+function xmldb_filter_urltolink_install() {
+    global $CFG;
+    require_once($CFG->libdir . "/filterlib.php");
+
+    filter_set_global_state('urltolink', TEXTFILTER_ON, -1);
+}
index 82b7432..44ecff2 100644 (file)
@@ -29,7 +29,7 @@ if ($ADMIN->fulltree) {
     $settings->add(new admin_setting_configmulticheckbox('filter_urltolink/formats',
             get_string('settingformats', 'filter_urltolink'),
             get_string('settingformats_desc', 'filter_urltolink'),
-            array(FORMAT_MOODLE => 1), format_text_menu()));
+            [FORMAT_HTML => 1, FORMAT_MARKDOWN => 1, FORMAT_MOODLE => 1], format_text_menu()));
 
     $settings->add(new admin_setting_configcheckbox('filter_urltolink/embedimages',
             get_string('embedimages', 'filter_urltolink'),
index 471eff4..7ced1c3 100644 (file)
 /**
  * Version details
  *
- * @package    filter
- * @subpackage urltolink
+ * @package    filter_urltolink
  * @copyright  2010 David Mudrak <david@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;        // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2020051200;        // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2019111200;        // Requires this Moodle version
 $plugin->component = 'filter_urltolink'; // Full name of the plugin (used for diagnostics)
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 c59daba..9ad8559 100644 (file)
@@ -162,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';
@@ -256,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.';
@@ -372,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';
@@ -379,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 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 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
diff --git a/lib/classes/task/asynchronous_copy_task.php b/lib/classes/task/asynchronous_copy_task.php
new file mode 100644 (file)
index 0000000..6504d40
--- /dev/null
@@ -0,0 +1,211 @@
+<?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/>.
+
+/**
+ * Adhoc task that performs asynchronous course copies.
+ *
+ * @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
+ */
+
+namespace core\task;
+
+use async_helper;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/backup/util/includes/backup_includes.php');
+require_once($CFG->dirroot . '/backup/moodle2/backup_plan_builder.class.php');
+require_once($CFG->libdir . '/externallib.php');
+
+/**
+ * Adhoc task that performs asynchronous course copies.
+ *
+ * @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
+ */
+class asynchronous_copy_task extends adhoc_task {
+
+    /**
+     * Run the adhoc task and preform the backup.
+     */
+    public function execute() {
+        global $CFG, $DB;
+        $started = time();
+
+        $backupid = $this->get_custom_data()->backupid;
+        $restoreid = $this->get_custom_data()->restoreid;
+        $backuprecord = $DB->get_record('backup_controllers', array('backupid' => $backupid), 'id, itemid', MUST_EXIST);
+        $restorerecord = $DB->get_record('backup_controllers', array('backupid' => $restoreid), 'id, itemid', MUST_EXIST);
+
+        // First backup the course.
+        mtrace('Course copy: Processing asynchronous course copy for course id: ' . $backuprecord->itemid);
+        try {
+            $bc = \backup_controller::load_controller($backupid); // Get the backup controller by backup id.
+        } catch (\backup_dbops_exception $e) {
+            mtrace('Course copy: Can not load backup controller for copy, marking job as failed');
+            delete_course($restorerecord->itemid, false); // Clean up partially created destination course.
+            return; // Return early as we can't continue.
+        }
+        $bc->set_progress(new \core\progress\db_updater($backuprecord->id, 'backup_controllers', 'progress'));
+        $copyinfo = $bc->get_copy();
+        $backupplan = $bc->get_plan();
+
+        $keepuserdata = (bool)$copyinfo->userdata;
+        $keptroles = $copyinfo->keptroles;
+
+        $backupplan->get_setting('users')->set_value('1');
+        $bc->set_kept_roles($keptroles);
+
+        // If we are not keeping user data don't include users or data in the backup.
+        // In this case we'll add the user enrolments at the end.
+        // Also if we have no roles to keep don't backup users.
+        if (empty($keptroles) || !$keepuserdata) {
+            $backupplan->get_setting('users')->set_value('0');
+        }
+
+        // Do some preflight checks on the backup.
+        $status = $bc->get_status();
+        $execution = $bc->get_execution();
+        // Check that the backup is in the correct status and
+        // that is set for asynchronous execution.
+        if ($status == \backup::STATUS_AWAITING && $execution == \backup::EXECUTION_DELAYED) {
+            // Execute the backup.
+            mtrace('Course copy: Backing up course, id: ' . $backuprecord->itemid);
+            $bc->execute_plan();
+
+        } else {
+            // If status isn't 700, it means the process has failed.
+            // Retrying isn't going to fix it, so marked operation as failed.
+            mtrace('Course copy: Bad backup controller status, is: ' . $status . ' should be 700, marking job as failed.');
+            $bc->set_status(\backup::STATUS_FINISHED_ERR);
+            delete_course($restorerecord->itemid, false); // Clean up partially created destination course.
+            $bc->destroy();
+            return; // Return early as we can't continue.
+
+        }
+
+        $results = $bc->get_results();
+        $backupbasepath = $backupplan->get_basepath();
+        $file = $results['backup_destination'];
+        $file->extract_to_pathname(get_file_packer('application/vnd.moodle.backup'), $backupbasepath);
+        // Start the restore process.
+        $rc = \restore_controller::load_controller($restoreid);  // Get the restore controller by restore id.
+        $rc->set_progress(new \core\progress\db_updater($restorerecord->id, 'backup_controllers', 'progress'));
+        $rc->prepare_copy();
+
+        // Set the course settings we can do now (the remaining settings will be done after restore completes).
+        $plan = $rc->get_plan();
+
+        $startdate = $plan->get_setting('course_startdate');
+        $startdate->set_value($copyinfo->startdate);
+        $fullname = $plan->get_setting('course_fullname');
+        $fullname->set_value($copyinfo->fullname);
+        $shortname = $plan->get_setting('course_shortname');
+        $shortname->set_value($copyinfo->shortname);
+
+        // Do some preflight checks on the restore.
+        $rc->execute_precheck();
+        $status = $rc->get_status();
+        $execution = $rc->get_execution();
+
+        // Check that the restore is in the correct status and
+        // that is set for asynchronous execution.
+        if ($status == \backup::STATUS_AWAITING && $execution == \backup::EXECUTION_DELAYED) {
+            // Execute the restore.
+            mtrace('Course copy: Restoring into course, id: ' . $restorerecord->itemid);
+            $rc->execute_plan();
+
+        } else {
+            // If status isn't 700, it means the process has failed.
+            // Retrying isn't going to fix it, so marked operation as failed.
+            mtrace('Course copy: Bad backup controller status, is: ' . $status . ' should be 700, marking job as failed.');
+            $rc->set_status(\backup::STATUS_FINISHED_ERR);
+            delete_course($restorerecord->itemid, false); // Clean up partially created destination course.
+            $file->delete();
+            if (empty($CFG->keeptempdirectoriesonbackup)) {
+                fulldelete($backupbasepath);
+            }
+            $rc->destroy();
+            return; // Return early as we can't continue.
+
+        }
+
+        // Copy user enrolments from source course to destination.
+        if (!empty($keptroles) && !$keepuserdata) {
+            mtrace('Course copy: Creating user enrolments in destination course.');
+            $context = \context_course::instance($backuprecord->itemid);
+
+            $enrol = enrol_get_plugin('manual');
+            $instance = null;
+            $enrolinstances = enrol_get_instances($restorerecord->itemid, true);
+            foreach ($enrolinstances as $courseenrolinstance) {
+                if ($courseenrolinstance->enrol == 'manual') {
+                    $instance = $courseenrolinstance;
+                    break;
+                }
+            }
+
+            // Abort if there enrolment plugin problems.
+            if (empty($enrol) || empty($instance)) {
+                mtrace('Course copy: Could not enrol users in course.');;
+                delete_course($restorerecord->itemid, false);
+                return;
+            }
+
+            // Enrol the users from the source course to the destination.
+            foreach ($keptroles as $roleid) {
+                $sourceusers = get_role_users($roleid, $context);
+                foreach ($sourceusers as $sourceuser) {
+                    $enrol->enrol_user($instance, $sourceuser->id, $roleid);
+                }
+            }
+        }
+
+        // Set up remaining course settings.
+        $course = $DB->get_record('course', array('id' => $restorerecord->itemid), '*', MUST_EXIST);
+        $course->visible = $copyinfo->visible;
+        $course->idnumber = $copyinfo->idnumber;
+        $course->enddate = $copyinfo->enddate;
+
+        $DB->update_record('course', $course);
+
+        // Send message to user if enabled.
+        $messageenabled = (bool)get_config('backup', 'backup_async_message_users');
+        if ($messageenabled && $rc->get_status() == \backup::STATUS_FINISHED_OK) {
+            mtrace('Course copy: Sending user notification.');
+            $asynchelper = new async_helper('copy', $restoreid);
+            $messageid = $asynchelper->send_message();
+            mtrace('Course copy: Sent message: ' . $messageid);
+        }
+
+        // Cleanup.
+        $bc->destroy();
+        $rc->destroy();
+        $file->delete();
+        if (empty($CFG->keeptempdirectoriesonbackup)) {
+            fulldelete($backupbasepath);
+        }
+
+        $duration = time() - $started;
+        mtrace('Course copy: Copy completed in: ' . $duration . ' seconds');
+    }
+}
index 2c4c81d..e8c1350 100644 (file)
@@ -101,6 +101,24 @@ $functions = array(
         'ajax'          => true,
         'loginrequired' => true,
     ),
+    'core_backup_get_copy_progress' => array(
+        'classname'   => 'core_backup_external',
+        'classpath' => 'backup/externallib.php',
+        'methodname'  => 'get_copy_progress',
+        'description' => 'Gets the progress of course copy operations.',
+        'type'        => 'read',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ),
+    'core_backup_submit_copy_form' => array(
+        'classname'   => 'core_backup_external',
+        'classpath' => 'backup/externallib.php',
+        'methodname'  => 'submit_copy_form',
+        'description' => 'Handles ajax submission of course copy form.',
+        'type'        => 'read',
+        'ajax'          => true,
+        'loginrequired' => true,
+    ),
     'core_badges_get_user_badges' => array(
         'classname'     => 'core_badges_external',
         'methodname'    => 'get_user_badges',
index 4fe02e9..58508c4 100644 (file)
@@ -36,7 +36,7 @@ if ($ADMIN->fulltree) {
 style1 = title, bold, italic
 list = unorderedlist, orderedlist
 links = link
-files = image, media, recordrtc, managefiles, h5p
+files = emojipicker, image, media, recordrtc, managefiles, h5p
 style2 = underline, strike, subscript, superscript
 align = align
 indent = indent
index bfe5b52..904c882 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019111800;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2020051200;        // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019111200;        // Requires this Moodle version.
 $plugin->component = 'editor_atto';  // Full name of the plugin (used for diagnostics).
index e02f4fd..f656e8b 100644 (file)
@@ -4552,6 +4552,12 @@ class settings_navigation extends navigation_node {
             $coursenode->add(get_string('import'), $url, self::TYPE_SETTING, null, 'import', new pix_icon('i/import', ''));
         }
 
+        // Copy this course.
+        if ($adminoptions->copy) {
+            $url = new moodle_url('/backup/copy.php', array('id' => $course->id));
+            $coursenode->add(get_string('copycourse'), $url, self::TYPE_SETTING, null, 'copy', new pix_icon('t/copy', ''));
+        }
+
         // Reset this course
         if ($adminoptions->reset) {
             $url = new moodle_url('/course/reset.php', array('id'=>$course->id));
index ba96e53..119141c 100644 (file)
     Example context (json):
     {
         "backupid": "f04abf8cba0319e486a3dfa7e9cb4476",
+        "restoreid": "f04abf8cba0319e486a3dfa7e9cb4477",
+        "operation": "backup",
         "width": "500"
     }
 }}
 <div class="progress active" style="height: 25px; width: {{#width}}{{width}}{{/width}}{{^width}}500{{/width}}px;">
-    <div id="{{backupid}}_bar" class="progress-bar progress-bar-striped progress-bar-animated" style="width: 100%" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
+    <div id="{{backupid}}_bar"
+         class="progress-bar progress-bar-striped progress-bar-animated"
+         style="width: 100%"
+         role="progressbar"
+         aria-valuemin="0"
+         aria-valuemax="100"
+         aria-valuenow="0"
+         {{#operation}}data-operation="{{operation}}"{{/operation}}
+         {{#backupid}}data-backupid="{{backupid}}"{{/backupid}}
+         {{#restoreid}}data-restoreid="{{restoreid}}"{{/restoreid}}>
         {{# str }} asyncprocesspending, backup {{/ str }}
     </div>
 </div>
diff --git a/lib/templates/async_copy_complete_cell.mustache b/lib/templates/async_copy_complete_cell.mustache
new file mode 100644 (file)
index 0000000..4dba08a
--- /dev/null
@@ -0,0 +1,39 @@
+{{!
+    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/async_restore_progress_row.
+
+    Moodle Asynchronous restore status table row template.
+
+    The purpose of this template is to render status
+    table row updates during an asynchronous restore process.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * none
+
+    Context variables required for this template:
+    *
+
+    Example context (json):
+    {}
+}}
+<span class="action-icon">
+  <i class="icon fa fa-check fa-fw " title="{{# str }} successfulcopy, backup {{/ str }}" aria-label="{{# str }} successfulcopy, backup {{/ str }}"></i>
+</span>
index fcaf107..dcf3646 100644 (file)
@@ -4309,6 +4309,37 @@ class core_accesslib_testcase extends advanced_testcase {
         $users = get_users_by_capability($contexts->cat1course1forum, $caput);
         $this->assertArrayHasKey($uut->id, $users);
     }
+
+    /**
+     * Test require_all_capabilities.
+     */
+    public function test_require_all_capabilities() {
+        global $DB;
+
+        $this->resetAfterTest();
+
+        $course = $this->getDataGenerator()->create_course();
+        $coursecontext = context_course::instance($course->id);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'), '*', MUST_EXIST);
+        $teacher = $this->getDataGenerator()->create_user();
+        role_assign($teacherrole->id, $teacher->id, $coursecontext);
+
+        // Note: Here are used default capabilities, the full test is in permission evaluation bellow,
+        // use two capabilities that teacher has and one does not, none of them should be allowed for not-logged-in user.
+        $this->assertTrue($DB->record_exists('capabilities', array('name' => 'moodle/backup:backupsection')));
+        $this->assertTrue($DB->record_exists('capabilities', array('name' => 'moodle/backup:backupcourse')));
+
+        $sca = array('moodle/backup:backupsection', 'moodle/backup:backupcourse');
+
+        $this->setUser($teacher);
+        require_all_capabilities($sca, $coursecontext);
+        require_all_capabilities($sca, $coursecontext, $teacher);
+
+        // Guest users should not have any of these perms.
+        $this->setUser(0);
+        $this->expectException(\required_capability_exception::class);
+        require_all_capabilities($sca, $coursecontext);
+    }
 }
 
 /**
index 9d6d3b1..0b89bf2 100644 (file)
@@ -97,8 +97,8 @@ class core_weblib_format_text_testcase extends advanced_testcase {
     }
 
     public function test_format_text_overflowdiv() {
-        $this->assertEquals('<div class="no-overflow"><p>:-)</p></div>',
-                format_text('<p>:-)</p>', FORMAT_HTML, array('overflowdiv' => true)));
+        $this->assertEquals('<div class="no-overflow"><p>Hello world</p></div>',
+                format_text('<p>Hello world</p>', FORMAT_HTML, array('overflowdiv' => true)));
     }
 
     /**
index 76a4522..339bb2c 100644 (file)
@@ -41,14 +41,14 @@ class backup_h5pactivity_activity_structure_step extends backup_activity_structu
         // Replace with the attributes and final elements that the element will handle.
         $attributes = ['id'];
         $finalelements = ['name', 'timecreated', 'timemodified', 'intro',
-                'introformat', 'grade', 'displayoptions', 'enabletracking', 'grademethod'];
+                'introformat', 'grade', 'displayoptions', 'enabletracking', 'grademethod', 'reviewmode'];
         $root = new backup_nested_element('h5pactivity', $attributes, $finalelements);
 
         $attempts = new backup_nested_element('attempts');
 
         $attempt = new backup_nested_element('attempt', ['id'],
             ['h5pactivityid', 'userid', 'timecreated', 'timemodified', 'attempt', 'rawscore', 'maxscore',
-            'duration', 'completion', 'success']
+            'duration', 'completion', 'success', 'scaled']
         );
 
         $results = new backup_nested_element('results');
diff --git a/mod/h5pactivity/classes/event/report_viewed.php b/mod/h5pactivity/classes/event/report_viewed.php
new file mode 100644 (file)
index 0000000..bf3ed09
--- /dev/null
@@ -0,0 +1,131 @@
+<?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/>.
+
+/**
+ * H5P activity report viewed.
+ *
+ * @package     mod_h5pactivity
+ * @copyright   2020 Ferran Recio <ferran@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\event;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * The report_viewed event class.
+ *
+ * @property-read array $other {
+ *      Extra information about the event.
+ *
+ *      - int instanceid: The instance ID
+ *      - int userid: The optional user ID
+ *      - int attemptid: The optional attempt ID
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class report_viewed extends \core\event\base {
+
+    /**
+     * Init method.
+     *
+     * @return void
+     */
+    protected function init(): void {
+        $this->data['objecttable'] = 'h5pactivity';
+        $this->data['crud'] = 'r';
+        $this->data['edulevel'] = self::LEVEL_PARTICIPATING;
+    }
+
+    /**
+     * Returns localised general event name.
+     *
+     * @return string
+     */
+    public static function get_name() {
+        return get_string('report_viewed', 'mod_h5pactivity');
+    }
+
+    /**
+     * Custom validation.
+     *
+     * @throws \coding_exception
+     * @return void
+     */
+    protected function validate_data() {
+        parent::validate_data();
+
+        if (empty($this->other['instanceid'])) {
+            throw new \coding_exception('The \'instanceid\' value must be set in other.');
+        }
+    }
+
+    /**
+     * Returns non-localised description of what happened.
+     *
+     * @return string
+     */
+    public function get_description() {
+        return "The user with id '$this->userid' viewed the H5P report for the H5P with " .
+            "course module id '$this->contextinstanceid'.";
+    }
+
+    /**
+     * Get URL related to the action
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        $params = ['a' => $this->other['instanceid']];
+
+        if (!empty($this->other['userid'])) {
+            $params['userid'] = $this->other['userid'];
+        }
+
+        if (!empty($this->other['attemptid'])) {
+            $params['attemptid'] = $this->other['attemptid'];
+        }
+
+        return new \moodle_url('/mod/h5pactivity/report.php', $params);
+    }
+
+    /**
+     * This is used when restoring course logs where it is required that we
+     * map the objectid to it's new value in the new course.
+     *
+     * @return array
+     */
+    public static function get_objectid_mapping() {
+        return ['db' => 'h5pactivity', 'restore' => 'h5pactivity'];
+    }
+
+    /**
+     * Return the other field mapping.
+     *
+     * @return array
+     */
+    public static function get_other_mapping() {
+        $othermapped = array();
+        $othermapped['attemptid'] = array('db' => 'h5pactivity_attempts', 'restore' => 'h5pactivity_attempts');
+        $othermapped['userid'] = array('db' => 'user', 'restore' => 'user');
+        return $othermapped;
+    }
+
+}
index e037ebb..0333f76 100644 (file)
@@ -293,6 +293,17 @@ class attempt {
         return $DB->count_records('h5pactivity_attempts_results', $conditions);
     }
 
+    /**
+     * Return all results stored in this attempt.
+     *
+     * @return stdClass[] results records.
+     */
+    public function get_results(): array {
+        global $DB;
+        $conditions = ['attemptid' => $this->record->id];
+        return $DB->get_records('h5pactivity_attempts_results', $conditions, 'id ASC');
+    }
+
     /**
      * Get additional data for some interaction types.
      *
@@ -404,6 +415,24 @@ class attempt {
         return $this->record->userid;
     }
 
+    /**
+     * Return the attempt H5P timecreated.
+     *
+     * @return int the attempt timecreated
+     */
+    public function get_timecreated(): int {
+        return $this->record->timecreated;
+    }
+
+    /**
+     * Return the attempt H5P timemodified.
+     *
+     * @return int the attempt timemodified
+     */
+    public function get_timemodified(): int {
+        return $this->record->timemodified;
+    }
+
     /**
      * Return the attempt H5P activity ID.
      *
@@ -458,6 +487,15 @@ class attempt {
         return $this->record->success;
     }
 
+    /**
+     * Return the attempt scaled.
+     *
+     * @return int|null the scaled value
+     */
+    public function get_scaled(): ?int {
+        return $this->record->scaled;
+    }
+
     /**
      * Return if the attempt has been modified.
      *
index 466995a..4f446c6 100644 (file)
 
 namespace mod_h5pactivity\local;
 
+use mod_h5pactivity\local\report\participants;
+use mod_h5pactivity\local\report\attempts;
+use mod_h5pactivity\local\report\results;
 use context_module;
 use cm_info;
 use moodle_recordset;
+use core_user;
 use stdClass;
 
 /**
@@ -55,6 +59,12 @@ class manager {
     /** Use first attempt results for grading. */
     const GRADEFIRSTATTEMPT = 4;
 
+    /** Participants cannot review their own attempts. */
+    const REVIEWNONE = 0;
+
+    /** Participants can review their own attempts when have one attempt completed. */
+    const REVIEWCOMPLETION = 1;
+
     /** @var stdClass course_module record. */
     private $instance;
 
@@ -118,6 +128,38 @@ class manager {
         ];
     }
 
+    /**
+     * Return the selected attempt criteria.
+     * @return string[] an array "grademethod value", "attempt description"
+     */
+    public function get_selected_attempt(): array {
+        $types = [
+            self::GRADEHIGHESTATTEMPT => get_string('attempt_highest', 'mod_h5pactivity'),
+            self::GRADEAVERAGEATTEMPT => get_string('attempt_average', 'mod_h5pactivity'),
+            self::GRADELASTATTEMPT => get_string('attempt_last', 'mod_h5pactivity'),
+            self::GRADEFIRSTATTEMPT => get_string('attempt_first', 'mod_h5pactivity'),
+            self::GRADEMANUAL => get_string('attempt_none', 'mod_h5pactivity'),
+        ];
+        if ($this->instance->enabletracking) {
+            $key = $this->instance->grademethod;
+        } else {
+            $key = self::GRADEMANUAL;
+        }
+        return [$key, $types[$key]];
+    }
+
+    /**
+     * Return the available review modes.
+     *
+     * @return string[] an array "option value" => "option description"
+     */
+    public static function get_review_modes(): array {
+        return [
+            self::REVIEWCOMPLETION => get_string('review_on_completion', 'mod_h5pactivity'),
+            self::REVIEWNONE => get_string('review_none', 'mod_h5pactivity'),
+        ];
+    }
+
     /**
      * Check if tracking is enabled in a particular h5pactivity for a specific user.
      *
@@ -135,6 +177,50 @@ class manager {
         return has_capability('mod/h5pactivity:submit', $this->context, $user, false);
     }
 
+    /**
+     * Check if a user can see the activity attempts list.
+     *
+     * @param stdClass|null $user user record (default $USER)
+     * @return bool if the user can see the attempts link
+     */
+    public function can_view_all_attempts (stdClass $user = null): bool {
+        global $USER;
+        if (!$this->instance->enabletracking) {
+            return false;
+        }
+        if (empty($user)) {
+            $user = $USER;
+        }
+        return has_capability('mod/h5pactivity:reviewattempts', $this->context, $user);
+    }
+
+    /**
+     * Check if a user can see own attempts.
+     *
+     * @param stdClass|null $user user record (default $USER)
+     * @return bool if the user can see the own attempts link
+     */
+    public function can_view_own_attempts (stdClass $user = null): bool {
+        global $USER;
+        if (!$this->instance->enabletracking) {
+            return false;
+        }
+        if (empty($user)) {
+            $user = $USER;
+        }
+        if (has_capability('mod/h5pactivity:reviewattempts', $this->context, $user, false)) {
+            return true;
+        }
+        if ($this->instance->reviewmode == self::REVIEWNONE) {
+            return false;
+        }
+        if ($this->instance->reviewmode == self::REVIEWCOMPLETION) {
+            return true;
+        }
+        return false;
+
+    }
+
     /**
      * Return a relation of userid and the valid attempt's scaled score.
      *
@@ -198,6 +284,46 @@ class manager {
         return $DB->get_records_sql($sql, $params);
     }
 
+    /**
+     * Count the activity completed attempts.
+     *
+     * If no user is provided will count all activity attempts.
+     *
+     * @param int|null $userid optional user id (default null)
+     * @return int the total amount of attempts
+     */
+    public function count_attempts(int $userid = null): int {
+        global $DB;
+        $params = [
+            'h5pactivityid' => $this->instance->id,
+            'completion' => 1
+        ];
+        if ($userid) {
+            $params['userid'] = $userid;
+        }
+        return $DB->count_records('h5pactivity_attempts', $params);
+    }
+
+    /**
+     * Return an array of all users and it's total attempts.
+     *
+     * Note: this funciton only returns the list of users with attempts,
+     * it does not check all participants.
+     *
+     * @return array indexed count userid => total number of attempts
+     */
+    public function count_users_attempts(): array {
+        global $DB;
+        $params = [
+            'h5pactivityid' => $this->instance->id,
+        ];
+        $sql = "SELECT userid, count(*)
+                  FROM {h5pactivity_attempts}
+                 WHERE h5pactivityid = :h5pactivityid
+                 GROUP BY userid";
+        return $DB->get_records_sql_menu($sql, $params);
+    }
+
     /**
      * Return the current context.
      *
@@ -208,7 +334,7 @@ class manager {
     }
 
     /**
-     * Return the current context.
+     * Return the current instance.
      *
      * @return stdClass the instance record
      */
@@ -234,4 +360,89 @@ class manager {
         $idnumber = $this->coursemodule->idnumber ?? '';
         return new grader($this->instance, $idnumber);
     }
+
+    /**
+     * Return the suitable report to show the attempts.
+     *
+     * This method controls the access to the different reports
+     * the activity have.
+     *
+     * @param int $userid an opional userid to show
+     * @param int $attemptid an optional $attemptid to show
+     * @return report|null available report (or null if no report available)
+     */
+    public function get_report(int $userid = null, int $attemptid = null): ?report {
+        global $USER;
+        $attempt = null;
+        if ($attemptid) {
+            $attempt = $this->get_attempt($attemptid);
+            if (!$attempt) {
+                return null;
+            }
+            // If we have and attempt we can ignore the provided $userid.
+            $userid = $attempt->get_userid();
+        }
+
+        if ($this->can_view_all_attempts()) {
+            $user = core_user::get_user($userid);
+        } else if ($this->can_view_own_attempts()) {
+            $user = $USER;
+            if ($userid && $user->id != $userid) {
+                return null;
+            }
+        } else {
+            return null;
+        }
+
+        // Check if that user can be tracked.
+        if ($user && !$this->is_tracking_enabled($user)) {
+            return null;
+        }
+
+        // Create the proper report.
+        if ($user && $attempt) {
+            return new results($this, $user, $attempt);
+        } else if ($user) {
+            return new attempts($this, $user);
+        }
+        return new participants($this);
+    }
+
+    /**
+     * Return a single attempt.
+     *
+     * @param int $attemptid the attempt id
+     * @return attempt
+     */
+    public function get_attempt(int $attemptid): ?attempt {
+        global $DB;
+        $record = $DB->get_record('h5pactivity_attempts', ['id' => $attemptid]);
+        if (!$record) {
+            return null;
+        }
+        return new attempt($record);
+    }
+
+    /**
+     * Return an array of all user attempts (including incompleted)
+     *
+     * @param int $userid the user id
+     * @return attempt[]
+     */
+    public function get_user_attempts(int $userid): array {
+        global $DB;
+        $records = $DB->get_records(
+            'h5pactivity_attempts',
+            ['userid' => $userid, 'h5pactivityid' => $this->instance->id],
+            'id ASC'
+        );
+        if (!$records) {
+            return [];
+        }
+        $result = [];
+        foreach ($records as $record) {
+            $result[] = new attempt($record);
+        }
+        return $result;
+    }
 }
diff --git a/mod/h5pactivity/classes/local/report.php b/mod/h5pactivity/classes/local/report.php
new file mode 100644 (file)
index 0000000..4267e49
--- /dev/null
@@ -0,0 +1,58 @@
+<?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/>.
+
+/**
+ * H5P activity report interface
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use templatable;
+use stdClass;
+
+/**
+ * Interface for any mod_h5pactivity report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+interface report {
+
+    /**
+     * Return the report user record.
+     *
+     * @return stdClass|null a user or null
+     */
+    public function get_user(): ?stdClass;
+
+    /**
+     * Return the report attempt object.
+     *
+     * @return attempt|null the attempt object or null
+     */
+    public function get_attempt(): ?attempt;
+
+    /**
+     * Print the report visualization.
+     */
+    public function print(): void;
+}
\ No newline at end of file
diff --git a/mod/h5pactivity/classes/local/report/attempts.php b/mod/h5pactivity/classes/local/report/attempts.php
new file mode 100644 (file)
index 0000000..eac8460
--- /dev/null
@@ -0,0 +1,139 @@
+<?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/>.
+
+/**
+ * H5P activity attempts report
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local\report;
+
+use mod_h5pactivity\local\report;
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\output\reportattempts;
+use stdClass;
+
+/**
+ * Class  H5P activity attempts report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+class attempts implements report {
+
+    /** @var manager the H5P activity manager instance. */
+    private $manager;
+
+    /** @var stdClass the user record. */
+    private $user;
+
+    /**
+     * Create a new participants report.
+     *
+     * @param manager $manager h5pactivity manager object
+     * @param stdClass $user user record
+     */
+    public function __construct(manager $manager, stdClass $user) {
+        $this->manager = $manager;
+        $this->user = $user;
+    }
+
+    /**
+     * Return the report user record.
+     *
+     * @return stdClass|null a user or null
+     */
+    public function get_user(): ?stdClass {
+        return $this->user;
+    }
+
+    /**
+     * Return the report attempt object.
+     *
+     * Attempts report has no specific attempt.
+     *
+     * @return attempt|null the attempt object or null
+     */
+    public function get_attempt(): ?attempt {
+        return null;
+    }
+
+    /**
+     * Print the report.
+     */
+    public function print(): void {
+        global $OUTPUT;
+
+        $manager = $this->manager;
+        $cm = $manager->get_coursemodule();
+
+        $scored = $this->get_scored();
+        $title = $scored->title ?? null;
+        $scoredattempt = $scored->attempt ?? null;
+
+        $attempts = $this->get_attempts();
+
+        $widget = new reportattempts($attempts, $this->user, $cm->course, $title, $scoredattempt);
+        echo $OUTPUT->render($widget);
+    }
+
+    /**
+     * Return the current report attempts.
+     *
+     * This method is used to render the report in both browser and mobile.
+     *
+     * @return attempts[]
+     */
+    public function get_attempts(): array {
+        return $this->manager->get_user_attempts($this->user->id);
+    }
+
+    /**
+     * Return the current report attempts.
+     *
+     * This method is used to render the report in both browser and mobile.
+     *
+     * @return stdClass|null a structure with
+     *      - title => name of the selected attempt (or null)
+     *      - attempt => the selected attempt object (or null)
+     *      - gradingmethos => the activity grading method (or null)
+     */
+    public function get_scored(): ?stdClass {
+        $manager = $this->manager;
+        $scores = $manager->get_users_scaled_score($this->user->id);
+        $score = $scores[$this->user->id] ?? null;
+
+        if (empty($score->attemptid)) {
+            return null;
+        }
+
+        list($grademethod, $title) = $manager->get_selected_attempt();
+        $scoredattempt = $manager->get_attempt($score->attemptid);
+
+        $result = (object)[
+            'title' => $title,
+            'attempt' => $scoredattempt,
+            'grademethod' => $grademethod,
+        ];
+        return $result;
+    }
+}
diff --git a/mod/h5pactivity/classes/local/report/participants.php b/mod/h5pactivity/classes/local/report/participants.php
new file mode 100644 (file)
index 0000000..973b422
--- /dev/null
@@ -0,0 +1,204 @@
+<?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/>.
+
+/**
+ * H5P activity participants report
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local\report;
+
+use mod_h5pactivity\local\report;
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\attempt;
+use table_sql;
+use moodle_url;
+use html_writer;
+use stdClass;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir.'/tablelib.php');
+
+/**
+ * Class  H5P activity participants report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+class participants extends table_sql implements report {
+
+    /** @var manager the H5P activity manager instance. */
+    private $manager;
+
+    /** @var array the users scored attempts. */
+    private $scores;
+
+    /** @var array the user attempts count. */
+    private $count;
+
+    /**
+     * Create a new participants report.
+     *
+     * @param manager $manager h5pactivitymanager object
+     */
+    public function __construct(manager $manager) {
+        parent::__construct('mod_h5pactivity-participants');
+        $this->manager = $manager;
+        $this->scores = $manager->get_users_scaled_score();
+        $this->count = $manager->count_users_attempts();
+
+        // Setup table_sql.
+        $columns = ['fullname', 'timemodified', 'score', 'attempts'];
+        $headers = [
+            get_string('fullname'), get_string('date'),
+            get_string('score', 'mod_h5pactivity'), get_string('attempts', 'mod_h5pactivity'),
+        ];
+        $this->define_columns($columns);
+        $this->define_headers($headers);
+        $this->set_attribute('class', 'generaltable generalbox boxaligncenter boxwidthwide');
+        $this->sortable(true);
+        $this->no_sorting('score');
+        $this->no_sorting('timemodified');
+        $this->no_sorting('attempts');
+        $this->pageable(true);
+
+        // Set query SQL.
+        $capjoin = get_enrolled_with_capabilities_join($this->manager->get_context(), '', 'mod/h5pactivity:submit');
+        $this->set_sql(
+            'u.*',
+            "{user} u $capjoin->joins",
+            $capjoin->wheres,
+            $capjoin->params);
+    }
+
+    /**
+     * Return the report user record.
+     *
+     * Participants report has no specific user.
+     *
+     * @return stdClass|null a user or null
+     */
+    public function get_user(): ?stdClass {
+        return null;
+    }
+
+    /**
+     * Return the report attempt object.
+     *
+     * Participants report has no specific attempt.
+     *
+     * @return attempt|null the attempt object or null
+     */
+    public function get_attempt(): ?attempt {
+        return null;
+    }
+
+    /**
+     * Print the report.
+     */
+    public function print(): void {
+        global $PAGE, $OUTPUT;
+
+        $this->define_baseurl($PAGE->url);
+
+        echo $OUTPUT->heading(get_string('attempts_report', 'mod_h5pactivity'));
+
+        $this->out($this->get_page_size(), true);
+    }
+
+    /**
+     * Warning in case no user has the selected initials letters.
+     *
+     */
+    public function print_nothing_to_display() {
+        global $OUTPUT;
+        echo $this->render_reset_button();
+        $this->print_initials_bar();
+        echo $OUTPUT->notification(get_string('noparticipants', 'mod_h5pactivity'), 'warning');
+    }
+
+    /**
+     * Generate the fullname column.
+     *
+     * @param stdClass $user
+     * @return string
+     */
+    public function col_fullname($user): string {
+        global $OUTPUT;
+        $cm = $this->manager->get_coursemodule();
+        return $OUTPUT->user_picture($user, ['size' => 35, 'courseid' => $cm->course, 'includefullname' => true]);
+    }
+
+    /**
+     * Generate score column.
+     *
+     * @param stdClass $user the user record
+     * @return string
+     */
+    public function col_score(stdClass $user): string {
+        $cm = $this->manager->get_coursemodule();
+        if (isset($this->scores[$user->id])) {
+            $score = $this->scores[$user->id];
+            $maxgrade = floatval(100);
+            $scaled = round($maxgrade * $score->scaled).'%';
+            if (empty($score->attemptid)) {
+                return $scaled;
+            } else {
+                $url = new moodle_url('/mod/h5pactivity/report.php', ['a' => $cm->instance, 'attemptid' => $score->attemptid]);
+                return html_writer::link($url, $scaled);
+            }
+        }
+        return '';
+    }
+
+    /**
+     * Generate attempts count column, if any.
+     *
+     * @param stdClass $user the user record
+     * @return string
+     */
+    public function col_attempts(stdClass $user): string {
+        $cm = $this->manager->get_coursemodule();
+        if (isset($this->count[$user->id])) {
+            $msg = get_string('review_user_attempts', 'mod_h5pactivity', $this->count[$user->id]);
+            $url = new moodle_url('/mod/h5pactivity/report.php', ['a' => $cm->instance, 'userid' => $user->id]);
+            return html_writer::link($url, $msg);
+        }
+        return '';
+
+    }
+
+    /**
+     * Generate attempt timemodified column, if any.
+     *
+     * @param stdClass $user the user record
+     * @return string
+     */
+    public function col_timemodified(stdClass $user): string {
+        if (isset($this->scores[$user->id])) {
+            $score = $this->scores[$user->id];
+            return userdate($score->timemodified);
+        }
+        return '';
+    }
+}
diff --git a/mod/h5pactivity/classes/local/report/results.php b/mod/h5pactivity/classes/local/report/results.php
new file mode 100644 (file)
index 0000000..c5690f9
--- /dev/null
@@ -0,0 +1,98 @@
+<?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/>.
+
+/**
+ * H5P activity results report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local\report;
+
+use mod_h5pactivity\local\report;
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\output\reportresults;
+use stdClass;
+
+/**
+ * Class  H5P activity results report.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ */
+class results implements report {
+
+    /** @var manager the H5P activity manager instance. */
+    private $manager;
+
+    /** @var stdClass the user record. */
+    private $user;
+
+    /** @var attempt the h5pactivity attempt to show. */
+    private $attempt;
+
+    /**
+     * Create a new participants report.
+     *
+     * @param manager $manager h5pactivity manager object
+     * @param stdClass $user user record
+     * @param attempt $attempt attempt object
+     */
+    public function __construct(manager $manager, stdClass $user, attempt $attempt) {
+        $this->manager = $manager;
+        $this->user = $user;
+        $this->attempt = $attempt;
+    }
+
+    /**
+     * Return the report user record.
+     *
+     * @return stdClass|null a user or null
+     */
+    public function get_user(): ?stdClass {
+        return $this->user;
+    }
+
+    /**
+     * Return the report attempt object.
+     *
+     * Attempts report has no specific attempt.
+     *
+     * @return attempt|null the attempt object or null
+     */
+    public function get_attempt(): ?attempt {
+        return $this->attempt;
+    }
+
+    /**
+     * Print the report.
+     */
+    public function print(): void {
+        global $OUTPUT;
+
+        $manager = $this->manager;
+        $attempt = $this->attempt;
+        $cm = $manager->get_coursemodule();
+
+        $widget = new reportresults($attempt, $this->user, $cm->course);
+        echo $OUTPUT->render($widget);
+    }
+}
diff --git a/mod/h5pactivity/classes/output/attempt.php b/mod/h5pactivity/classes/output/attempt.php
new file mode 100644 (file)
index 0000000..ac433b4
--- /dev/null
@@ -0,0 +1,227 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\reportlink
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\local\attempt as activity_attempt;
+use renderable;
+use templatable;
+use renderer_base;
+use moodle_url;
+use user_picture;
+use stdClass;
+
+/**
+ * Class to help display report link in mod_h5pactivity.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class attempt implements renderable, templatable {
+
+    /** @var activity_attempt attempt */
+    public $attempt;
+
+    /** @var stdClass user record */
+    public $user;
+
+    /** @var int courseid necesary to present user picture */
+    public $courseid;
+
+    /**
+     * Constructor.
+     *
+     * @param activity_attempt $attempt the attempt object
+     * @param stdClass $user a user record (default null).
+     * @param int $courseid optional course id (default null).
+     */
+    public function __construct(activity_attempt $attempt, stdClass $user = null, int $courseid = null) {
+        $this->attempt = $attempt;
+        $this->user = $user;
+        $this->courseid = $courseid;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        $attempt = $this->attempt;
+
+        $data = (object)[
+            'id' => $attempt->get_id(),
+            'h5pactivityid' => $attempt->get_h5pactivityid(),
+            'userid' => $attempt->get_userid(),
+            'timecreated' => $attempt->get_timecreated(),
+            'timemodified' => $attempt->get_timemodified(),
+            'attempt' => $attempt->get_attempt(),
+            'rawscore' => $attempt->get_rawscore(),
+            'maxscore' => $attempt->get_maxscore(),
+            'duration' => '-',
+            'durationcompact' => '-',
+            'completion' => $attempt->get_completion(),
+            'completionicon' => $this->completion_icon($output, $attempt->get_completion()),
+            'completiontext' => $this->completion_icon($output, $attempt->get_completion(), true),
+            'success' => $attempt->get_success(),
+            'successicon' => $this->success_icon($output, $attempt->get_success()),
+            'successtext' => $this->success_icon($output, $attempt->get_success(), true),
+            'scaled' => $attempt->get_scaled(),
+            'reporturl' => new moodle_url('/mod/h5pactivity/report.php', [
+                'a' => $attempt->get_h5pactivityid(), 'attemptid' => $attempt->get_id()
+            ]),
+        ];
+        if ($attempt->get_duration() !== null) {
+            $duration = $this->extract_duration($attempt->get_duration());
+            $data->duration = $this->format_duration($duration);
+            $data->durationcompact = $this->format_duration_short($duration);
+        }
+
+        if (!empty($data->maxscore)) {
+            $data->score = get_string('score_out_of', 'mod_h5pactivity', $data);
+        }
+        if ($this->user) {
+            $data->user = $this->user;
+            $userpicture = new user_picture($this->user);
+            $userpicture->courseid = $this->courseid;
+            $data->user->picture = $output->render($userpicture);
+            $data->user->fullname = fullname($this->user);
+        }
+        return $data;
+    }
+
+    /**
+     * Return a completion icon HTML.
+     *
+     * @param renderer_base $output the renderer base object
+     * @param int|null $completion the current completion value
+     * @param bool $showtext if the icon must have a text or only icon
+     * @return string icon HTML
+     */
+    private function completion_icon(renderer_base $output, int $completion = null, bool $showtext = false): string {
+        if ($completion === null) {
+            return '';
+        }
+        if ($completion) {
+            $alt = get_string('attempt_completion_yes', 'mod_h5pactivity');
+            $icon = 'i/completion-auto-y';
+        } else {
+            $alt = get_string('attempt_completion_no', 'mod_h5pactivity');
+            $icon = 'i/completion-auto-n';
+        }
+        $text = '';
+        if ($showtext) {
+            $text = $alt;
+            $alt = '';
+        }
+        return $output->pix_icon($icon, $alt).$text;
+    }
+
+    /**
+     * Return a success icon
+     * @param renderer_base $output the renderer base object
+     * @param int|null $success the current success value
+     * @param bool $showtext if the icon must have a text or only icon
+     * @return string icon HTML
+     */
+    private function success_icon(renderer_base $output, int $success = null, bool $showtext = false): string {
+        if ($success === null) {
+            $alt = get_string('attempt_success_unknown', 'mod_h5pactivity');
+            if ($showtext) {
+                return $alt;
+            }
+            $icon = 'i/empty';
+        } else if ($success) {
+            $alt = get_string('attempt_success_pass', 'mod_h5pactivity');
+            $icon = 'i/checkedcircle';
+        } else {
+            $alt = get_string('attempt_success_fail', 'mod_h5pactivity');
+            $icon = 'i/uncheckedcircle';
+        }
+        $text = '';
+        if ($showtext) {
+            $text = $alt;
+            $alt = '';
+        }
+        return $output->pix_icon($icon, $alt).$text;
+    }
+
+    /**
+     * Return the duration in long format (localized)
+     *
+     * @param stdClass $duration object with (h)hours, (m)minutes and (s)seconds
+     * @return string the long format duration
+     */
+    private function format_duration (stdClass $duration): string {
+        $result = [];
+        if ($duration->h) {
+            $result[] = get_string('numhours', 'moodle', $duration->h);
+        }
+        if ($duration->m) {
+            $result[] = get_string('numminutes', 'moodle', $duration->m);
+        }
+        if ($duration->s) {
+            $result[] = get_string('numseconds', 'moodle', $duration->s);
+        }
+        return implode(' ', $result);
+    }
+
+    /**
+     * Return the duration en short format (for example: 145' 43'')
+     *
+     * Note: this method is used to make duration responsive.
+     *
+     * @param stdClass $duration object with (h)hours, (m)minutes and (s)seconds
+     * @return string the short format duration
+     */
+    private function format_duration_short (stdClass $duration): string {
+        $result = [];
+        if ($duration->h || $duration->m) {
+            $result[] = ($duration->h * 60 + $duration->m)."'";
+        }
+        if ($duration->s) {
+            $result[] = $duration->s."''";
+        }
+        return implode(' ', $result);
+    }
+
+    /**
+     * Extract hours and minutes from second duration.
+     *
+     * Note: this function is used to generate the param for format_duration
+     * and format_duration_short
+     *
+     * @param int $seconds number of second
+     * @return stdClass with (h)hours, (m)minutes and (s)seconds
+     */
+    private function extract_duration (int $seconds): stdClass {
+        $h = floor($seconds / 3600);
+        $m = floor(($seconds - $h * 3600) / 60);
+        $s = $seconds - ($h * 3600 + $m * 60);
+        return (object)['h' => $h, 'm' => $m, 's' => $s];
+    }
+}
diff --git a/mod/h5pactivity/classes/output/reportattempts.php b/mod/h5pactivity/classes/output/reportattempts.php
new file mode 100644 (file)
index 0000000..84b15e5
--- /dev/null
@@ -0,0 +1,118 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\report\attempts
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\output\attempt as output_attempt;
+use renderable;
+use templatable;
+use renderer_base;
+use user_picture;
+use stdClass;
+
+/**
+ * Class to output an attempts report on mod_h5pactivity.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class reportattempts implements renderable, templatable {
+
+    /** @var attempt[] attempts */
+    public $attempts;
+
+    /** @var stdClass user record */
+    public $user;
+
+    /** @var int courseid necesary to present user picture */
+    public $courseid;
+
+    /** @var attempt scored attempt */
+    public $scored;
+
+    /** @var string scored attempt title */
+    public $title;
+
+    /**
+     * Constructor.
+     *
+     * The "scored attempt" is the attempt used for grading. By default it is the max score attempt
+     * but this could be defined in the activity settings. In some cases this scored attempts does not
+     * exists at all, this is the reason why it's an optional param.
+     *
+     * @param array $attempts an array of attempts
+     * @param stdClass $user a user record
+     * @param int $courseid course id
+     * @param string|null $title title to display on the scored attempt (null if none attempt is the scored one)
+     * @param attempt|null $scored the scored attempt (null if none)
+     */
+    public function __construct(array $attempts, stdClass $user, int $courseid, string $title = null, attempt $scored = null) {
+        $this->attempts = $attempts;
+        $this->user = $user;
+        $this->courseid = $courseid;
+        $this->title = $title;
+        $this->scored = $scored;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        global $USER;
+
+        $data = (object)['attempts' => [], 'user' => $this->user];
+        foreach ($this->attempts as $attempt) {
+            $outputattempt = new output_attempt($attempt);
+            $data->attempts[] = $outputattempt->export_for_template($output);
+        }
+        $data->attemptscount = count($data->attempts);
+
+        $userpicture = new user_picture($this->user);
+        $userpicture->courseid = $this->courseid;
+        $data->user->fullname = fullname($this->user);
+        $data->user->picture = $output->render($userpicture);
+
+        if ($USER->id == $this->user->id) {
+            $data->title = get_string('myattempts', 'mod_h5pactivity');
+        }
+
+        if (!empty($this->title)) {
+            $scored = (object)[
+                'title' => $this->title,
+                'attempts' => [],
+            ];
+            $outputattempt = new output_attempt($this->scored);
+            $scored->attempts[] = $outputattempt->export_for_template($output);
+            $data->scored = $scored;
+        }
+
+        return $data;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/reportlink.php b/mod/h5pactivity/classes/output/reportlink.php
new file mode 100644 (file)
index 0000000..771aa03
--- /dev/null
@@ -0,0 +1,68 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\reportlink
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use templatable;
+use renderer_base;
+use moodle_url;
+
+/**
+ * Class to help display report link in mod_h5pactivity.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class reportlink implements renderable, templatable {
+
+    /** @var H5P factory */
+    public $url;
+
+    /** @var H5P library list */
+    public $message;
+
+    /**
+     * Constructor.
+     *
+     * @param moodle_url $url the destination url
+     * @param string $message the link message
+     */
+    public function __construct(moodle_url $url, string $message) {
+        $this->url = $url;
+        $this->message = $message;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        return $this;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/reportresults.php b/mod/h5pactivity/classes/output/reportresults.php
new file mode 100644 (file)
index 0000000..7627a30
--- /dev/null
@@ -0,0 +1,92 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\reportresults
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\output\attempt as output_attempt;
+use mod_h5pactivity\output\result as output_result;
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class to display the result report in mod_h5pactivity.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class reportresults implements renderable, templatable {
+
+    /** @var attempt the header attempt */
+    public $attempt;
+
+    /** @var stdClass user record */
+    public $user;
+
+    /** @var int courseid necesary to present user picture */
+    public $courseid;
+
+    /**
+     * Constructor.
+     *
+     * @param attempt $attempt the current attempt
+     * @param stdClass $user a user record
+     * @param int $courseid course id
+     */
+    public function __construct(attempt $attempt, stdClass $user, int $courseid) {
+        $this->attempt = $attempt;
+        $this->user = $user;
+        $this->courseid = $courseid;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+
+        $outputattempt = new output_attempt($this->attempt, $this->user, $this->courseid);
+
+        $data = (object)[
+            'attempt' => $outputattempt->export_for_template($output),
+        ];
+
+        $results = $this->attempt->get_results();
+        $data->results = [];
+        foreach ($results as $key => $result) {
+            $outputresult = output_result::create_from_record($result);
+            if ($outputresult) {
+                $data->results[] = $outputresult->export_for_template($output);
+            }
+        }
+
+        return $data;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result.php b/mod/h5pactivity/classes/output/result.php
new file mode 100644 (file)
index 0000000..fc8b53a
--- /dev/null
@@ -0,0 +1,300 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output;
+
+defined('MOODLE_INTERNAL') || die();
+
+use renderable;
+use templatable;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class to display an attempt tesult in mod_h5pactivity.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class result implements renderable, templatable {
+
+    /** Correct answer state. */
+    const CORRECT = 1;
+
+    /** Incorrect answer state. */
+    const INCORRECT = 2;
+
+    /** Checked answer state. */
+    const CHECKED = 3;
+
+    /** Unchecked answer state. */
+    const UNCHECKED = 4;
+
+    /** Pass answer state. */
+    const PASS = 5;
+
+    /** Pass answer state. */
+    const FAIL = 6;
+
+    /** Unkown answer state. */
+    const UNKNOWN = 7;
+
+    /** Text answer state. */
+    const TEXT = 8;
+
+    /** @var stdClass result record */
+    protected $result;
+
+    /** @var mixed additional decoded data */
+    protected $additionals;
+
+    /** @var mixed response decoded data */
+    protected $response;
+
+    /** @var mixed correctpattern decoded data */
+    protected $correctpattern = [];
+
+    /**
+     * Constructor.
+     *
+     * @param stdClass $result a h5pactivity_attempts_results record
+     */
+    protected function __construct(stdClass $result) {
+        $this->result = $result;
+        if (empty($result->additionals)) {
+            $this->additionals = new stdClass();
+        } else {
+            $this->additionals = json_decode($result->additionals);
+        }
+        $this->response = $this->decode_response($result->response);
+        if (!empty($result->correctpattern)) {
+            $correctpattern = json_decode($result->correctpattern);
+            foreach ($correctpattern as $pattern) {
+                $this->correctpattern[] = $this->decode_response($pattern);
+            }
+        }
+    }
+
+    /**
+     * return the correct result output depending on the interactiontype
+     *
+     * @param stdClass $result h5pactivity_attempts_results record
+     * @return result|null the result output class if any
+     */
+    public static function create_from_record(stdClass $result): ?self {
+        // Compound result track is omitted from the report.
+        if ($result->interactiontype == 'compound') {
+            return null;
+        }
+        $classname = "mod_h5pactivity\\output\\result\\{$result->interactiontype}";
+        $classname = str_replace('-', '', $classname);
+        if (class_exists($classname)) {
+            return new $classname($result);
+        }
+        return new self($result);
+    }
+
+    /**
+     * Return a decoded response structure.
+     *
+     * @param string $value the current response structure
+     * @return array an array of reponses
+     */
+    private function decode_response(string $value): array {
+        // If [,] means a list of elements.
+        $list = explode('[,]', $value);
+        // Inside a list element [.] means sublist (pair) and [:] a range.
+        foreach ($list as $key => $item) {
+            if (strpos($item, '[.]') !== false) {
+                $list[$key] = explode('[.]', $item);
+            } else if (strpos($item, '[:]') !== false) {
+                $list[$key] = explode('[:]', $item);
+            }
+        }
+        return $list;
+    }
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $result = $this->result;
+
+        $data = (object)[
+            'id' => $result->id,
+            'attemptid' => $result->attemptid,
+            'subcontent' => $result->subcontent,
+            'timecreated' => $result->timecreated,
+            'interactiontype' => $result->interactiontype,
+            'description' => format_string($result->description),
+            'rawscore' => $result->rawscore,
+            'maxscore' => $result->maxscore,
+            'duration' => $result->duration,
+            'completion' => $result->completion,
+            'success' => $result->success,
+        ];
+        $result;
+
+        $options = $this->export_options();
+
+        if (!empty($options)) {
+            $data->hasoptions = true;
+            $data->optionslabel = $this->get_optionslabel();
+            $data->correctlabel = $this->get_correctlabel();
+            $data->answerlabel = $this->get_answerlabel();
+            $data->options = array_values($options);
+            $data->track = true;
+        }
+
+        if (!empty($result->maxscore)) {
+            $data->score = get_string('score_out_of', 'mod_h5pactivity', $result);
+        }
+        return $data;
+    }
+
+    /**
+     * Return the options data structure.
+     *
+     * Result types have to override this method generate a specific options report.
+     *
+     * An option is an object with:
+     *   - id: the option ID
+     *   - description: option description text
+     *   - useranswer (optional): what the user answer (see get_answer method)
+     *   - correctanswer (optional): the correct answer (see get_answer method)
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+        return [];
+    }
+
+    /**
+     * Return a label for result user options/choices.
+     *
+     * Specific result types can override this method to customize
+     * the result options table header.
+     *
+     * @return string to use in options table
+     */
+    protected function get_optionslabel(): string {
+        return get_string('choice', 'mod_h5pactivity');
+    }
+
+    /**
+     * Return a label for result user correct answer.
+     *
+     * Specific result types can override this method to customize
+     * the result options table header.
+     *
+     * @return string to use in options table
+     */
+    protected function get_correctlabel(): string {
+        return get_string('correct_answer', 'mod_h5pactivity');
+    }
+
+    /**
+     * Return a label for result user attempt answer.
+     *
+     * Specific result types can override this method to customize
+     * the result options table header.
+     *
+     * @return string to use in options table
+     */
+    protected function get_answerlabel(): string {
+        return get_string('attempt_answer', 'mod_h5pactivity');
+    }
+
+    /**
+     * Extract descriptions from array.
+     *
+     * @param array $data additional attribute to parse
+     * @return string[] the resulting strings
+     */
+    protected function get_descriptions(array $data): array {
+        $result = [];
+        foreach ($data as $key => $value) {
+            $description = $this->get_description($value);
+            $index = $value->id ?? $key;
+            $index = trim($index);
+            if (is_numeric($index)) {
+                $index = intval($index);
+            }
+            $result[$index] = (object)['description' => $description, 'id' => $index];
+        }
+        ksort($result);
+        return $result;
+    }
+
+    /**
+     * Extract description from data element.
+     *
+     * @param stdClass $data additional attribute to parse
+     * @return string the resulting string
+     */
+    protected function get_description(stdClass $data): string {
+        if (!isset($data->description)) {
+            return '';
+        }
+        $translations = (array) $data->description;
+        if (empty($translations)) {
+            return '';
+        }
+        // By default, H5P packages only send "en-US" descriptions.
+        $result = $translations['en-US'] ?? array_shift($translations);
+        return trim($result);
+    }
+
+    /**
+     * Return an answer data to show results.
+     *
+     * @param int $state the answer state
+     * @param string $answer the extra text to display (default null)
+     * @return stdClass with "answer" text and the state attribute to be displayed
+     */
+    protected function get_answer(int $state, string $answer = null): stdClass {
+        $states = [
+            self::CORRECT => 'correct',
+            self::INCORRECT => 'incorrect',
+            self::CHECKED => 'checked',
+            self::UNCHECKED => 'unchecked',
+            self::PASS => 'pass',
+            self::FAIL => 'fail',
+            self::UNKNOWN => 'unkown',
+            self::TEXT => 'text',
+        ];
+        $state = $states[$state] ?? self::UNKNOWN;
+        if ($answer === null) {
+            $answer = get_string('answer_'.$state, 'mod_h5pactivity');
+        }
+        $result = (object)[
+            'answer' => $answer,
+            $state => true,
+        ];
+        return $result;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/choice.php b/mod/h5pactivity/classes/output/result/choice.php
new file mode 100644 (file)
index 0000000..da4011e
--- /dev/null
@@ -0,0 +1,101 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\choice
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+
+/**
+ * Class to display H5P choice result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class choice extends result {
+
+    /**
+     * Return the options data structure.
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+
+        // Suppose H5P choices have only a single list of valid answers.
+        $correctpattern = reset($this->correctpattern);
+        if (empty($correctpattern)) {
+            $correctpattern = [];
+        }
+
+        $additionals = $this->additionals;
+
+        // H5P has a special extension for long choices.
+        $extensions = (array) $additionals->extensions ?? [];
+        $filter = isset($extensions['https://h5p.org/x-api/line-breaks']) ? true : false;
+
+        if (isset($additionals->choices)) {
+            $options = $this->get_descriptions($additionals->choices);
+        } else {
+            $options = [];
+        }
+
+        // Some H5P activities like Find the Words don't user the standard CMI format delimiter
+        // and don't use propper choice additionals. In those cases the report needs to fix this
+        // using the correct pattern as choices and using a non standard delimiter.
+        if (empty($options)) {
+            if (count($correctpattern) == 1) {
+                $correctpattern = explode(',', reset($correctpattern));
+            }
+            foreach ($correctpattern as $value) {
+                $option = (object)[
+                    'id' => $value,
+                    'description' => $value,
+                ];
+                $options[$value] = $option;
+            }
+        }
+
+        foreach ($options as $key => $value) {
+            $correctstate = (in_array($key, $correctpattern)) ? parent::CHECKED : parent::UNCHECKED;
+            if (in_array($key, $this->response)) {
+                $answerstate = ($correctstate == parent::CHECKED) ? parent::PASS : parent::FAIL;
+                // In some cases, like Branching scenario H5P activity, no correct Pattern is provided
+                // so any answer is just a check.
+                if (empty($correctpattern)) {
+                    $answerstate = parent::CHECKED;
+                }
+                $value->useranswer = $this->get_answer($answerstate);
+            }
+            $value->correctanswer = $this->get_answer($correctstate);
+
+            if ($filter && $correctstate == parent::UNCHECKED && !isset($value->useranswer)) {
+                unset($options[$key]);
+            }
+        }
+
+        return $options;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/fillin.php b/mod/h5pactivity/classes/output/result/fillin.php
new file mode 100644 (file)
index 0000000..0439845
--- /dev/null
@@ -0,0 +1,139 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\fillin
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class to display H5P fill-in result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class fillin extends result {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $data = parent::export_for_template($output);
+        $data->content = $this->result->description;
+        $data->description = get_string('result_fill-in', 'mod_h5pactivity');
+        return $data;
+    }
+
+    /**
+     * Return the options data structure.
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+
+        $correctpatterns = $this->correctpattern;
+
+        $additionals = $this->additionals;
+
+        $extensions = (array) $additionals->extensions ?? [];
+
+        // There are two way in which H5P could force case sensitivity, with extensions
+        // or using options in the correctpatterns. By default it is case sensible.
+        $casesensitive = $extensions['https://h5p.org/x-api/case-sensitivity'] ?? true;
+        if (!empty($this->result->correctpattern) && strpos($this->result->correctpattern, '{case_matters=false}') !== null) {
+                $casesensitive = false;
+        }
+
+        $values = [];
+        // Add all possibilities from $additionals.
+        if (isset($extensions['https://h5p.org/x-api/alternatives'])) {
+            foreach ($extensions['https://h5p.org/x-api/alternatives'] as $key => $value) {
+                if (!is_array($value)) {
+                    $value = [$value];
+                }
+                $values[$key] = ($casesensitive) ? $value : array_change_key_case($value);
+            }
+        }
+        // Add possibilities from correctpattern.
+        foreach ($correctpatterns as $correctpattern) {
+            foreach ($correctpattern as $key => $pattern) {
+                // The xAPI admits more params a part form values.
+                // For now this extra information is not used in reporting
+                // but it is posible future H5P types need them.
+                $value = preg_replace('/\{.+=.*\}/', '', $pattern);
+                $value = ($casesensitive) ? $value : strtolower($value);
+                if (!isset($values[$key])) {
+                    $values[$key] = [];
+                }
+                if (!in_array($value, $values[$key])) {
+                    array_unshift($values[$key], $value);
+                }
+            }
+        }
+
+        // Generate options.
+        $options = [];
+        $num = 1;
+        foreach ($values as $key => $value) {
+            $option = (object)[
+                'id' => $key,
+                'description' => get_string('result_fill-in_gap', 'mod_h5pactivity', $num),
+            ];
+
+            $gapresponse = $this->response[$key] ?? null;
+            $gapresponse = ($casesensitive) ? $gapresponse : strtolower($gapresponse);
+            if ($gapresponse !== null && in_array($gapresponse, $value)) {
+                $state = parent::CORRECT;
+            } else {
+                $state = parent::INCORRECT;
+            }
+            $option->useranswer = $this->get_answer($state, $gapresponse);
+
+            $option->correctanswer = $this->get_answer(parent::TEXT, implode(' / ', $value));
+
+            $options[] = $option;
+            $num++;
+        }
+
+        return $options;
+    }
+
+    /**
+     * Return a label for result user options/choices
+     *
+     * Specific result types can override this method to customize
+     * the result options table header.
+     *
+     * @return string to use in options table
+     */
+    protected function get_optionslabel(): string {
+        return get_string('result_matching', 'mod_h5pactivity');
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/longfillin.php b/mod/h5pactivity/classes/output/result/longfillin.php
new file mode 100644 (file)
index 0000000..7297132
--- /dev/null
@@ -0,0 +1,53 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\longfillin
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class to display H5P long fill in result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class longfillin extends result {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $data = parent::export_for_template($output);
+        $data->content = reset($this->response);
+        $data->track = true;
+        return $data;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/matching.php b/mod/h5pactivity/classes/output/result/matching.php
new file mode 100644 (file)
index 0000000..645afdf
--- /dev/null
@@ -0,0 +1,126 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\matching
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+
+/**
+ * Class to display H5P matching result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class matching extends result {
+
+    /**
+     * Return the options data structure.
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+        // Suppose H5P choices have only list of valid answers.
+        $correctpattern = reset($this->correctpattern);
+
+        $additionals = $this->additionals;
+
+        // Get sources (options).
+        if (isset($additionals->source)) {
+            $options = $this->get_descriptions($additionals->source);
+        } else {
+            $options = [];
+        }
+
+        // Get targets.
+        if (isset($additionals->target)) {
+            $targets = $this->get_descriptions($additionals->target);
+        } else {
+            $targets = [];
+        }
+
+        // Correct answers.
+        foreach ($correctpattern as $pattern) {
+            if (!is_array($pattern) || count($pattern) != 2) {
+                continue;
+            }
+            // One pattern must be from options and the other from targets.
+            if (isset($options[$pattern[0]]) && isset($targets[$pattern[1]])) {
+                $option = $options[$pattern[0]];
+                $target = $targets[$pattern[1]];
+            } else if (isset($targets[$pattern[0]]) && isset($options[$pattern[1]])) {
+                $option = $options[$pattern[1]];
+                $target = $targets[$pattern[0]];
+            } else {
+                $option = null;
+            }
+            if ($option) {
+                $option->correctanswer = $this->get_answer(parent::TEXT, $target->description);
+                $option->correctanswerid = $target->id;
+            }
+        }
+
+        // User responses.
+        foreach ($this->response as $response) {
+            if (!is_array($response) || count($response) != 2) {
+                continue;
+            }
+            // One repsonse must be from options and the other from targets.
+            if (isset($options[$response[0]]) && isset($targets[$response[1]])) {
+                $option = $options[$response[0]];
+                $target = $targets[$response[1]];
+                $answer = $response[1];
+            } else if (isset($targets[$response[0]]) && isset($options[$response[1]])) {
+                $option = $options[$response[1]];
+                $target = $targets[$response[0]];
+                $answer = $response[0];
+            } else {
+                $option = null;
+            }
+            if ($option) {
+                if (isset($option->correctanswerid) && $option->correctanswerid == $answer) {
+                    $state = parent::CORRECT;
+                } else {
+                    $state = parent::INCORRECT;
+                }
+                $option->useranswer = $this->get_answer($state, $target->description);
+            }
+        }
+        return $options;
+    }
+
+    /**
+     * Return a label for result user options/choices
+     *
+     * Specific result types can override this method to customize
+     * the result options table header.
+     *
+     * @return string to use in options table
+     */
+    protected function get_optionslabel(): string {
+        return get_string('result_matching', 'mod_h5pactivity');
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/other.php b/mod/h5pactivity/classes/output/result/other.php
new file mode 100644 (file)
index 0000000..7d0f66a
--- /dev/null
@@ -0,0 +1,54 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\other
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+use stdClass;
+
+/**
+ * Class to display H5P other result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class other extends result {
+
+    /**
+     * Export this data so it can be used as the context for a mustache template.
+     *
+     * @param renderer_base $output
+     * @return stdClass
+     */
+    public function export_for_template(renderer_base $output): stdClass {
+        $data = parent::export_for_template($output);
+        if (empty($data->description)) {
+            $data->description = get_string('result_other', 'mod_h5pactivity');
+        }
+        return $data;
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/sequencing.php b/mod/h5pactivity/classes/output/result/sequencing.php
new file mode 100644 (file)
index 0000000..286d1ed
--- /dev/null
@@ -0,0 +1,101 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\sequencing
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+
+/**
+ * Class to display H5P sequencing result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class sequencing extends result {
+
+    /**
+     * Return the options data structure.
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+
+        $correctpattern = reset($this->correctpattern);
+
+        $additionals = $this->additionals;
+
+        $response = $this->response;
+
+        if (isset($additionals->choices)) {
+            $choices = $this->get_descriptions($additionals->choices);
+        } else {
+            $choices = [];
+        }
+
+        $options = [];
+        $num = 1;
+        foreach ($correctpattern as $key => $pattern) {
+            if (!isset($choices[$pattern])) {
+                continue;
+            }
+            $option = (object)[
+                'id' => true,
+                'description' => get_string('result_sequencing_position', 'mod_h5pactivity', $num),
+                'correctanswer' => $this->get_answer(parent::TEXT, $choices[$pattern]->description),
+                'correctanswerid' => 'item_'.$key,
+            ];
+            if (isset($response[$key])) {
+                $answerstate = ($response[$key] == $option->correctanswerid) ? parent::PASS : parent::FAIL;
+            } else {
+                $answerstate = parent::FAIL;
+            }
+            $option->useranswer = $this->get_answer($answerstate);
+
+            $options[$key] = $option;
+            $num ++;
+        }
+        return $options;
+    }
+
+    /**
+     * Return a label for result user options/choices.
+     *
+     * @return string to use in options table
+     */
+    protected function get_optionslabel(): string {
+        return get_string('result_sequencing_choice', 'mod_h5pactivity');
+    }
+
+    /**
+     * Return a label for result user correct answer.
+     *
+     * @return string to use in options table
+     */
+    protected function get_correctlabel(): string {
+        return get_string('result_sequencing_answer', 'mod_h5pactivity');
+    }
+}
diff --git a/mod/h5pactivity/classes/output/result/truefalse.php b/mod/h5pactivity/classes/output/result/truefalse.php
new file mode 100644 (file)
index 0000000..a16cf1a
--- /dev/null
@@ -0,0 +1,76 @@
+<?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/>.
+
+/**
+ * Contains class mod_h5pactivity\output\result\truefalse
+ *
+ * @package   mod_h5pactivity
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\output\result;
+
+defined('MOODLE_INTERNAL') || die();
+
+use mod_h5pactivity\output\result;
+use renderer_base;
+
+/**
+ * Class to display H5P choice result.
+ *
+ * @copyright 2020 Ferran Recio
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class truefalse extends result {
+
+    /**
+     * Return the options data structure.
+     *
+     * @return array of options
+     */
+    protected function export_options(): ?array {
+
+        // This interaction type have only one entry which is the correct option.
+        $correctpattern = reset($this->correctpattern);
+        $correctpattern = filter_var(reset($correctpattern), FILTER_VALIDATE_BOOLEAN);
+
+        $response = filter_var(reset($this->response), FILTER_VALIDATE_BOOLEAN);
+
+        $options = [
+            (object)[
+                'id' => true,
+                'description' => get_string('true', 'mod_h5pactivity'),
+            ],
+            (object)[
+                'id' => false,
+                'description' => get_string('false', 'mod_h5pactivity'),
+            ],
+        ];
+        foreach ($options as $value) {
+            $correctstate = ($value->id == $correctpattern) ? parent::CHECKED : parent::UNCHECKED;
+
+            if ($value->id == $response) {
+                $answerstate = ($correctstate == parent::CHECKED) ? parent::PASS : parent::FAIL;
+                $value->useranswer = $this->get_answer($answerstate);
+            }
+
+            $value->correctanswer = $this->get_answer($correctstate);
+        }
+
+        return $options;
+    }
+}
index cef7aba..459b848 100644 (file)
@@ -56,4 +56,15 @@ $capabilities = [
             'student' => CAP_ALLOW
         ],
     ],
+
+    'mod/h5pactivity:reviewattempts' => array(
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => array(
+            'teacher' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+            'manager' => CAP_ALLOW
+        ),
+        'clonepermissionsfrom' => 'moodle/grade:manage'
+    ),
 ];
index 0484154..57cd1e0 100644 (file)
@@ -17,6 +17,7 @@
         <FIELD NAME="displayoptions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="H5P Button display options"/>
         <FIELD NAME="enabletracking" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Enable xAPI tracking"/>
         <FIELD NAME="grademethod" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Which H5P attempt is used for grading"/>
+        <FIELD NAME="reviewmode" TYPE="int" LENGTH="4" NOTNULL="false" DEFAULT="1" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
index d0fb851..1f19284 100644 (file)
@@ -225,5 +225,20 @@ function xmldb_h5pactivity_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2020041401, 'h5pactivity');
     }
 
+    if ($oldversion < 2020042202) {
+
+        // Define field reviewmode to be added to h5pactivity.
+        $table = new xmldb_table('h5pactivity');
+        $field = new xmldb_field('reviewmode', XMLDB_TYPE_INTEGER, '4', null, null, null, '1', 'grademethod');
+
+        // Conditionally launch add field reviewmode.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // H5pactivity savepoint reached.
+        upgrade_mod_savepoint(true, 2020042202, 'h5pactivity');
+    }
+
     return true;
 }
index ccf6c4a..f4b30e4 100644 (file)
@@ -23,6 +23,8 @@
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 require(__DIR__.'/../../config.php');
 
 // Course module ID.
@@ -34,9 +36,27 @@ $itemnumber = optional_param('itemnumber', 0, PARAM_INT);
 // Graded user ID (optional).
 $userid = optional_param('userid', 0, PARAM_INT);
 
-require_login();
+list ($course, $cm) = get_course_and_cm_from_cmid($id, 'h5pactivity');
+
+require_login($course, true, $cm);
+
+$manager = manager::create_from_coursemodule($cm);
+
+if (!$manager->can_view_all_attempts() && !$manager->can_view_own_attempts()) {
+    redirect(new moodle_url('/mod/h5pactivity/view.php', ['id' => $id]));
+}
+
+$moduleinstance = $manager->get_instance();
+
+$params = [
+    'a' => $moduleinstance->id,
+    'userid' => $userid,
+];
 
-// TODO: in the near future this file will redirect to a specific user H5P attempts page.
+$scores = $manager->get_users_scaled_score($userid);
+$score = $scores[$userid] ?? null;
+if (!empty($score->attemptid)) {
+    $params['attemptid'] = $score->attemptid;
+}
 
-// In the simplest case just redirect to the view page.
-redirect('view.php?id='.$id);
+redirect(new moodle_url('/mod/h5pactivity/report.php', $params));
index c855830..35fa58e 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['all_attempts'] = 'All user attempts';
+$string['answer_checked'] = 'Answer checked';
+$string['answer_correct'] = 'Your answer is correct';
+$string['answer_fail'] = 'Incorrect answer';
+$string['answer_incorrect'] = 'Your answer is incorrect';
+$string['answer_pass'] = 'Correct answer';
+$string['answer_unchecked'] = 'Answer unchecked';
+$string['answer_unknown'] = 'Unkown answer';
+$string['answer_text'] = 'Answer text';
 $string['areapackage'] = 'Package file';
 $string['attempt'] = 'Attempt';
+$string['attempt_average'] = 'Attempts average scored';
+$string['attempt_answer'] = 'Attempt answer';
+$string['attempt_completion_no'] = 'This attempt is not marked as completed';
+$string['attempt_completion_yes'] = 'This attempt is completed';
+$string['attempt_first'] = 'First attempt';
+$string['attempt_highest'] = 'Highest score attempt';
+$string['attempt_last'] = 'Last attempt';
+$string['attempt_none'] = 'No attempts are used for grading';
+$string['attempt_number'] = 'Attempt #{$a}';
+$string['attempt_success_fail'] = 'Fail';
+$string['attempt_success_pass'] = 'Pass';
+$string['attempt_success_unknown'] = 'Not reported';
 $string['attempts'] = 'Attempts';
+$string['attempts_report'] = 'Attempts report';
+$string['attempts_none'] = 'This user has no attempts to display.';
+$string['choice'] = 'Choice';
+$string['completion'] = 'Completion';
+$string['correct_answer'] = 'Correct answer';
 $string['deleteallattempts'] = 'Delete all H5P attempts';
 $string['displayexport'] = 'Allow download';
 $string['displayembed'] = 'Embed button';
 $string['displaycopyright'] = 'Copyright button';
+$string['duration'] = 'Duration';
 $string['enabletracking'] = 'Enable attempt tracking';
+$string['false'] = 'False';
 $string['grade_grademethod'] = 'Grading method';
 $string['grade_grademethod_help'] = 'When using point grading, the following methods are available for calculating the final grade:
 
@@ -47,6 +75,7 @@ $string['grade_average_attempt'] = 'Average grade';
 $string['grade_last_attempt'] = 'Last attempt';
 $string['grade_first_attempt'] = 'First attempt';
 $string['h5pactivity:addinstance'] = 'Add a new H5P';
+$string['h5pactivity:reviewattempts'] = 'Review H5P attempts';
 $string['h5pactivity:submit'] = 'Submit H5P attempts';
 $string['h5pactivity:view'] = 'View H5P';
 $string['h5pactivityfieldset'] = 'H5P settings';
@@ -54,6 +83,7 @@ $string['h5pactivityname'] = 'H5P';
 $string['h5pactivitysettings'] = 'Settings';
 $string['h5pattempts'] = 'Attempt options';
 $string['h5pdisplay'] = 'H5P options';
+$string['maxscore'] = 'Max score';
 $string['modulename'] = 'H5P';
 $string['modulename_help'] = 'H5P is an abbreviation for HTML5 Package - interactive content such as presentations, videos and other multimedia, questions, quizzes, games and more. The H5P activity enables H5P to be uploaded and added to a course.
 
@@ -61,6 +91,10 @@ Any question attempts are marked automatically, and the grade is recorded in the
 $string['modulename_link'] = 'mod/h5pactivity/view';
 $string['modulenameplural'] = 'H5P';
 $string['myattempts'] = 'My attempts';
+$string['no_compatible_track'] = 'This interaction ({$a}) does not provide tracking information or the tracking
+        provided is not compatible with the current activity version.';
+$string['noparticipants'] = 'No participants to display';
+$string['outcome'] = 'Outcome';
 $string['package'] = 'Package file';
 $string['package_help'] = 'The package file is a h5p file containing H5P interactive content.';
 $string['page-mod-h5pactivity-x'] = 'Any H5P module page';
@@ -74,6 +108,27 @@ $string['privacy:metadata:timemodified'] = 'The last time element was tracked';
 $string['privacy:metadata:userid'] = 'The ID of the user who accessed the H5P activity';
 $string['privacy:metadata:xapi_track'] = 'Attempt tracking information';
 $string['privacy:metadata:xapi_track_results'] = 'Attempt results tracking information';
+$string['report_viewed'] = 'Report viewed';
+$string['result_compound'] = 'Combined partial score';
+$string['result_fill-in'] = 'Fill-in text';
+$string['result_fill-in_gap'] = 'Gap #{$a}';
+$string['result_matching'] = 'Matching choice';
+$string['result_other'] = 'Unkown interaction type';
+$string['result_sequencing_choice'] = 'Positions';
+$string['result_sequencing_answer'] = 'Position value';
+$string['result_sequencing_position'] = '#{$a}';
+$string['review_all_attempts'] = 'View all attempts ({$a} submitted)';
+$string['review_mode'] = 'Review attempts';
+$string['review_my_attempts'] = 'View my attempts';
+$string['review_user_attempts'] = 'View user attempts ({$a})';
+$string['review_none'] = 'Participants cannot review their own attempts';
+$string['review_on_completion'] = 'Participants can review their own attempts';
+$string['score'] = 'Score';
+$string['score_out_of'] = '{$a->rawscore} out of {$a->maxscore}';
+$string['startdate'] = 'Start date';
 $string['statement_received'] = 'xAPI statement received';
+$string['totalscore'] = 'Total score';
 $string['tracking_messages'] = 'Some H5P provide attempt tracking data for advanced reporting such as number of attempts, responses and grades. Note: Some H5P don\'t provide attempt tracking data. In such cases, the following settings will have no effect.';
+$string['true'] = 'True';
 $string['view'] = 'View';
+$string['view_report'] = 'View report';
index d3c20d0..090a1a3 100644 (file)
@@ -104,6 +104,11 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
         $mform->disabledIf('grademethod', 'grade[modgrade_type]', 'neq', 'point');
         $mform->addHelpButton('grademethod', 'grade_grademethod', 'mod_h5pactivity');
 
+        $options = manager::get_review_modes();
+        $mform->addElement('select', 'reviewmode', get_string('review_mode', 'mod_h5pactivity'), $options);
+        $mform->setType('reviewmode', PARAM_INT);
+        $mform->hideIf('reviewmode', 'enabletracking', 'notchecked');
+
         // Add standard elements.
         $this->standard_coursemodule_elements();
 
diff --git a/mod/h5pactivity/report.php b/mod/h5pactivity/report.php
new file mode 100644 (file)
index 0000000..e1ed52a
--- /dev/null
@@ -0,0 +1,130 @@
+<?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/>.
+
+/**
+ * Prints an instance of mod_h5pactivity.
+ *
+ * @package     mod_h5pactivity
+ * @copyright   2020 Ferran Recio <ferran@moodle.com>
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\event\report_viewed;
+
+require(__DIR__.'/../../config.php');
+require_once(__DIR__.'/lib.php');
+
+$userid = optional_param('userid', null, PARAM_INT);
+$attemptid = optional_param('attemptid', null, PARAM_INT);
+
+// Attempts have only the instance id information but system events
+// have only cmid. To prevent unnecesary db queries, this page accept both.
+$id = optional_param('id', null, PARAM_INT);
+if (empty($id)) {
+    $a = required_param('a', PARAM_INT);
+    list ($course, $cm) = get_course_and_cm_from_instance($a, 'h5pactivity');
+} else {
+    list ($course, $cm) = get_course_and_cm_from_cmid($id, 'h5pactivity');
+}
+
+require_login($course, true, $cm);
+
+$manager = manager::create_from_coursemodule($cm);
+
+$report = $manager->get_report($userid, $attemptid);
+if (!$report) {
+    print_error('permissiondenied');
+}
+
+$user = $report->get_user();
+$attempt = $report->get_attempt();
+
+$moduleinstance = $manager->get_instance();
+
+$context = $manager->get_context();
+
+$params = ['a' => $cm->instance];
+if ($user) {
+    $params['userid'] = $user->id;
+}
+if ($attempt) {
+    $params['attemptid'] = $attempt->get_id();
+}
+$PAGE->set_url('/mod/h5pactivity/report.php', $params);
+
+// Trigger event.
+$other = [
+    'instanceid' => $params['a'],
+    'userid' => $params['userid'] ?? null,
+    'attemptid' => $params['attemptid'] ?? null,
+];
+$event = report_viewed::create([
+    'objectid' => $moduleinstance->id,
+    'context' => $context,
+    'other' => $other,
+]);
+$event->add_record_snapshot('course', $course);
+$event->add_record_snapshot('h5pactivity', $moduleinstance);
+$event->trigger();
+
+$shortname = format_string($course->shortname, true, ['context' => $context]);
+$pagetitle = strip_tags($shortname.': '.format_string($moduleinstance->name));
+$PAGE->set_title(format_string($pagetitle));
+
+$navbar = [];
+if ($manager->can_view_all_attempts()) {
+    // Report navbar have 3 levels for teachers:
+    // - Participants list
+    // - Participant attempts list
+    // - Individual attempt details.
+    $nav = [get_string('attempts', 'mod_h5pactivity'), null];
+    if ($user) {
+        $nav[1] = new moodle_url('/mod/h5pactivity/report.php', ['a' => $cm->instance]);
+        $navbar[] = $nav;
+
+        $nav = [fullname($user), null];
+        if ($attempt) {
+            $nav[1] = new moodle_url('/mod/h5pactivity/report.php', ['a' => $cm->instance, 'userid' => $user->id]);
+        }
+    }
+    $navbar[] = $nav;
+} else {
+    // Report navbar have 2 levels for a regular participant:
+    // - My attempts
+    // - Individual attempt details.
+    $nav = [get_string('myattempts', 'mod_h5pactivity'), null];
+    if ($attempt) {
+        $nav[1] = new moodle_url('/mod/h5pactivity/report.php', ['a' => $cm->instance]);
+    }
+    $navbar[] = $nav;
+
+}
+if ($attempt) {
+    $navbar[] = [get_string('attempt_number', 'mod_h5pactivity', $attempt->get_attempt()), null];
+}
+foreach ($navbar as $nav) {
+    $PAGE->navbar->add($nav[0], $nav[1]);
+}
+
+$PAGE->set_heading(format_string($course->fullname));
+$PAGE->set_context($context);
+
+echo $OUTPUT->header();
+
+echo $report->print();
+
+echo $OUTPUT->footer();
diff --git a/mod/h5pactivity/templates/attempt.mustache b/mod/h5pactivity/templates/attempt.mustache
new file mode 100644 (file)
index 0000000..faa97b3
--- /dev/null
@@ -0,0 +1,96 @@
+{{!
+    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 mod_h5pactivity/attempt
+
+    This template will render an attempt basic information inside a H5P activity results report.
+
+    Variables required for this template:
+    * timemodified - Full attempts list
+    * attempt - Full attempts list
+    * rawscore - Full attempts list
+    * maxscore - Full attempts list
+    * duration - Full attempts list
+    * completionicon - Full attempts list
+    * successicon - Full attempts list
+    * reporturl - Full attempts list
+
+    Variables optional for this template:
+    * user - optional user record
+    * scored - The scored attempt
+
+    Example context (json):
+    {
+        "id": 11,
+        "h5pactivityid": 1,
+        "userid": 3,
+        "timecreated": 1587655101,
+        "timemodified": 1587655101,
+        "attempt": 2,
+        "rawscore": 6,
+        "maxscore": 6,
+        "duration": "2 minutes 10 seconds",
+        "completion": 1,
+        "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"  title=\"Attempt completed\" aria-label=\"Attempt completed\"><\/i>",
+        "success": 1,
+        "successicon": "<i class=\"icon fa fa-check-circle fa-fw \"  title=\"Attempt completed successfully\" aria-label=\"Attempt completed successfully\"><\/i>",
+        "scaled": 1,
+        "reporturl": {},
+        "score": "6 out of 6",
+        "user": {
+            "id": "3",
+            "idnumber": "",
+            "firstname": "John",
+            "lastname": "Doe",
+            "email": "s1@example.com",
+            "institution": "Moodle HQ",
+            "department": "Business",
+            "address": "",
+            "city": "Barcelona",
+            "country": "Spain",
+            "lang": "en",
+            "picture": "[userpic]",
+            "fullname": "User Fullname"
+        }
+    }
+
+}}
+<h2 class="px-0 px-md-3">
+    {{#user}}
+      {{{picture}}}
+      {{#str}}attempt, mod_h5pactivity{{/str}} #{{attempt}}: {{fullname}}
+    {{/user}}
+    {{^user}}
+      {{#str}}attempt, mod_h5pactivity{{/str}} #{{attempt}}
+    {{/user}}
+</h2>
+<div class="container-fluid mt-4 mb-5 px-0 px-md-3">
+    <dl class="row">
+        <dt class="col-12">{{#str}} startdate, mod_h5pactivity {{/str}}</dt>
+        <dd class="col-12">{{#userdate}} {{timemodified}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}</dd>
+        <dt class="col-12">{{#str}} completion, mod_h5pactivity {{/str}}</dt>
+        <dd class="col-12">{{{completiontext}}}</dd>
+        <dt class="col-12 text-truncate">{{#str}} duration, mod_h5pactivity {{/str}}</dt>
+        <dd class="col-12">{{duration}}</dd>
+        <dt class="col-12">{{#str}} outcome, mod_h5pactivity {{/str}}</dt>
+        <dd class="col-12">{{{successtext}}}</dd>
+        {{#score}}
+            <dt class="col-12">{{#str}} totalscore, mod_h5pactivity {{/str}}</dt>
+            <dd class="col-12">{{score}}</dd>
+        {{/score}}
+    </dl>
+</div>
diff --git a/mod/h5pactivity/templates/attempts.mustache b/mod/h5pactivity/templates/attempts.mustache
new file mode 100644 (file)
index 0000000..2f1f549
--- /dev/null
@@ -0,0 +1,145 @@
+{{!
+    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 mod_h5pactivity/attempts
+
+    This template will render the report link inside a H5P activity.
+
+    Variables required for this template:
+    * attempts - Full attempts list with:
+      * timemodified - Attempt timemodified
+      * attempt - Attempt number
+      * rawscore - Attempt rawscore
+      * maxscore - Attempt maxscore
+      * duration - Attempt duration
+      * completionicon - Completion icon HTML
+      * successicon - Success icon HTML
+      * reporturl - Attempt report URL
+
+    Variables optional for this template:
+    * title - optional selected attempt name
+    * scored - The scored attempt
+
+    Example context (json):
+    {
+        "attempts": [
+            {
+                "id": 7,
+                "h5pactivityid": 1,
+                "userid": 5,
+                "timecreated": 1587654916,
+                "timemodified": 1587654916,
+                "attempt": 1,
+                "rawscore": 6,
+                "maxscore": 6,
+                "duration": "14 seconds",
+                "durationcompact": "14''",
+                "completion": 1,
+                "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "success": 1,
+                "successicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "scaled": 1,
+                "reporturl": {},
+                "score": "6 out of 6"
+            },
+            {
+                "id": 8,
+                "h5pactivityid": 1,
+                "userid": 5,
+                "timecreated": 1587654927,
+                "timemodified": 1587654927,
+                "attempt": 2,
+                "rawscore": 1,
+                "maxscore": 6,
+                "duration": "25 seconds",
+                "durationcompact": "25''",
+                "completion": 1,
+                "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "success": 0,
+                "successicon": "<i class=\"icon fa fa-circle-o fa-fw \"><\/i>",
+                "scaled": 0,
+                "reporturl": {},
+                "score": "1 out of 6"
+            },
+            {
+                "id": 9,
+                "h5pactivityid": 1,
+                "userid": 5,
+                "timecreated": 1587654942,
+                "timemodified": 1587654942,
+                "attempt": 3,
+                "rawscore": 3,
+                "maxscore": 6,
+                "duration": "40 seconds",
+                "durationcompact": "40''",
+                "completion": 1,
+                "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "success": 0,
+                "successicon": "<i class=\"icon fa fa-circle-o fa-fw \"><\/i>",
+                "scaled": 0,
+                "reporturl": {},
+                "score": "3 out of 6"
+            }
+        ]
+    }
+
+}}
+<table class="table">
+  <thead>
+    <tr>
+      <th scope="col">#</th>
+      <th scope="col">{{#str}} date {{/str}}</th>
+      <th scope="col">{{#str}} score, mod_h5pactivity {{/str}}</th>
+      <th scope="col" class="d-none d-md-table-cell">{{#str}} maxscore, mod_h5pactivity {{/str}}</th>
+      <th scope="col" class="d-none d-sm-table-cell">{{#str}} duration, mod_h5pactivity {{/str}}</th>
+      <th scope="col" class="d-none d-sm-table-cell" class="text-center">{{#str}} completion, mod_h5pactivity {{/str}}</th>
+      <th scope="col" class="text-center">{{#str}} success {{/str}}</th>
+      <th scope="col">{{#str}} report {{/str}}</th>
+    </tr>
+  </thead>
+  <tbody>
+    {{#attempts}}
+    <tr>
+      <th scope="row">{{attempt}}</th>
+      <td>
+        <span class="d-none d-lg-inline">
+          {{#userdate}} {{timemodified}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}
+        </span>
+        <span class="d-inline d-lg-none">
+          {{#userdate}} {{timemodified}}, {{#str}} strftimedatetimeshort, core_langconfig {{/str}} {{/userdate}}
+        </span>
+      </td>
+      <td>{{rawscore}}<span class="d-inline d-md-none"> / {{maxscore}}</span></td>
+      <td class="d-none d-md-table-cell">{{maxscore}}</td>
+      <td class="d-none d-sm-table-cell">
+        <span class="d-none d-md-inline">{{duration}}</span>
+        <span class="d-inline d-md-none">{{durationcompact}}</span>
+      </td>
+      <td class="text-center d-none d-sm-table-cell">{{{completionicon}}}</td>
+      <td class="text-center">{{{successicon}}}</td>
+      <td>
+        <span class="d-none d-md-inline">
+          <a href="{{{reporturl}}}">{{#str}} view_report, mod_h5pactivity {{/str}}</a>
+        </span>
+        <span class="d-inline d-md-none">
+          <a href="{{{reporturl}}}">{{#str}} view {{/str}}</a>
+        </span>
+      </td>
+    </tr>
+    {{/attempts}}
+  </tbody>
+</table>
diff --git a/mod/h5pactivity/templates/reportattempts.mustache b/mod/h5pactivity/templates/reportattempts.mustache
new file mode 100644 (file)
index 0000000..7dd697b
--- /dev/null
@@ -0,0 +1,156 @@
+{{!
+    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 mod_h5pactivity/reportattempts
+
+    This template will render the report link inside a H5P activity.
+
+    Variables required for this template:
+    * attempts - Full attempts list
+
+    Variables optional for this template:
+    * title - optional selected attempt name
+    * scored - The scored attempt
+    * attemptscount - The current number of attempts
+
+    Example context (json):
+    {
+        "attempts": [
+            {
+                "id": 7,
+                "h5pactivityid": 1,
+                "userid": 5,
+                "timecreated": 1587654916,
+                "timemodified": 1587654916,
+                "attempt": 1,
+                "rawscore": 6,
+                "maxscore": 6,
+                "duration": "14 seconds",
+                "durationcompact": "14''",
+                "completion": 1,
+                "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "success": 1,
+                "successicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",
+                "scaled": 1,
+                "reporturl": {},
+                "score": "6 out of 6"
+            },
+            {
+                "id": 8,
+                "h5pactivityid": 1,
+                "userid": 5,
+                "timecreated": 1587654927,
+                "timemodified": 1587654927,
+                "attempt": 2,
+                "rawscore": 1,
+                "maxscore": 6,
+                "duration": "25 seconds",
+                "durationcompact": "25''",
+                "completion": 1,
+                "completionicon": "<i class=\"icon fa fa-check-circle fa-fw \"><\/i>",