Merge branch 'MDL-68367' of https://github.com/mkassaei/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 21 May 2020 09:10:16 +0000 (17:10 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 21 May 2020 09:10:16 +0000 (17:10 +0800)
178 files changed:
admin/settings/courses.php
backup/util/helper/tests/async_helper_test.php
badges/backpack-connect.php [new file with mode: 0644]
badges/backpack-export.php [new file with mode: 0644]
badges/classes/backpack_api2p1.php [new file with mode: 0644]
badges/classes/backpack_api2p1_mapping.php [new file with mode: 0644]
badges/classes/form/backpack.php
badges/classes/form/external_backpack.php
badges/classes/oauth2/auth.php [new file with mode: 0644]
badges/classes/oauth2/badge_backpack_oauth2.php [new file with mode: 0644]
badges/classes/oauth2/client.php [new file with mode: 0644]
badges/classes/output/external_backpacks_page.php
badges/classes/privacy/provider.php
badges/mybackpack.php
badges/oauth2callback.php [new file with mode: 0644]
badges/renderer.php
badges/tests/behat/backpack.feature [new file with mode: 0644]
blocks/timeline/templates/event-list-item.mustache
calendar/amd/build/selectors.min.js
calendar/amd/build/selectors.min.js.map
calendar/amd/build/view_manager.min.js
calendar/amd/build/view_manager.min.js.map
calendar/amd/src/selectors.js
calendar/amd/src/view_manager.js
calendar/classes/external/calendar_event_exporter.php
calendar/classes/external/event_action_exporter.php
calendar/classes/external/event_exporter.php
calendar/classes/external/event_exporter_base.php
calendar/classes/external/event_icon_exporter.php
calendar/classes/local/event/container.php
calendar/classes/local/event/data_access/event_vault.php
calendar/classes/local/event/entities/action_event.php
calendar/classes/local/event/entities/event.php
calendar/classes/local/event/entities/event_interface.php
calendar/classes/local/event/factories/event_abstract_factory.php
calendar/classes/local/event/mappers/event_mapper.php
calendar/externallib.php
calendar/lib.php
calendar/renderer.php
calendar/templates/event_details.mustache
calendar/templates/event_item.mustache
calendar/templates/event_summary_modal.mustache
calendar/templates/month_detailed.mustache
calendar/tests/action_event_test.php
calendar/tests/event_mapper_test.php
calendar/tests/event_test.php
calendar/tests/helpers.php
calendar/tests/repeat_event_collection_test.php
calendar/upgrade.txt
config-dist.php
contentbank/classes/contenttype.php
contentbank/tests/fixtures/testable_contenttype.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/build/local/activitychooser/selectors.min.js
course/amd/build/local/activitychooser/selectors.min.js.map
course/amd/src/activitychooser.js
course/amd/src/local/activitychooser/dialogue.js
course/amd/src/local/activitychooser/selectors.js
course/classes/category.php
course/classes/deletecategory_form.php
course/modlib.php
course/renderer.php
course/templates/activity_navigation.mustache
course/templates/activitychooser.mustache
course/tests/behat/activity_chooser.feature
course/tests/behat/recommend_activities.feature
course/tests/behat/search_recommended_activities.feature
course/tests/category_hooks_test.php [new file with mode: 0644]
course/tests/fixtures/mock_hooks.php [new file with mode: 0644]
enrol/manual/ajax.php
enrol/manual/amd/build/quickenrolment.min.js
enrol/manual/amd/build/quickenrolment.min.js.map
enrol/manual/amd/src/quickenrolment.js
enrol/manual/tests/behat/quickenrolment.feature
grade/tests/report_graderlib_test.php
grade/tests/reportuserlib_test.php
lang/en/badges.php
lang/en/calendar.php
lang/en/course.php
lang/en/error.php
lib/adminlib.php
lib/badgeslib.php
lib/behat/classes/behat_core_generator.php
lib/behat/classes/behat_generator_base.php
lib/classes/event/course_category_deleted.php
lib/classes/output/icon_system_fontawesome.php
lib/classes/session/redis.php
lib/db/install.xml
lib/db/upgrade.php
lib/editor/atto/db/upgrade.php
lib/editor/atto/settings.php
lib/editor/atto/version.php
lib/templates/full_header.mustache
lib/templates/navbar.mustache
lib/templates/preferences_groups.mustache
lib/testing/generator/data_generator.php
lib/testing/tests/generator_test.php
lib/tests/accesslib_test.php
lib/tests/event/contentbank_content_viewed_test.php
lib/tests/moodlelib_test.php
lib/tests/outputcomponents_test.php
lib/upgrade.txt
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer.min.js.map
message/amd/src/message_drawer.js
message/templates/message_drawer.mustache
message/templates/message_drawer_view_conversation_footer.mustache
message/templates/message_drawer_view_conversation_footer_content.mustache
message/templates/message_drawer_view_conversation_header.mustache
message/templates/message_drawer_view_group_info_body_content.mustache
message/templates/message_drawer_view_overview_header.mustache
message/templates/message_drawer_view_overview_section.mustache
mod/h5pactivity/classes/external/get_attempts.php [new file with mode: 0644]
mod/h5pactivity/db/services.php
mod/h5pactivity/lib.php
mod/h5pactivity/tests/external/get_attempts_test.php [new file with mode: 0644]
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/version.php
mod/quiz/attemptlib.php
mod/quiz/comment.php
mod/quiz/editrandom.php
mod/quiz/locallib.php
mod/quiz/report/grading/report.php
mod/quiz/report/responses/first_or_all_responses_table.php
mod/quiz/styles.css
mod/quiz/tests/attempt_walkthrough_test.php
mod/quiz/tests/behat/editing_add_random.feature
mod/quiz/tests/behat/editing_edit_random.feature [new file with mode: 0644]
question/behaviour/behaviourbase.php
question/behaviour/missing/tests/missingbehaviour_test.php
question/behaviour/rendererbase.php
question/classes/privacy/provider.php
question/engine/datalib.php
question/engine/lib.php
question/engine/questionattempt.php
question/engine/questionusage.php
question/engine/renderer.php
question/engine/tests/questionattempt_db_test.php
question/engine/tests/questionattempt_test.php
question/engine/tests/questionusagebyactivity_data_test.php
question/engine/tests/questionusagebyactivity_test.php
question/engine/tests/unitofwork_test.php
question/engine/upgrade.txt
question/preview.php
question/previewlib.php
question/tests/generator/behat_core_question_generator.php [new file with mode: 0644]
question/tests/generator/lib.php
question/tests/privacy_helper.php
question/type/ddmarker/question.php
question/type/missingtype/tests/missingtype_test.php
question/type/questiontypebase.php
question/type/random/questiontype.php
theme/boost/scss/moodle/admin.scss
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/course.scss
theme/boost/scss/moodle/debug.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/forms.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/question.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
user/amd/build/participants.min.js
user/amd/build/participants.min.js.map
user/amd/src/participants.js
user/index.php
user/tests/externallib_test.php
user/tests/privacy_test.php
user/tests/profilelib_test.php
user/tests/userlib_test.php
version.php

index 8936f7c..b6ba368 100644 (file)
@@ -61,12 +61,6 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             array('moodle/restore:restorecourse')
         )
     );
-    $ADMIN->add('courses',
-        new admin_externalpage('activitychooser', new lang_string('activitychooserrecommendations', 'course'),
-            new moodle_url('/course/recommendations.php'),
-            array('moodle/course:recommendactivity')
-        )
-    );
 
     // Course Default Settings Page.
     // NOTE: these settings must be applied after all other settings because they depend on them.
@@ -187,6 +181,30 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
                 $CFG->wwwroot . '/course/pending.php', array('moodle/site:approvecourse')));
     }
 
+    // Add a category for the Activity Chooser.
+    $ADMIN->add('courses', new admin_category('activitychooser', new lang_string('activitychoosercategory', 'course')));
+    $temp = new admin_settingpage('activitychoosersettings', new lang_string('activitychoosersettings', 'course'));
+    $temp->add(
+        new admin_setting_configselect(
+            'activitychoosertabmode',
+            new lang_string('activitychoosertabmode', 'course'),
+            new lang_string('activitychoosertabmode_desc', 'course'),
+            0,
+            [
+                0 => new lang_string('activitychoosertabmodeone', 'course'),
+                1 => new lang_string('activitychoosertabmodetwo', 'course'),
+                2 => new lang_string('activitychoosertabmodethree', 'course'),
+            ]
+        )
+    );
+    $ADMIN->add('activitychooser', $temp);
+    $ADMIN->add('activitychooser',
+        new admin_externalpage('activitychooserrecommended', new lang_string('activitychooserrecommendations', 'course'),
+            new moodle_url('/course/recommendations.php'),
+            array('moodle/course:recommendactivity')
+        )
+    );
+
     // Add a category for backups.
     $ADMIN->add('courses', new admin_category('backups', new lang_string('backups','admin')));
 
index c27ccf2..fffcd16 100644 (file)
@@ -161,6 +161,7 @@ class core_backup_async_helper_testcase extends \core_privacy\tests\provider_tes
         $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();
+        $bc->destroy();
         $copyrec = \async_helper::get_backup_record($backupid);
 
         $this->assertEquals($backupid, $copyrec->backupid);
@@ -185,8 +186,9 @@ class core_backup_async_helper_testcase extends \core_privacy\tests\provider_tes
         $this->assertFalse($ispending);
 
         // Create the initial backupcontoller.
-        new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
             \backup::INTERACTIVE_NO, \backup::MODE_ASYNC, $USER->id, \backup::RELEASESESSION_YES);
+        $bc->destroy();
         $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
 
         // Should be false as there as async backup is false.
@@ -215,8 +217,9 @@ class core_backup_async_helper_testcase extends \core_privacy\tests\provider_tes
         $this->assertFalse($ispending);
 
         // Create the initial backupcontoller.
-        new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
+        $bc = new \backup_controller(\backup::TYPE_1COURSE, $course->id, \backup::FORMAT_MOODLE,
             \backup::INTERACTIVE_NO, \backup::MODE_COPY, $USER->id, \backup::RELEASESESSION_YES);
+        $bc->destroy();
         $ispending = async_helper::is_async_pending($course->id, 'course', 'backup');
 
         // Should be True as this a copy operation.
diff --git a/badges/backpack-connect.php b/badges/backpack-connect.php
new file mode 100644 (file)
index 0000000..cece808
--- /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/>.
+
+/**
+ * Connect to backpack site.
+ *
+ * @package    core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/badgeslib.php');
+
+$scope = optional_param('scope', '', PARAM_RAW);
+$action = optional_param('action', null, PARAM_RAW);
+
+if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
+    throw new coding_exception('backpacks only support Open Badges V2.1');
+}
+
+require_login();
+
+$externalbackpack = badges_get_site_backpack($CFG->badges_site_backpack);
+$persistedissuer = \core\oauth2\issuer::get_record(['id' => $externalbackpack->oauth2_issuerid]);
+if ($persistedissuer) {
+    $issuer = new \core\oauth2\issuer($externalbackpack->oauth2_issuerid);
+    $returnurl = new moodle_url('/badges/backpack-connect.php',
+        ['action' => 'authorization', 'sesskey' => sesskey()]);
+
+    $client = new core_badges\oauth2\client($issuer, $returnurl, $scope, $externalbackpack);
+    if ($client) {
+        if (!$client->is_logged_in()) {
+            redirect($client->get_login_url());
+        }
+        $wantsurl = new moodle_url('/badges/mybadges.php');
+        $auth = new \core_badges\oauth2\auth();
+        $auth->complete_data($client, $wantsurl);
+    } else {
+        throw new moodle_exception('Could not get an OAuth client.');
+    }
+} else {
+    throw new moodle_exception('Unknown OAuth client.');
+}
diff --git a/badges/backpack-export.php b/badges/backpack-export.php
new file mode 100644 (file)
index 0000000..e1015b7
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * Export badges to the backpack site.
+ *
+ * @package    core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+require_once(__DIR__ . '/../config.php');
+require_once($CFG->libdir . '/badgeslib.php');
+
+if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
+    throw new coding_exception('backpacks only support Open Badges V2.1');
+}
+$hash = optional_param('hash', null, PARAM_RAW);
+
+$PAGE->set_pagelayout('admin');
+$url = new moodle_url('/badges/backpack-export.php');
+
+require_login();
+if (empty($CFG->badges_allowexternalbackpack) || empty($CFG->enablebadges)) {
+    redirect($CFG->wwwroot);
+}
+$backpack = badges_get_site_backpack($CFG->badges_site_backpack);
+$userbadges = badges_get_user_badges($USER->id);
+$context = context_user::instance($USER->id);
+
+$PAGE->set_context($context);
+$PAGE->set_url($url);
+$title = get_string('badges', 'badges');
+$PAGE->set_title($title);
+$PAGE->set_heading(fullname($USER));
+$PAGE->set_pagelayout('standard');
+
+$redirecturl = new moodle_url('/badges/mybadges.php');
+if ($hash) {
+    $backpack = badges_get_site_backpack($CFG->badges_site_backpack);
+    $api = new core_badges\backpack_api2p1($backpack);
+    $notify = $api->put_assertions($hash);
+    if (!empty($notify['status']) && $notify['status'] == \core\output\notification::NOTIFY_SUCCESS) {
+        redirect($redirecturl, $notify['message'], null, \core\output\notification::NOTIFY_SUCCESS);
+    } else if (!empty($notify['status']) && $notify['status'] == \core\output\notification::NOTIFY_ERROR) {
+        redirect($redirecturl, $notify['message'], null, \core\output\notification::NOTIFY_ERROR);
+    }
+}
+redirect($redirecturl);
\ No newline at end of file
diff --git a/badges/classes/backpack_api2p1.php b/badges/classes/backpack_api2p1.php
new file mode 100644 (file)
index 0000000..055a3c3
--- /dev/null
@@ -0,0 +1,236 @@
+<?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/>.
+
+/**
+ * Communicate with backpacks.
+ *
+ * @copyright  2020 Tung Thai based on Totara Learning Solutions Ltd {@link http://www.totaralms.com/} dode
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/filelib.php');
+
+use cache;
+use coding_exception;
+use context_system;
+use moodle_url;
+use core_badges\backpack_api2p1_mapping;
+use core_badges\oauth2\client;
+use curl;
+use stdClass;
+
+/**
+ * To process badges with backpack and control api request and this class using for Open Badge API v2.1 methods.
+ *
+ * @package   core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backpack_api2p1 {
+
+    /** @var object is the external backpack. */
+    private $externalbackpack;
+
+    /** @var array define api mapping. */
+    private $mappings = [];
+
+    /** @var false|null|stdClass|\core_badges\backpack_api2p1 to */
+    private $tokendata;
+
+    /** @var null clienid. */
+    private $clientid = null;
+
+    /** @var null version api of the backpack. */
+    protected $backpackapiversion;
+
+    /** @var null api URL of the backpack. */
+    protected $backpackapiurl = '';
+
+    /**
+     * backpack_api2p1 constructor.
+     *
+     * @param object $externalbackpack object
+     * @throws coding_exception error message
+     */
+    public function __construct($externalbackpack) {
+
+        if (!empty($externalbackpack)) {
+            $this->externalbackpack = $externalbackpack;
+            $this->backpackapiversion = $externalbackpack->apiversion;
+            $this->backpackapiurl = $externalbackpack->backpackapiurl;
+            $this->get_clientid = $this->get_clientid($externalbackpack->oauth2_issuerid);
+
+            if (!($this->tokendata = $this->get_stored_token($externalbackpack->id))
+                && $this->backpackapiversion != OPEN_BADGES_V2P1) {
+                throw new coding_exception('Backpack incorrect');
+            }
+        }
+
+        $this->define_mappings();
+    }
+
+
+    /**
+     * Define the mappings supported by this usage and api version.
+     */
+    private function define_mappings() {
+        if ($this->backpackapiversion == OPEN_BADGES_V2P1) {
+
+            $mapping = [];
+            $mapping[] = [
+                'post.assertions',                               // Action.
+                '[URL]/assertions',   // URL
+                '[PARAM]',                                  // Post params.
+                false,                                      // Multiple.
+                'post',                                     // Method.
+                true,                                       // JSON Encoded.
+                true                                        // Auth required.
+            ];
+
+            $mapping[] = [
+                'get.assertions',                               // Action.
+                '[URL]/assertions',   // URL
+                '[PARAM]',                                  // Post params.
+                false,                                      // Multiple.
+                'get',                                     // Method.
+                true,                                       // JSON Encoded.
+                true                                        // Auth required.
+            ];
+
+            foreach ($mapping as $map) {
+                $map[] = false; // Site api function.
+                $map[] = OPEN_BADGES_V2P1; // V2 function.
+                $this->mappings[] = new backpack_api2p1_mapping(...$map);
+            }
+
+        }
+    }
+
+    /**
+     * Disconnect the backpack from this user.
+     *
+     * @param object $backpack to disconnect.
+     * @return bool
+     * @throws \dml_exception
+     */
+    public function disconnect_backpack($backpack) {
+        global $USER, $DB;
+
+        if ($backpack) {
+            $DB->delete_records_select('badge_external', 'backpackid = :backpack', ['backpack' => $backpack->id]);
+            $DB->delete_records('badge_backpack', ['id' => $backpack->id]);
+            $DB->delete_records('badge_backpack_oauth2', ['externalbackpackid' => $this->externalbackpack->id,
+                'userid' => $USER->id]);
+
+            return true;
+        }
+        return false;
+    }
+
+    /**
+     * Make an api request.
+     *
+     * @param string $action The api function.
+     * @param string $postdata The body of the api request.
+     * @return mixed
+     */
+    public function curl_request($action, $postdata = null) {
+        $tokenkey = $this->tokendata->token;
+        foreach ($this->mappings as $mapping) {
+            if ($mapping->is_match($action)) {
+                return $mapping->request(
+                    $this->backpackapiurl,
+                    $tokenkey,
+                    $postdata
+                );
+            }
+        }
+
+        throw new coding_exception('Unknown request');
+    }
+
+    /**
+     * Get token.
+     *
+     * @param int $externalbackpackid ID of external backpack.
+     * @return oauth2\badge_backpack_oauth2|false|stdClass|null
+     */
+    protected function get_stored_token($externalbackpackid) {
+        global $USER;
+
+        $token = \core_badges\oauth2\badge_backpack_oauth2::get_record(
+            ['externalbackpackid' => $externalbackpackid, 'userid' => $USER->id]);
+        if ($token !== false) {
+            $token = $token->to_record();
+            return $token;
+        }
+        return null;
+    }
+
+    /**
+     * Get client id.
+     *
+     * @param int $issuerid id of Oauth2 service.
+     * @throws coding_exception
+     */
+    private function get_clientid($issuerid) {
+        $issuer = \core\oauth2\api::get_issuer($issuerid);
+        if (!empty($issuer)) {
+            $this->clientid = $issuer->get('clientid');
+        }
+    }
+
+    /**
+     * Export a badge to the backpack site.
+     *
+     * @param string $hash of badge issued.
+     * @return array
+     * @throws \moodle_exception
+     * @throws coding_exception
+     */
+    public function put_assertions($hash) {
+        $data = [];
+        if (!$hash) {
+            return false;
+        }
+
+        $issuer = new \core\oauth2\issuer($this->externalbackpack->oauth2_issuerid);
+        $client = new client($issuer, new moodle_url('/badges/mybadges.php'), '', $this->externalbackpack);
+        if (!$client->is_logged_in()) {
+            $redirecturl = new moodle_url('/badges/mybadges.php', ['error' => 'backpackexporterror']);
+            redirect($redirecturl);
+        }
+
+        $this->tokendata = $this->get_stored_token($this->externalbackpack->id);
+
+        $assertion = new \core_badges_assertion($hash, OPEN_BADGES_V2);
+        $data['assertion'] = $assertion->get_badge_assertion();
+        $response = $this->curl_request('post.assertions', $data);
+        if ($response && isset($response->status->statusCode) && $response->status->statusCode == 200) {
+            $msg['status'] = \core\output\notification::NOTIFY_SUCCESS;
+            $msg['message'] = get_string('addedtobackpack', 'badges');
+        } else {
+            $msg['status'] = \core\output\notification::NOTIFY_ERROR;
+            $msg['message'] = get_string('backpackexporterror', 'badges', $data['assertion']['badge']['name']);
+        }
+        return $msg;
+    }
+}
diff --git a/badges/classes/backpack_api2p1_mapping.php b/badges/classes/backpack_api2p1_mapping.php
new file mode 100644 (file)
index 0000000..7419f3b
--- /dev/null
@@ -0,0 +1,178 @@
+<?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/>.
+
+/**
+ * Represent the url for each method and the encoding of the parameters and response.
+ *
+ * The code is based on badges/classes/backpack_api_mapping.php by Yuliya Bozhko <yuliya.bozhko@totaralms.com>.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/filelib.php');
+
+use context_system;
+use curl;
+
+/**
+ * Represent a single method for the remote api and this class using for Open Badge API v2.1 methods.
+ *
+ * @package   core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class backpack_api2p1_mapping {
+
+    /** @var string The action of this method. */
+    public $action;
+
+    /** @var string The base url of this backpack. */
+    private $url;
+
+    /** @var array List of parameters for this method. */
+    public $params;
+
+    /** @var boolean This method returns an array of responses. */
+    public $multiple;
+
+    /** @var string get or post methods. */
+    public $method;
+
+    /** @var boolean json decode the response. */
+    public $json;
+
+    /** @var boolean Authentication is required for this request. */
+    public $authrequired;
+
+    /** @var boolean Differentiate the function that can be called on a user backpack or a site backpack. */
+    private $isuserbackpack;
+
+    /**
+     * Create a mapping.
+     *
+     * @param string $action The action of this method.
+     * @param string $url The base url of this backpack.
+     * @param mixed $postparams List of parameters for this method.
+     * @param boolean $multiple This method returns an array of responses.
+     * @param string $method get or post methods.
+     * @param boolean $json json decode the response.
+     * @param boolean $authrequired Authentication is required for this request.
+     * @param boolean $isuserbackpack user backpack or a site backpack.
+     * @param integer $backpackapiversion OpenBadges version 1 or 2.
+     */
+    public function __construct($action, $url, $postparams,
+                                $multiple, $method, $json, $authrequired, $isuserbackpack, $backpackapiversion) {
+        $this->action = $action;
+        $this->url = $url;
+        $this->postparams = $postparams;
+        $this->multiple = $multiple;
+        $this->method = $method;
+        $this->json = $json;
+        $this->authrequired = $authrequired;
+        $this->isuserbackpack = $isuserbackpack;
+        $this->backpackapiversion = $backpackapiversion;
+    }
+
+    /**
+     * Does the action match this mapping?
+     *
+     * @param string $action The action.
+     * @return boolean
+     */
+    public function is_match($action) {
+        return $this->action == $action;
+    }
+
+    /**
+     * Parse the method url and insert parameters.
+     *
+     * @param string $apiurl The raw apiurl.
+     * @return string
+     */
+    private function get_url($apiurl) {
+        $urlscheme = parse_url($apiurl, PHP_URL_SCHEME);
+        $urlhost = parse_url($apiurl, PHP_URL_HOST);
+
+        $url = $this->url;
+        $url = str_replace('[SCHEME]', $urlscheme, $url);
+        $url = str_replace('[HOST]', $urlhost, $url);
+        $url = str_replace('[URL]', $apiurl, $url);
+
+        return $url;
+    }
+
+    /**
+     * Standard options used for all curl requests.
+     *
+     * @return array
+     */
+    private function get_curl_options() {
+        return array(
+            'FRESH_CONNECT'     => true,
+            'RETURNTRANSFER'    => true,
+            'FORBID_REUSE'      => true,
+            'HEADER'            => 0,
+            'CONNECTTIMEOUT'    => 3,
+            'CONNECTTIMEOUT'    => 3,
+            // Follow redirects with the same type of request when sent 301, or 302 redirects.
+            'CURLOPT_POSTREDIR' => 3,
+        );
+    }
+
+    /**
+     * Make an api request and parse the response.
+     *
+     * @param string $apiurl Raw request url.
+     * @param string $tokenkey to verify authorization.
+     * @param array $post request method.
+     * @return bool|mixed
+     */
+    public function request($apiurl, $tokenkey, $post = []) {
+        $curl = new curl();
+        $url = $this->get_url($apiurl);
+        if ($tokenkey) {
+            $curl->setHeader('Authorization: Bearer ' . $tokenkey);
+        }
+
+        if ($this->json) {
+            $curl->setHeader(array('Content-type: application/json'));
+            if ($this->method == 'post') {
+                $post = json_encode($post);
+            }
+        }
+
+        $curl->setHeader(array('Accept: application/json', 'Expect:'));
+        $options = $this->get_curl_options();
+        if ($this->method == 'get') {
+            $response = $curl->get($url, $post, $options);
+        } else if ($this->method == 'post') {
+            $response = $curl->post($url, $post, $options);
+        }
+        $response = json_decode($response);
+        if (isset($response->result)) {
+            $response = $response->result;
+        }
+
+        return $response;
+    }
+}
\ No newline at end of file
index a060d85..6c7370c 100644 (file)
@@ -88,16 +88,18 @@ class backpack extends moodleform {
             $status = html_writer::tag('span', get_string('notconnected', 'badges'),
                 array('class' => 'notconnected', 'id' => 'connection-status'));
             $mform->addElement('static', 'status', get_string('status'), $status);
-            $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"');
-            $mform->addHelpButton('email', 'backpackemail', 'badges');
-            $mform->addRule('email', get_string('required'), 'required', null, 'client');
-            $mform->setType('email', PARAM_EMAIL);
-            if (badges_open_badges_backpack_api() == OPEN_BADGES_V2) {
-                $mform->addElement('passwordunmask', 'backpackpassword', get_string('password'));
-                $mform->setType('backpackpassword', PARAM_RAW);
-            } else {
-                $mform->addElement('hidden', 'backpackpassword', '');
-                $mform->setType('backpackpassword', PARAM_RAW);
+            if (badges_open_badges_backpack_api() != OPEN_BADGES_V2P1) {
+                $mform->addElement('text', 'email', get_string('email'), 'maxlength="100" size="30"');
+                $mform->addHelpButton('email', 'backpackemail', 'badges');
+                $mform->addRule('email', get_string('required'), 'required', null, 'client');
+                $mform->setType('email', PARAM_EMAIL);
+                if (badges_open_badges_backpack_api() == OPEN_BADGES_V2) {
+                    $mform->addElement('passwordunmask', 'backpackpassword', get_string('password'));
+                    $mform->setType('backpackpassword', PARAM_RAW);
+                } else {
+                    $mform->addElement('hidden', 'backpackpassword', '');
+                    $mform->setType('backpackpassword', PARAM_RAW);
+                }
             }
             $this->add_action_buttons(false, get_string('backpackconnectionconnect', 'badges'));
         }
@@ -107,7 +109,12 @@ class backpack extends moodleform {
      * Validates form data
      */
     public function validation($data, $files) {
+        global $CFG;
+
         $errors = parent::validation($data, $files);
+        if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+            return $errors;
+        }
         // We don't need to verify the email address if we're clearing a pending email verification attempt.
         if (!isset($data['revertbutton'])) {
             $check = new stdClass();
index 7b7d653..1bb90c5 100644 (file)
@@ -68,7 +68,7 @@ class external_backpack extends \moodleform {
         $label = $options[$backpack->apiversion];
         $mform->addElement('static', 'apiversioninfo', get_string('apiversion', 'core_badges'), $label);
         $mform->addElement('hidden', 'apiversion', $backpack->apiversion);
-        $mform->setType('apiversion', PARAM_INTEGER);
+        $mform->setType('apiversion', PARAM_RAW);
 
         $mform->addElement('hidden', 'id', $backpack->id);
         $mform->setType('id', PARAM_INTEGER);
@@ -81,12 +81,16 @@ class external_backpack extends \moodleform {
 
         $issuercontact = $CFG->badges_defaultissuercontact;
         $mform->addElement('static', 'issuerinfo', get_string('defaultissuercontact', 'core_badges'), $issuercontact);
-
-        $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
-        $mform->setType('password', PARAM_RAW);
-        $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
-        $mform->hideIf('password', 'apiversion', 'eq', 1);
-
+        if ($backpack->apiversion != OPEN_BADGES_V2P1) {
+            $mform->addElement('passwordunmask', 'password', get_string('defaultissuerpassword', 'core_badges'));
+            $mform->setType('password', PARAM_RAW);
+            $mform->addHelpButton('password', 'defaultissuerpassword', 'badges');
+            $mform->hideIf('password', 'apiversion', 'eq', 1);
+        } else {
+            $oauth2options = badges_get_oauth2_service_options();
+            $mform->addElement('select', 'oauth2_issuerid', get_string('oauth2issuer', 'core_badges'), $oauth2options);
+            $mform->setType('oauth2_issuerid', PARAM_INT);
+        }
         $this->set_data($backpack);
 
         // Disable short forms.
diff --git a/badges/classes/oauth2/auth.php b/badges/classes/oauth2/auth.php
new file mode 100644 (file)
index 0000000..038689c
--- /dev/null
@@ -0,0 +1,96 @@
+<?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 file to proccess Oauth2 connects for backpack.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir.'/authlib.php');
+
+use stdClass;
+
+/**
+ * Proccess Oauth2 connects to backpack site.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class auth extends \auth_oauth2\auth {
+
+    /**
+     * To complete data after login.
+     *
+     * @param client $client object.
+     * @param string $redirecturl the url redirect.
+     */
+    public function complete_data(\core_badges\oauth2\client $client, $redirecturl) {
+        global $DB, $USER;
+
+        $userinfo = $client->get_userinfo();
+        $badgebackpack = new stdClass();
+        $badgebackpack->userid = $USER->id;
+        if ($userinfo && isset($userinfo->email)) {
+            $badgebackpack->email = $userinfo->email;
+        } else {
+            $badgebackpack->email = $USER->email;
+        }
+        $badgebackpack->externalbackpackid = $client->backpack->id;
+        $badgebackpack->backpackuid = 0;
+        $badgebackpack->autosync = 0;
+        $badgebackpack->password = '';
+        $record = $DB->get_record('badge_backpack', ['userid' => $USER->id,
+            'externalbackpackid' => $client->backpack->id]);
+        if (!$record) {
+            $DB->insert_record('badge_backpack', $badgebackpack);
+        } else {
+            $badgebackpack->id = $record->id;
+            $DB->update_record('badge_backpack', $badgebackpack);
+        }
+
+        redirect($redirecturl, get_string('backpackconnected', 'badges'), null,
+            \core\output\notification::NOTIFY_SUCCESS);
+    }
+
+    /**
+     * Check user has been logged the backpack site.
+     *
+     * @param int $externalbackpackid ID of external backpack.
+     * @param int $userid ID of user.
+     * @return bool
+     */
+    public static function is_logged_oauth2($externalbackpackid, $userid) {
+        global $USER;
+        if (empty($userid)) {
+            $userid = $USER->id;
+        }
+        $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $externalbackpackid, 'userid' => $userid]);
+        if ($persistedtoken) {
+            return true;
+        }
+        return false;
+    }
+}
diff --git a/badges/classes/oauth2/badge_backpack_oauth2.php b/badges/classes/oauth2/badge_backpack_oauth2.php
new file mode 100644 (file)
index 0000000..7e108ed
--- /dev/null
@@ -0,0 +1,77 @@
+<?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 file contains the form add/update oauth2 for backpack is connected.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+use core\persistent;
+
+/**
+ * Class badge_backpack_oauth2 for backpack is connected.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class badge_backpack_oauth2 extends persistent {
+
+    /**
+     * The table name.
+     */
+    const TABLE = 'badge_backpack_oauth2';
+
+    /**
+     * Return the definition of the properties of this model.
+     *
+     * @return array
+     */
+    protected static function define_properties() {
+        return array(
+            'userid' => array(
+                'type' => PARAM_INT,
+            ),
+            'issuerid' => array(
+                'type' => PARAM_INT
+            ),
+            'externalbackpackid' => array(
+                'type' => PARAM_INT
+            ),
+            'token' => array(
+                'type' => PARAM_TEXT
+            ),
+            'refreshtoken' => array(
+                'type' => PARAM_TEXT
+            ),
+            'expires' => array(
+                'type' => PARAM_INT
+            ),
+            'scope' => array(
+                'type' => PARAM_TEXT
+            ),
+        );
+    }
+}
\ No newline at end of file
diff --git a/badges/classes/oauth2/client.php b/badges/classes/oauth2/client.php
new file mode 100644 (file)
index 0000000..c214b5c
--- /dev/null
@@ -0,0 +1,348 @@
+<?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/>.
+
+/**
+ * Configurable OAuth2 client class.
+ *
+ * @package    core_badges
+ * @subpackage badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+
+namespace core_badges\oauth2;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/oauthlib.php');
+require_once($CFG->libdir . '/filelib.php');
+require_once('badge_backpack_oauth2.php');
+
+use moodle_url;
+use moodle_exception;
+use stdClass;
+
+define('BACKPACK_CHALLENGE_METHOD', 'S256');
+define('BACKPACK_CODE_VERIFIER_TIME', 60);
+
+/**
+ * Configurable OAuth2 client to request authorization and store token. Use the PKCE method to verifier authorization.
+ *
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+class client extends \core\oauth2\client {
+
+    /**  @var \core\oauth2\issuer */
+    private $issuer;
+
+    /** @var string $clientid client identifier issued to the client */
+    private $clientid = '';
+
+    /** @var string $clientsecret The client secret. */
+    private $clientsecret = '';
+
+    /** @var moodle_url $returnurl URL to return to after authenticating */
+    private $returnurl = null;
+
+    /** @var string $grantscope */
+    protected $grantscope = '';
+
+    /** @var string $scope */
+    protected $scope = '';
+
+    /** @var bool basicauth */
+    protected $basicauth = true;
+
+    /** @var string|null backpack object */
+    public $backpack = '';
+
+    /**
+     * client constructor.
+     *
+     * @param issuer $issuer oauth2 service.
+     * @param string $returnurl return url after login
+     * @param string $additionalscopes the scopes has been granted
+     * @param null $backpack backpack object.
+     * @throws \coding_exception error message.
+     */
+    public function __construct(\core\oauth2\issuer $issuer, $returnurl = '', $additionalscopes = '',
+                                $backpack = null) {
+        $this->issuer = $issuer;
+        $this->clientid = $issuer->get('clientid');
+        $this->returnurl = $returnurl;
+        $this->clientsecret = $issuer->get('clientsecret');
+        $this->backpack = $backpack;
+        $this->grantscope = $additionalscopes;
+        $this->scope = $additionalscopes;
+        parent::__construct($issuer, $returnurl, $additionalscopes, false);
+    }
+
+    /**
+     * Get login url.
+     *
+     * @return moodle_url
+     * @throws \coding_exception
+     * @throws moodle_exception
+     */
+    public function get_login_url() {
+        $callbackurl = self::callback_url();
+        $scopes = $this->issuer->get('scopessupported');
+
+        // Removed the scopes does not support in authorization.
+        $excludescopes = ['profile', 'openid'];
+        $arrascopes = explode(' ', $scopes);
+        foreach ($excludescopes as $exscope) {
+            $key = array_search($exscope, $arrascopes);
+            if (isset($key)) {
+                unset($arrascopes[$key]);
+            }
+        }
+        $scopes = implode(' ', $arrascopes);
+
+        $params = array_merge(
+            [
+                'client_id' => $this->clientid,
+                'response_type' => 'code',
+                'redirect_uri' => $callbackurl->out(false),
+                'state' => $this->returnurl->out_as_local_url(false),
+                'scope' => $scopes,
+                'code_challenge' => $this->code_challenge(),
+                'code_challenge_method' => BACKPACK_CHALLENGE_METHOD,
+            ]
+        );
+        return new moodle_url($this->auth_url(), $params);
+    }
+
+    /**
+     * Generate code challenge.
+     *
+     * @return string
+     */
+    public function code_challenge() {
+        $random = bin2hex(openssl_random_pseudo_bytes(43));
+        $verifier = $this->base64url_encode(pack('H*', $random));
+        $challenge = $this->base64url_encode(pack('H*', hash('sha256', $verifier)));
+        $_SESSION['SESSION']->code_verifier = $verifier;
+        return $challenge;
+    }
+
+    /**
+     * Get code verifier.
+     *
+     * @return bool
+     */
+    public function code_verifier() {
+        if (isset($_SESSION['SESSION']) && !empty($_SESSION['SESSION']->code_verifier)) {
+            return $_SESSION['SESSION']->code_verifier;
+        }
+        return false;
+    }
+
+    /**
+     * Generate base64url encode.
+     *
+     * @param string $plaintext text to convert.
+     * @return string
+     */
+    public function base64url_encode($plaintext) {
+        $base64 = base64_encode($plaintext);
+        $base64 = trim($base64, "=");
+        $base64url = strtr($base64, '+/', '-_');
+        return ($base64url);
+    }
+
+    /**
+     * Callback url where the request is returned to.
+     *
+     * @return moodle_url url of callback
+     */
+    public static function callback_url() {
+        return new moodle_url('/badges/oauth2callback.php');
+    }
+
+    /**
+     * Check and refresh token to keep login on backpack site.
+     *
+     * @return bool
+     * @throws \coding_exception
+     * @throws moodle_exception
+     */
+    public function is_logged_in() {
+
+        // Has the token expired?
+        if (isset($this->accesstoken->expires) && time() >= $this->accesstoken->expires) {
+            if (isset($this->accesstoken->refreshtoken)) {
+                return $this->upgrade_token($this->accesstoken->refreshtoken, 'refresh_token');
+            } else {
+                throw new moodle_exception('Could not refresh oauth token, please try again.');
+            }
+        }
+
+        if (isset($this->accesstoken->token) && isset($this->accesstoken->scope)) {
+            return true;
+        }
+
+        // If we've been passed then authorization code generated by the
+        // authorization server try and upgrade the token to an access token.
+        $code = optional_param('oauth2code', null, PARAM_RAW);
+        // Note - sometimes we may call is_logged_in twice in the same request - we don't want to attempt
+        // to upgrade the same token twice.
+        if ($code && $this->upgrade_token($code, 'authorization_code')) {
+            return true;
+        }
+
+        return false;
+    }
+
+    /**
+     * Request new token.
+     *
+     * @param string $code code verify from Auth site.
+     * @param string $granttype grant type.
+     * @return bool
+     * @throws moodle_exception
+     */
+    public function upgrade_token($code, $granttype = 'authorization_code') {
+        $callbackurl = self::callback_url();
+
+        if ($granttype == 'authorization_code') {
+            $params = array('code' => $code,
+                'grant_type' => $granttype,
+                'redirect_uri' => $callbackurl->out(false),
+                'scope' => $this->get_scopes(),
+                'code_verifier' => $this->code_verifier()
+            );
+        } else if ($granttype == 'refresh_token') {
+            $this->basicauth = false;
+            $params = array('refresh_token' => $code,
+                'grant_type' => $granttype,
+                'scope' => $this->get_scopes(),
+            );
+        }
+        if ($this->basicauth) {
+            $idsecret = urlencode($this->clientid) . ':' . urlencode($this->clientsecret);
+            $this->setHeader('Authorization: Basic ' . base64_encode($idsecret));
+        } else {
+            $params['client_id'] = $this->clientid;
+            $params['client_secret'] = $this->clientsecret;
+        }
+        // Requests can either use http GET or POST.
+        $response = $this->post($this->token_url(), $this->build_post_data($params));
+        $r = json_decode($response);
+        if ($this->info['http_code'] !== 200) {
+            throw new moodle_exception('Could not upgrade oauth token');
+        }
+
+        if (is_null($r)) {
+            throw new moodle_exception("Could not decode JSON token response");
+        }
+
+        if (!empty($r->error)) {
+            throw new moodle_exception($r->error . ' ' . $r->error_description);
+        }
+
+        if (!isset($r->access_token)) {
+            return false;
+        }
+
+        // Store the token an expiry time.
+        $accesstoken = new stdClass;
+        $accesstoken->token = $r->access_token;
+        if (isset($r->expires_in)) {
+            // Expires 10 seconds before actual expiry.
+            $accesstoken->expires = (time() + ($r->expires_in - 10));
+        }
+        if (isset($r->refresh_token)) {
+            $this->refreshtoken = $r->refresh_token;
+            $accesstoken->refreshtoken = $r->refresh_token;
+        }
+        $accesstoken->scope = $r->scope;
+
+        // Also add the scopes.
+        $this->store_token($accesstoken);
+
+        return true;
+    }
+
+    /**
+     * Store a token to verify for send request.
+     *
+     * @param null|stdClass $token
+     */
+    protected function store_token($token) {
+        global $USER;
+
+        $this->accesstoken = $token;
+        // Create or update a DB record with the new token.
+        $persistedtoken = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]);
+        if ($token !== null) {
+            if (!$persistedtoken) {
+                $persistedtoken = new badge_backpack_oauth2();
+                $persistedtoken->set('issuerid', $this->backpack->oauth2_issuerid);
+                $persistedtoken->set('externalbackpackid', $this->backpack->id);
+                $persistedtoken->set('userid', $USER->id);
+            } else {
+                $persistedtoken->set('timemodified', time());
+            }
+            // Update values from $token. Don't use from_record because that would skip validation.
+            $persistedtoken->set('usermodified', $USER->id);
+            $persistedtoken->set('token', $token->token);
+            $persistedtoken->set('refreshtoken', $token->refreshtoken);
+            $persistedtoken->set('expires', $token->expires);
+            $persistedtoken->set('scope', $token->scope);
+            $persistedtoken->save();
+        } else {
+            if ($persistedtoken) {
+                $persistedtoken->delete();
+            }
+        }
+    }
+
+    /**
+     * Get token of current user.
+     *
+     * @return stdClass|null token object
+     */
+    protected function get_stored_token() {
+        global $USER;
+
+        $token = badge_backpack_oauth2::get_record(['externalbackpackid' => $this->backpack->id, 'userid' => $USER->id]);
+        if ($token !== false) {
+            $token = $token->to_record();
+            return $token;
+        }
+        return null;
+    }
+
+    /**
+     * Get scopes granted.
+     *
+     * @return null|string
+     */
+    protected function get_scopes() {
+        if (!empty($this->grantscope)) {
+            return $this->grantscope;
+        }
+        $token = $this->get_stored_token();
+        if ($token) {
+            return $token->scope;
+        }
+        return null;
+    }
+}
index aa9592f..839a731 100644 (file)
@@ -63,7 +63,7 @@ class external_backpacks_page implements \renderable {
         foreach ($this->backpacks as $backpack) {
             $exporter = new backpack_exporter($backpack);
             $backpack = $exporter->export($output);
-            if ($backpack->apiversion == OPEN_BADGES_V2) {
+            if ($backpack->apiversion == OPEN_BADGES_V2 || $backpack->apiversion == OPEN_BADGES_V2P1) {
                 $backpack->canedit = true;
             } else {
                 $backpack->canedit = false;
index cd0c010..06dfd7a 100644 (file)
@@ -105,6 +105,14 @@ class provider implements
             'issuer' => 'privacy:metadata:external:backpacks:issuer',
         ], 'privacy:metadata:external:backpacks');
 
+        $collection->add_database_table('badge_backpack_oauth2', [
+            'userid' => 'privacy:metadata:backpackoauth2:userid',
+            'usermodified' => 'privacy:metadata:backpackoauth2:usermodified',
+            'token' => 'privacy:metadata:backpackoauth2:token',
+            'issuerid' => 'privacy:metadata:backpackoauth2:issuerid',
+            'scope' => 'privacy:metadata:backpackoauth2:scope',
+        ], 'privacy:metadata:backpackoauth2');
+
         return $collection;
     }
 
index bcb9482..61dd374 100644 (file)
@@ -56,10 +56,17 @@ $badgescache = cache::make('core', 'externalbadges');
 if ($disconnect && $backpack) {
     require_sesskey();
     $sitebackpack = badges_get_site_backpack($backpack->externalbackpackid);
-    // If backpack is connected, need to select collections.
-    $bp = new \core_badges\backpack_api($sitebackpack, $backpack);
-    $bp->disconnect_backpack($USER->id, $backpack->id);
-    redirect(new moodle_url('/badges/mybackpack.php'));
+    if ($sitebackpack->apiversion == OPEN_BADGES_V2P1) {
+        $bp = new \core_badges\backpack_api2p1($sitebackpack);
+        $bp->disconnect_backpack($backpack);
+        redirect(new moodle_url('/badges/mybackpack.php'), get_string('backpackdisconnected', 'badges'), null,
+            \core\output\notification::NOTIFY_SUCCESS);
+    } else {
+        // If backpack is connected, need to select collections.
+        $bp = new \core_badges\backpack_api($sitebackpack, $backpack);
+        $bp->disconnect_backpack($USER->id, $backpack->id);
+        redirect(new moodle_url('/badges/mybackpack.php'));
+    }
 }
 $warning = '';
 if ($backpack) {
@@ -100,6 +107,16 @@ if ($backpack) {
         $bp->set_backpack_collections($backpack->id, $groups);
         redirect(new moodle_url('/badges/mybadges.php'));
     }
+} else if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+    // If backpack is version 2.1 to redirect on the backpack site to login.
+    // User input username/email/password on the backpack site
+    // After confirm the scopes.
+    $form = new \core_badges\form\backpack(new moodle_url('/badges/mybackpack.php'));
+    if ($form->is_cancelled()) {
+        redirect(new moodle_url('/badges/mybadges.php'));
+    } else if ($data = $form->get_submitted_data()) {
+        redirect(new moodle_url('/badges/backpack-connect.php'));
+    }
 } else {
     // If backpack is not connected, need to connect first.
     // To create a new connection to the backpack, first we need to verify the user's email address:
diff --git a/badges/oauth2callback.php b/badges/oauth2callback.php
new file mode 100644 (file)
index 0000000..b3b8153
--- /dev/null
@@ -0,0 +1,57 @@
+<?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/>.
+
+/**
+ * Verify authorization callback.
+ *
+ * @package    core_badges
+ * @copyright  2020 Tung Thai
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ * @author     Tung Thai <Tung.ThaiDuc@nashtechglobal.com>
+ */
+require_once(__DIR__ . '/../config.php');
+
+$error = optional_param('error', '', PARAM_RAW);
+
+if ($error) {
+    $message = optional_param('error_description', '', PARAM_RAW);
+    if ($message) {
+        print_error($message);
+    } else {
+        print_error($error);
+    }
+    die();
+}
+
+require_login();
+
+// The authorization code generated by the authorization server.
+$code = required_param('code', PARAM_RAW);
+$scope = required_param('scope', PARAM_RAW);
+
+// The state parameter we've given (used in moodle as a redirect url).
+$state = required_param('state', PARAM_LOCALURL);
+
+$redirecturl = new moodle_url($state);
+$params = $redirecturl->params();
+
+if (isset($params['sesskey']) and confirm_sesskey($params['sesskey'])) {
+    $redirecturl->param('oauth2code', $code);
+    $redirecturl->param('scope', $scope);
+    redirect($redirecturl);
+} else {
+    print_error('invalidsesskey');
+}
index d3c2a6b..2f7bad8 100644 (file)
@@ -87,6 +87,8 @@ class core_badges_renderer extends plugin_renderer_base {
                     if (badges_open_badges_backpack_api() == OPEN_BADGES_V1) {
                         $action = new component_action('click', 'addtobackpack', array('assertion' => $assertion->out(false)));
                         $addurl = new moodle_url('#');
+                    } else if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+                        $addurl = new moodle_url('/badges/backpack-export.php', array('hash' => $badge->uniquehash));
                     } else {
                         $addurl = new moodle_url('/badges/backpack-add.php', array('hash' => $badge->uniquehash));
                     }
@@ -354,7 +356,11 @@ class core_badges_renderer extends plugin_renderer_base {
                     $this->output->add_action_handler($action, 'addbutton');
                     $output .= $tobackpack;
                 } else {
-                    $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash));
+                    if (badges_open_badges_backpack_api() == OPEN_BADGES_V2P1) {
+                        $assertion = new moodle_url('/badges/backpack-export.php', array('hash' => $ibadge->hash));
+                    } else {
+                        $assertion = new moodle_url('/badges/backpack-add.php', array('hash' => $ibadge->hash));
+                    }
                     $attributes = ['class' => 'btn btn-secondary m-1', 'role' => 'button'];
                     $tobackpack = html_writer::link($assertion, get_string('addtobackpack', 'badges'), $attributes);
                     $output .= $tobackpack;
diff --git a/badges/tests/behat/backpack.feature b/badges/tests/behat/backpack.feature
new file mode 100644 (file)
index 0000000..7c66ac7
--- /dev/null
@@ -0,0 +1,94 @@
+@core @core_badges @_file_upload
+Feature: Backpack badges
+  The settings to connect to backpack with OAuth2 service
+  As an learner
+  I need to verify display backpack in the my profile
+
+  Background:
+    Given the following "badge external backpack" exist:
+      | backpackapiurl                               | backpackweburl           | apiversion |
+      | https://dc.imsglobal.org/obchost/ims/ob/v2p1 | https://dc.imsglobal.org | 2.1          |
+    And the following "users" exist:
+      | username | firstname | lastname | email                |
+      | student1 | Student   | 1        | student1@example.com |
+
+  @javascript
+  Scenario: Verify backback settings
+    Given I am on homepage
+    And I log in as "admin"
+    And I navigate to "Badges > Backpack settings" in site administration
+    And I set the following fields to these values:
+      | External backpack connection | 1                        |
+      | Active external backpack     | https://dc.imsglobal.org |
+    And I press "Save changes"
+    And I navigate to "Badges > Add a new badge" in site administration
+    And I set the following fields to these values:
+      | Name          | Test badge verify backpack |
+      | Version       | v1                         |
+      | Language      | English                    |
+      | Description   | Test badge description     |
+      | Image author  | http://author.example.com  |
+      | Image caption | Test caption image         |
+      | issuername    | Test Badge Site            |
+      | issuercontact | testuser@example.com       |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    And I set the field "type" to "Manual issue by role"
+    And I set the field "Manager" to "1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    And I follow "Recipients (0)"
+    And I press "Award badge"
+    And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)"
+    And I press "Award badge"
+    And I log out
+    When I am on homepage
+    And I log in as "student1"
+    And I follow "Preferences" in the user menu
+    And I follow "Backpack settings"
+    Then I should see "https://dc.imsglobal.org"
+    And I should see "Not connected"
+
+  @javascript
+  Scenario: User has been connected backpack
+    Given I am on homepage
+    And I log in as "admin"
+    And I navigate to "Badges > Backpack settings" in site administration
+    And I set the following fields to these values:
+      | External backpack connection | 1                        |
+      | Active external backpack     | https://dc.imsglobal.org |
+    And I press "Save changes"
+    And I navigate to "Badges > Add a new badge" in site administration
+    And I set the following fields to these values:
+      | Name           | Test badge verify backpack |
+      | Version        | v1                         |
+      | Language       | English                    |
+      | Description    | Test badge description     |
+      | Image author   | http://author.example.com  |
+      | Image caption  | Test caption image         |
+      | issuername     | Test Badge Site            |
+      | issuercontact  | testuser@example.com       |
+    And I upload "badges/tests/behat/badge.png" file to "Image" filemanager
+    And I press "Create badge"
+    And I set the field "type" to "Manual issue by role"
+    And I set the field "Manager" to "1"
+    And I press "Save"
+    And I press "Enable access"
+    And I press "Continue"
+    And I follow "Recipients (0)"
+    And I press "Award badge"
+    And I set the field "potentialrecipients[]" to "Student 1 (student1@example.com)"
+    And I press "Award badge"
+    And I log out
+    And the following "setup backpack connected" exist:
+      | user     | externalbackpack         |
+      | student1 | https://dc.imsglobal.org |
+    When I log in as "student1"
+    And I follow "Preferences" in the user menu
+    And I follow "Backpack settings"
+    Then I should see "Connected"
+    And I follow "Preferences" in the user menu
+    And I follow "Manage badges"
+    And I should see "Test badge verify backpack"
+    And "Add to backpack" "link" should exist
index dc98508..c483c28 100644 (file)
                title={{#quote}}{{{name}}}{{/quote}}
                aria-label='{{#str}} ariaeventlistitem, block_timeline, { "name": {{#quote}}{{{name}}}{{/quote}}, "course": {{#quote}}{{{course.fullnamedisplay}}}{{/quote}}, "date": "{{#userdate}} {{timesort}}, {{#str}} strftimedatetime, core_langconfig {{/str}} {{/userdate}}" } {{/str}}'
             ><h6 class="event-name text-truncate mb-0">{{#quote}}{{{name}}}{{/quote}}</h6></a>
+            {{#course.fullnamedisplay}}
             <small class="text-muted text-truncate mb-0">{{#quote}}{{{course.fullnamedisplay}}}{{/quote}}</small>
+            {{/course.fullnamedisplay}}
             {{#action.actionable}}
             <h6 class="mb-0 pt-2">
-                <a href="{{{action.url}}}" aria-label="{{{action.name}}}" title="{{{action.name}}}" class="list-group-item-action">{{{action.name}}}</a>
+                <a href="{{action.url}}" aria-label="{{action.name}}" title="{{action.name}}" class="list-group-item-action">{{{action.name}}}</a>
                 {{#action.showitemcount}}
                 <span class="badge badge-secondary">{{action.itemcount}}</span>
                 {{/action.showitemcount}}
index 33e86ea..bcb161f 100644 (file)
Binary files a/calendar/amd/build/selectors.min.js and b/calendar/amd/build/selectors.min.js differ
index b37731a..cd5c94c 100644 (file)
Binary files a/calendar/amd/build/selectors.min.js.map and b/calendar/amd/build/selectors.min.js.map differ
index fa3574a..0e7cb63 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js and b/calendar/amd/build/view_manager.min.js differ
index 5d5a0c2..a0e67dc 100644 (file)
Binary files a/calendar/amd/build/view_manager.min.js.map and b/calendar/amd/build/view_manager.min.js.map differ
index 80ba378..6ecbea3 100644 (file)
@@ -30,6 +30,7 @@ define([], function() {
             course: "[data-eventtype-course]",
             group: "[data-eventtype-group]",
             user: "[data-eventtype-user]",
+            other: "[data-eventtype-other]",
         },
         popoverType: {
             site: "[data-popover-eventtype-site]",
@@ -37,6 +38,7 @@ define([], function() {
             course: "[data-popover-eventtype-course]",
             group: "[data-popover-eventtype-group]",
             user: "[data-popover-eventtype-user]",
+            other: "[data-popover-eventtype-other]",
         },
         calendarPeriods: {
             month: "[data-period='month']",
index d89d148..e5edf31 100644 (file)
@@ -403,7 +403,8 @@ const renderEventSummaryModal = (eventId) => {
                 candelete: eventData.candelete,
                 headerclasses: getEventTypeClassFromType(eventData.normalisedeventtype),
                 isactionevent: eventData.isactionevent,
-                url: eventData.url
+                url: eventData.url,
+                action: eventData.action
             }
         };
 
index ea32de3..7d548f9 100644 (file)
@@ -109,7 +109,6 @@ class calendar_event_exporter extends event_exporter_base {
         } else if ($event->get_type() == 'category') {
             $url = $event->get_category()->get_proxied_instance()->get_view_link();
         } else {
-            // TODO MDL-58866 We do not have any way to find urls for events outside of course modules.
             $url = course_get_url($hascourse ? $course : SITEID);
         }
 
index 097adbc..7711071 100644 (file)
@@ -49,7 +49,7 @@ class event_action_exporter extends exporter {
     public function __construct(action_interface $action, $related = []) {
         $data = new \stdClass();
         $data->name = $action->get_name();
-        $data->url = $action->get_url()->out(true);
+        $data->url = $action->get_url()->out(false);
         $data->itemcount = $action->get_item_count();
         $data->actionable = $action->is_actionable();
 
@@ -90,17 +90,14 @@ class event_action_exporter extends exporter {
     protected function get_other_values(renderer_base $output) {
         $event = $this->related['event'];
 
-        if (!$event->get_course_module()) {
-            // TODO MDL-58866 Only activity modules currently support this callback.
+        if (!$event->get_component()) {
             return ['showitemcount' => false];
         }
-        $modulename = $event->get_course_module()->get('modname');
-        $component = 'mod_' . $modulename;
         $showitemcountcallback = 'core_calendar_event_action_shows_item_count';
         $mapper = container::get_event_mapper();
         $calevent = $mapper->from_event_to_legacy_event($event);
         $params = [$calevent, $this->data->itemcount];
-        $showitemcount = component_callback($component, $showitemcountcallback, $params, false);
+        $showitemcount = component_callback($event->get_component(), $showitemcountcallback, $params, false);
 
         // Prepare other values data.
         $data = [
@@ -120,4 +117,13 @@ class event_action_exporter extends exporter {
             'event' => '\\core_calendar\\local\\event\\entities\\event_interface'
         ];
     }
+
+    /**
+     * Magic method returning parameters for formatting 'name' property
+     *
+     * @return bool[]
+     */
+    protected function get_format_parameters_for_name() {
+        return ['escape' => false];
+    }
 }
index 015d71a..1bbe0b7 100644 (file)
@@ -51,10 +51,6 @@ class event_exporter extends event_exporter_base {
         $values = parent::define_other_properties();
 
         $values['url'] = ['type' => PARAM_URL];
-        $values['action'] = [
-            'type' => event_action_exporter::read_properties_definition(),
-            'optional' => true,
-        ];
         return $values;
     }
 
@@ -86,7 +82,6 @@ class event_exporter extends event_exporter_base {
         } else if ($event->get_type() == 'course') {
             $url = \course_get_url($this->related['course'] ?: SITEID);
         } else {
-            // TODO MDL-58866 We do not have any way to find urls for events outside of course modules.
             $url = \course_get_url($this->related['course'] ?: SITEID);
         }
         $values['url'] = $url->out(false);
@@ -95,17 +90,6 @@ class event_exporter extends event_exporter_base {
         $legacyevent = container::get_event_mapper()->from_event_to_legacy_event($event);
         $values['formattedtime'] = calendar_format_event_time($legacyevent, time(), null, false);
 
-        if ($event instanceof action_event_interface) {
-            $actionrelated = [
-                'context' => $context,
-                'event' => $event
-            ];
-            $actionexporter = new event_action_exporter($event->get_action(), $actionrelated);
-            $values['action'] = $actionexporter->export($output);
-        }
-
-
-
         return $values;
     }
 }
index f761f70..5342220 100644 (file)
@@ -89,6 +89,7 @@ class event_exporter_base extends exporter {
         $data->timesort = $event->get_times()->get_sort_time()->getTimestamp();
         $data->visible = $event->is_visible() ? 1 : 0;
         $data->timemodified = $event->get_times()->get_modified_time()->getTimestamp();
+        $data->component = $event->get_component();
 
         if ($repeats = $event->get_repeats()) {
             $data->repeatid = $repeats->get_id();
@@ -160,6 +161,12 @@ class event_exporter_base extends exporter {
                 'default' => null,
                 'null' => NULL_ALLOWED
             ],
+            'component' => [
+                'type' => PARAM_COMPONENT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
             'modulename' => [
                 'type' => PARAM_TEXT,
                 'optional' => true,
@@ -242,6 +249,10 @@ class event_exporter_base extends exporter {
             'normalisedeventtypetext' => [
                 'type' => PARAM_TEXT
             ],
+            'action' => [
+                'type' => event_action_exporter::read_properties_definition(),
+                'optional' => true,
+            ],
         ];
     }
 
@@ -277,6 +288,10 @@ class event_exporter_base extends exporter {
         $iconexporter = new event_icon_exporter($event, ['context' => $context]);
         $identifier = 'type' . $values['normalisedeventtype'];
         $stringexists = get_string_manager()->string_exists($identifier, 'calendar');
+        if (!$stringexists) {
+            // Property normalisedeventtype is used to build the name of the CSS class for the events.
+            $values['normalisedeventtype'] = 'other';
+        }
         $values['normalisedeventtypetext'] = $stringexists ? get_string($identifier, 'calendar') : '';
 
         $values['icon'] = $iconexporter->export($output);
@@ -291,7 +306,7 @@ class event_exporter_base extends exporter {
             $values['category'] = $categorysummaryexporter->export($output);
         }
 
-        if ($course) {
+        if ($course && $course->id != SITEID) {
             $coursesummaryexporter = new course_summary_exporter($course, ['context' => $context]);
             $values['course'] = $coursesummaryexporter->export($output);
         }
@@ -319,6 +334,16 @@ class event_exporter_base extends exporter {
                 ['context' => \context_course::instance($event->get_course()->get('id'))]);
         }
 
+        if ($event instanceof action_event_interface) {
+            // Export event action if applicable.
+            $actionrelated = [
+                'context' => $this->related['context'],
+                'event' => $event
+            ];
+            $actionexporter = new event_action_exporter($event->get_action(), $actionrelated);
+            $values['action'] = $actionexporter->export($output);
+        }
+
         return $values;
     }
 
index 1949a9e..1f866d8 100644 (file)
@@ -45,6 +45,7 @@ class event_icon_exporter extends exporter {
      * @param array $related The related data.
      */
     public function __construct(event_interface $event, $related = []) {
+        global $PAGE;
         $coursemodule = $event->get_course_module();
         $category = $event->get_category();
         $categoryid = $category ? $category->get('id') : null;
@@ -70,6 +71,22 @@ class event_icon_exporter extends exporter {
             } else {
                 $alttext = get_string('activityevent', 'calendar');
             }
+        } else if ($event->get_component()) {
+            // Guess the icon and the title for the component event. By default display calendar icon and the
+            // plugin name as the alttext.
+            if ($PAGE->theme->resolve_image_location($event->get_type(), $event->get_component())) {
+                $key = $event->get_type();
+                $component = $event->get_component();
+            } else {
+                $key = 'i/otherevent';
+                $component = 'core';
+            }
+
+            if (get_string_manager()->string_exists($event->get_type(), $event->get_component())) {
+                $alttext = get_string($event->get_type(), $event->get_component());
+            } else {
+                $alttext = get_string('pluginname', $event->get_component());
+            }
         } else if ($issiteevent) {
             $key = 'i/siteevent';
             $component = 'core';
index 4b7a850..a820cc9 100644 (file)
@@ -133,7 +133,8 @@ class container {
                         }
                     }
 
-                    // At present we only have a bail-out check for events in course modules.
+                    // For non-module events we assume that all checks were done in core_calendar_is_event_visible callback.
+                    // For module events we also check that the course module and course itself are visible to the user.
                     if (empty($dbrow->modulename)) {
                         return false;
                     }
@@ -279,7 +280,7 @@ class container {
         // of the event class.
         $mapper = self::$eventmapper;
         $action = null;
-        if ($event->get_course_module()) {
+        if ($event->get_component()) {
             $requestinguserid = self::get_requesting_user();
             $legacyevent = $mapper->from_event_to_legacy_event($event);
             // We know for a fact that the the requesting user might be different from the logged in user,
@@ -288,10 +289,9 @@ class container {
                 $legacyevent->userid = $requestinguserid;
             }
 
-            // TODO MDL-58866 Only activity modules currently support this callback.
             // Any other event will not be displayed on the dashboard.
             $action = component_callback(
-                'mod_' . $event->get_course_module()->get('modname'),
+                $event->get_component(),
                 'core_calendar_provide_event_action',
                 [
                     $legacyevent,
@@ -322,7 +322,7 @@ class container {
     public static function apply_component_is_event_visible(event_interface $event) {
         $mapper = self::$eventmapper;
         $eventvisible = null;
-        if ($event->get_course_module()) {
+        if ($event->get_component()) {
             $requestinguserid = self::get_requesting_user();
             $legacyevent = $mapper->from_event_to_legacy_event($event);
             // We know for a fact that the the requesting user might be different from the logged in user,
@@ -331,9 +331,8 @@ class container {
                 $legacyevent->userid = $requestinguserid;
             }
 
-            // TODO MDL-58866 Only activity modules currently support this callback.
             $eventvisible = component_callback(
-                'mod_' . $event->get_course_module()->get('modname'),
+                $event->get_component(),
                 'core_calendar_is_event_visible',
                 [
                     $legacyevent,
index 8a8c20d..7bda7bf 100644 (file)
@@ -211,6 +211,9 @@ class event_vault implements event_vault_interface {
             return array_merge($carry, $groupings[0]);
         }, []);
 
+        // Always include the site events.
+        $courseids = $courseids ? array_merge($courseids, [SITEID]) : $courseids;
+
         return $this->get_events(
             null,
             null,
index 6c8b0f1..57b7631 100644 (file)
@@ -125,4 +125,12 @@ class action_event implements action_event_interface {
     public function get_action() {
         return $this->action;
     }
+
+    /**
+     * Event component
+     * @return string
+     */
+    public function get_component() {
+        return $this->event->get_component();
+    }
 }
index fdfee97..b3a7c74 100644 (file)
@@ -102,6 +102,11 @@ class event implements event_interface {
      */
     protected $visible;
 
+    /**
+     * @var string $component
+     */
+    protected $component;
+
     /**
      * @var proxy_interface $subscription Subscription for this event.
      */
@@ -124,6 +129,7 @@ class event implements event_interface {
      * @param bool                       $visible        The event's visibility. True for visible, false for invisible.
      * @param proxy_interface            $subscription   The event's subscription.
      * @param string                     $location       The event's location.
+     * @param string                     $component      The event's component.
      */
     public function __construct(
         $id,
@@ -139,7 +145,8 @@ class event implements event_interface {
         times_interface $times,
         $visible,
         proxy_interface $subscription = null,
-        $location = null
+        $location = null,
+        $component = null
     ) {
         $this->id = $id;
         $this->name = $name;
@@ -155,6 +162,7 @@ class event implements event_interface {
         $this->times = $times;
         $this->visible = $visible;
         $this->subscription = $subscription;
+        $this->component = $component;
     }
 
     public function get_id() {
@@ -212,4 +220,12 @@ class event implements event_interface {
     public function is_visible() {
         return $this->visible;
     }
+
+    /**
+     * Resolved event component (frankenstyle name of activity module or the component)
+     * @return string|null
+     */
+    public function get_component() {
+        return $this->get_course_module() ? 'mod_' . $this->get_course_module()->get('modname') : $this->component;
+    }
 }
index 5561f62..29e9d1c 100644 (file)
@@ -133,4 +133,10 @@ interface event_interface {
      * @return bool true if the event is visible, false otherwise
      */
     public function is_visible();
+
+    /**
+     * Resolved event component (frankenstyle name of activity module or the component)
+     * @return string|null
+     */
+    public function get_component();
 }
index b810ff8..1fa43c2 100644 (file)
@@ -133,6 +133,7 @@ abstract class event_abstract_factory implements event_factory_interface {
         $user = null;
         $module = null;
         $subscription = null;
+        $component = null;
 
         if ($dbrow->modulename && $dbrow->instance) {
             $module = new cm_info_proxy($dbrow->modulename, $dbrow->instance, $dbrow->courseid);
@@ -171,6 +172,10 @@ abstract class event_abstract_factory implements event_factory_interface {
             $repeatcollection = null;
         }
 
+        if (!empty($dbrow->component)) {
+            $component = $dbrow->component;
+        }
+
         $event = new event(
             $dbrow->id,
             $dbrow->name,
@@ -190,7 +195,8 @@ abstract class event_abstract_factory implements event_factory_interface {
             ),
             !empty($dbrow->visible),
             $subscription,
-            $dbrow->location
+            $dbrow->location,
+            $component
         );
 
         $isactionevent = !empty($dbrow->type) && $dbrow->type == CALENDAR_EVENT_TYPE_ACTION;
index dc2ef2c..a6746ea 100644 (file)
@@ -75,6 +75,7 @@ class event_mapper implements event_mapper_interface {
                 'groupid' => $coalesce('groupid'),
                 'userid' => $coalesce('userid'),
                 'repeatid' => $coalesce('repeatid'),
+                'component' => $coalesce('component'),
                 'modulename' => $coalesce('modulename'),
                 'instance' => $coalesce('instance'),
                 'eventtype' => $coalesce('eventtype'),
@@ -98,6 +99,7 @@ class event_mapper implements event_mapper_interface {
         $properties->categoryid = empty($properties->categoryid) ? 0 : $properties->categoryid;
         $properties->groupid = empty($properties->groupid) ? 0 : $properties->groupid;
         $properties->userid = empty($properties->userid) ? 0 : $properties->userid;
+        $properties->component = empty($properties->component) ? 0 : $properties->component;
         $properties->modulename = empty($properties->modulename) ? 0 : $properties->modulename;
         $properties->instance = empty($properties->instance) ? 0 : $properties->instance;
         $properties->repeatid = empty($properties->repeatid) ? 0 : $properties->repeatid;
@@ -127,6 +129,7 @@ class event_mapper implements event_mapper_interface {
             'groupid'          => $event->get_group() ? $event->get_group()->get('id') : null,
             'userid'           => $event->get_user() ? $event->get_user()->get('id') : null,
             'repeatid'         => $event->get_repeats() ? $event->get_repeats()->get_id() : null,
+            'component'        => $event->get_component(),
             'modulename'       => $event->get_course_module() ? $event->get_course_module()->get('modname') : null,
             'instance'         => $event->get_course_module() ? $event->get_course_module()->get('instance') : null,
             'eventtype'        => $event->get_type(),
index 4d54445..3f186f1 100644 (file)
@@ -797,20 +797,21 @@ class core_calendar_external extends external_api {
         self::validate_context($context);
         $warnings = array();
 
-        $legacyevent = calendar_event::load($eventid);
-        // Must check we can see this event.
-        if (!calendar_view_event_allowed($legacyevent)) {
+        $eventvault = event_container::get_event_vault();
+        if ($event = $eventvault->get_event_by_id($eventid)) {
+            $mapper = event_container::get_event_mapper();
+            if (!calendar_view_event_allowed($mapper->from_event_to_legacy_event($event))) {
+                $event = null;
+            }
+        }
+
+        if (!$event) {
             // We can't return a warning in this case because the event is not optional.
             // We don't know the context for the event and it's not worth loading it.
             $syscontext = context_system::instance();
             throw new \required_capability_exception($syscontext, 'moodle/course:view', 'nopermission', '');
         }
 
-        $legacyevent->count_repeats();
-
-        $eventmapper = event_container::get_event_mapper();
-        $event = $eventmapper->from_legacy_event_to_event($legacyevent);
-
         $cache = new events_related_objects_cache([$event]);
         $relatedobjects = [
             'context' => $cache->get_context($event),
index f378c10..c194636 100644 (file)
@@ -172,6 +172,7 @@ define('CALENDAR_EVENT_TYPE_ACTION', 1);
  * @property int $userid The user the event is associated with (0 if none)
  * @property int $repeatid If this is a repeated event this will be set to the
  *                          id of the original
+ * @property string $component If created by a plugin/component (other than module), the full frankenstyle name of a component
  * @property string $modulename If added by a module this will be the module name
  * @property int $instance If added by a module this will be the module instance
  * @property string $eventtype The event type
@@ -257,6 +258,10 @@ class calendar_event {
             $data->format = editors_get_preferred_format();
         }
 
+        if (empty($data->component)) {
+            $data->component = null;
+        }
+
         $this->properties = $data;
     }
 
@@ -337,6 +342,7 @@ class calendar_event {
             $context = \context_user::instance($this->properties->userid);
         } else if (isset($this->properties->userid) && $this->properties->userid > 0
             && $this->properties->userid != $USER->id &&
+            !empty($this->properties->modulename) &&
             isset($this->properties->instance) && $this->properties->instance > 0) {
             $cm = get_coursemodule_from_instance($this->properties->modulename, $this->properties->instance, 0,
                 false, MUST_EXIST);
@@ -1523,10 +1529,13 @@ function calendar_get_group_cached($groupid) {
 /**
  * Add calendar event metadata
  *
+ * @deprecated since 3.9
+ *
  * @param stdClass $event event info
  * @return stdClass $event metadata
  */
 function calendar_add_event_metadata($event) {
+    debugging('This function is no longer used', DEBUG_DEVELOPER);
     global $CFG, $OUTPUT;
 
     // Support multilang in event->name.
@@ -2276,6 +2285,11 @@ function calendar_edit_event_allowed($event, $manualedit = false) {
         return has_capability('moodle/course:manageactivities', $context);
     }
 
+    if ($manualedit && !empty($event->component)) {
+        // TODO possibly we can later add a callback similar to core_calendar_event_timestart_updated in the modules.
+        return false;
+    }
+
     // You cannot edit URL based calendar subscription events presently.
     if (!empty($event->subscriptionid)) {
         if (!empty($event->subscription->url)) {
@@ -2324,8 +2338,8 @@ function calendar_edit_event_allowed($event, $manualedit = false) {
  * @return bool Whether the user has permission to delete the event or not.
  */
 function calendar_delete_event_allowed($event) {
-    // Only allow delete if you have capabilities and it is not an module event.
-    return (calendar_edit_event_allowed($event) && empty($event->modulename));
+    // Only allow delete if you have capabilities and it is not an module or component event.
+    return (calendar_edit_event_allowed($event) && empty($event->modulename) && empty($event->component));
 }
 
 /**
@@ -3673,6 +3687,7 @@ function calendar_get_filter_types() {
         'course',
         'group',
         'user',
+        'other'
     ];
 
     return array_map(function($type) {
index 0679979..dcb3afc 100644 (file)
@@ -135,12 +135,15 @@ class core_calendar_renderer extends plugin_renderer_base {
     /**
      * Displays an event
      *
+     * @deprecated since 3.9
+     *
      * @param calendar_event $event
      * @param bool $showactions
      * @return string
      */
     public function event(calendar_event $event, $showactions=true) {
         global $CFG;
+        debugging('This function is no longer used', DEBUG_DEVELOPER);
 
         $event = calendar_add_event_metadata($event);
         $context = $event->context;
index 9f92043..0d9bf1e 100644 (file)
@@ -34,6 +34,7 @@
     {
         "formattedtime": "Wednesday, 17 April, 9:27 AM",
         "normalisedeventtype": "Group",
+        "normalisedeventtypetext": "Group event",
         "description": "An random event description",
         "location": "Moodle HQ",
         "isactionevent": "true",
         <div class="location-content col-11">{{{.}}}</div>
     </div>
 {{/location}}
-{{#isactionevent}}
-    <div class="row mt-1">
-        <div class="col-1">{{#pix}} i/courseevent, core, {{#str}} course {{/str}} {{/pix}}</div>
-        <div class="col-11"><a href="{{course.viewurl}}">{{{course.fullname}}}</a></div>
-    </div>
-{{/isactionevent}}
 {{#iscategoryevent}}
     <div class="row mt-1">
         <div class="col-1">{{#pix}} i/categoryevent, core, {{#str}} category {{/str}} {{/pix}}</div>
         <div class="col-11">{{{category.nestedname}}}</div>
     </div>
 {{/iscategoryevent}}
-{{#iscourseevent}}
+{{#course.viewurl}}
     <div class="row mt-1">
         <div class="col-1">{{#pix}} i/courseevent, core, {{#str}} course {{/str}} {{/pix}}</div>
-        <div class="col-11"><a href="{{url}}">{{{course.fullname}}}</a></div>
+        <div class="col-11"><a href="{{course.viewurl}}">{{{course.fullname}}}</a></div>
     </div>
-{{/iscourseevent}}
+{{/course.viewurl}}
 {{#groupname}}
-    <div class="row mt-1">
-        <div class="col-1">{{#pix}} i/courseevent, core, {{#str}} course {{/str}} {{/pix}}</div>
-        <div class="col-11"><a href="{{url}}">{{{course.fullname}}}</a></div>
-    </div>
     <div class="row mt-1">
         <div class="col-1">{{#pix}} i/groupevent, core, {{#str}} group {{/str}} {{/pix}}</div>
         <div class="col-11">{{{groupname}}}</div>
index ccbf773..6f30790 100644 (file)
@@ -52,6 +52,8 @@
     }} data-course-id="{{course.id}}"{{!
     }} data-event-id="{{id}}"{{!
     }} class="event m-t-1"{{!
+    }} data-event-component="{{component}}"{{!
+    }} data-event-eventtype="{{eventtype}}"{{!
     }} data-eventtype-{{normalisedeventtype}}="1"{{!
     }} data-event-title="{{name}}"{{!
     }} data-event-count="{{eventcount}}"{{!
         <div class="description card-body">
             {{> core_calendar/event_details }}
         </div>
-        {{#isactionevent}}
+        {{#action.actionable}}
+            <div class="card-footer text-right bg-transparent">
+                <a href="{{action.url}}" class="card-link">{{{action.name}}}</a>
+            </div>
+        {{/action.actionable}}
+        {{^action.actionable}}
+            {{#isactionevent}}
             <div class="card-footer text-right bg-transparent">
                 <a href="{{url}}" class="card-link">{{#str}} gotoactivity, core_calendar {{/str}}</a>
             </div>
-        {{/isactionevent}}
+            {{/isactionevent}}
+        {{/action.actionable}}
     </div>
 </div>
index a6e7c2d..fc7edd7 100644 (file)
@@ -21,7 +21,7 @@
 
     Example context (json):
     {
-        "title": "Assignment due 1",
+        "title": "Assignment due 1"
     }
 }}
 {{< core/modal }}
         {{#candelete}}
             <button type="button" class="btn btn-secondary" data-action="delete">{{#str}} delete {{/str}}</button>
         {{/candelete}}
-        {{#isactionevent}}
+        {{#action.actionable}}
+            <a href="{{action.url}}">{{{action.name}}}</a>
+        {{/action.actionable}}
+        {{^action.actionable}}
+            {{#isactionevent}}
             <a href="{{url}}">{{#str}} gotoactivity, core_calendar {{/str}}</a>
-        {{/isactionevent}}
+            {{/isactionevent}}
+        {{/action.actionable}}
+
         {{^isactionevent}}
             {{#canedit}}
                 <button type="button" class="btn btn-primary" data-action="edit">{{#str}} edit {{/str}}</button>
index e1273ec..11608cd 100644 (file)
@@ -89,6 +89,8 @@
                                         {{/underway}}
                                         {{^underway}}
                                             <li data-region="event-item"
+                                                dava-event-component="{{component}}"
+                                                data-event-eventtype="{{eventtype}}"
                                                 data-eventtype-{{normalisedeventtype}}="1"
                                                 {{#draggable}}
                                                     draggable="true"
index d6ae9f6..27aa5a2 100644 (file)
@@ -156,6 +156,14 @@ class core_calendar_action_event_test_event implements event_interface {
     public function is_visible() {
         return true;
     }
+
+    /**
+     * Component
+     * @return string|null
+     */
+    public function get_component() {
+        return null;
+    }
 }
 
 /**
index a9a39a5..e9cafd7 100644 (file)
@@ -255,6 +255,14 @@ class event_mapper_test_action_event implements action_event_interface {
             true
         );
     }
+
+    /**
+     * Component
+     * @return string|null
+     */
+    public function get_component() {
+        return $this->event->get_component();
+    }
 }
 
 /**
@@ -374,6 +382,14 @@ class event_mapper_test_event implements event_interface {
     public function is_visible() {
         return true;
     }
+
+    /**
+     * Component
+     * @return string|null
+     */
+    public function get_component() {
+        return null;
+    }
 }
 
 /**
index 3a70913..9302a9d 100644 (file)
@@ -59,16 +59,18 @@ class core_calendar_event_testcase extends advanced_testcase {
             $constructorparams['times'],
             $constructorparams['visible'],
             $constructorparams['subscription'],
-            $constructorparams['location']
+            $constructorparams['location'],
+            $constructorparams['component']
         );
 
         foreach ($constructorparams as $name => $value) {
-            if ($name !== 'visible') {
+            if ($name !== 'visible' && $name !== 'component') {
                 $this->assertEquals($event->{'get_' . $name}(), $value);
             }
         }
 
         $this->assertEquals($event->is_visible(), $constructorparams['visible']);
+        $this->assertEquals('mod_' . $event->get_course_module()->get('modname'), $event->get_component());
     }
 
     /**
@@ -76,7 +78,7 @@ class core_calendar_event_testcase extends advanced_testcase {
      */
     public function getters_testcases() {
         $lamecallable = function($id) {
-            return (object)['id' => $id];
+            return (object)['id' => $id, 'modname' => 'assign'];
         };
 
         return [
@@ -101,6 +103,7 @@ class core_calendar_event_testcase extends advanced_testcase {
                     'visible' => true,
                     'subscription' => new std_proxy(1, $lamecallable),
                     'location' => 'Test',
+                    'component' => null
                 ]
             ],
         ];
index 9a875f8..67f41ff 100644 (file)
@@ -137,7 +137,8 @@ class action_event_test_factory implements event_factory_interface {
             ),
             !empty($record->visible),
             $subscription,
-            $record->location
+            $record->location,
+            !empty($record->component) ? $record->component : null
         );
 
         $action = new action(
index 8c963bd..f11e271 100644 (file)
@@ -205,7 +205,8 @@ class core_calendar_repeat_event_collection_event_test_factory implements event_
             ),
             !empty($dbrow->visible),
             new std_proxy($dbrow->subscriptionid, $identity),
-            $dbrow->location
+            $dbrow->location,
+            $dbrow->component
         );
     }
 }
index 2f4273a..c458319 100644 (file)
@@ -1,6 +1,14 @@
 This files describes API changes in /calendar/* ,
 information provided here is intended especially for developers.
 
+=== 3.9 ===
+* Plugins can now create their own calendar events, both standard and action ones. To do it they need to specify
+  $event->component when creating an event. Component events can not be edited or deleted manually.
+  See https://docs.moodle.org/dev/Calendar_API#Component_events
+* The following functions have been deprecated because they were no longer used:
+  - calendar_add_event_metadata()
+  - core_calendar_renderer::event()
+
 === 3.8 ===
 * The following functions have been finally deprecated and can not be used anymore:
   * calendar_wday_name()
index 30855a4..96eb509 100644 (file)
@@ -319,6 +319,8 @@ $CFG->admin = 'admin';
 //      $CFG->session_redis_prefix = ''; // Optional, default is don't set one.
 //      $CFG->session_redis_acquire_lock_timeout = 120;
 //      $CFG->session_redis_lock_expire = 7200;
+//      $CFG->session_redis_lock_retry = 100; // Optional wait between lock attempts in ms, default is 100.
+//                                            // After 5 seconds it will throttle down to once per second.
 //      Use the igbinary serializer instead of the php default one. Note that phpredis must be compiled with
 //      igbinary support to make the setting to work. Also, if you change the serializer you have to flush the database!
 //      $CFG->session_redis_serializer_use_igbinary = false; // Optional, default is PHP builtin serializer.
index 704458a..e2c1940 100644 (file)
@@ -186,8 +186,10 @@ abstract class contenttype {
      */
     public function get_view_content(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();
+
+        return '';
     }
 
     /**
index f279481..a70bccb 100644 (file)
@@ -37,19 +37,6 @@ class contenttype extends \core_contentbank\contenttype {
     /** Feature for testing */
     const CAN_TEST = 'test';
 
-    /**
-     * Returns the URL where the content will be visualized.
-     *
-     * @param  content $content The content to delete.
-     * @return string            URL where to visualize the given content.
-     */
-    public function get_view_url(\core_contentbank\content $content): string {
-        $fileurl = $this->get_file_url($content->get_id());
-        $url = $fileurl."?forcedownload=1";
-
-        return $url;
-    }
-
     /**
      * Returns the HTML code to render the icon for content bank contents.
      *
index b3f553f..da936c1 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 78345bc..83b317b 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index abfc5d3..dde9eec 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index 355a979..6f8a885 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index 1fddca8..0b4a6ed 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js and b/course/amd/build/local/activitychooser/selectors.min.js differ
index bfa0617..177b53e 100644 (file)
Binary files a/course/amd/build/local/activitychooser/selectors.min.js.map and b/course/amd/build/local/activitychooser/selectors.min.js.map differ
index a66560e..0aa10d5 100644 (file)
@@ -31,16 +31,28 @@ import * as ModalFactory from 'core/modal_factory';
 import {get_string as getString} from 'core/str';
 import Pending from 'core/pending';
 
+// Set up some JS module wide constants that can be added to in the future.
+
+// Tab config options.
+const ALLACTIVITIESRESOURCES = 0;
+const ONLYALL = 1;
+const ACTIVITIESRESOURCES = 2;
+
+// Module types.
+const ACTIVITY = 0;
+const RESOURCE = 1;
+
 /**
  * Set up the activity chooser.
  *
  * @method init
  * @param {Number} courseId Course ID to use later on in fetchModules()
+ * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  */
-export const init = courseId => {
+export const init = (courseId, chooserConfig) => {
     const pendingPromise = new Pending();
 
-    registerListenerEvents(courseId);
+    registerListenerEvents(courseId, chooserConfig);
 
     pendingPromise.resolve();
 };
@@ -50,8 +62,9 @@ export const init = courseId => {
  *
  * @method registerListenerEvents
  * @param {Number} courseId
+ * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  */
-const registerListenerEvents = (courseId) => {
+const registerListenerEvents = (courseId, chooserConfig) => {
     const events = [
         'click',
         CustomEvents.events.activate,
@@ -108,7 +121,7 @@ const registerListenerEvents = (courseId) => {
 
                 bodyPromiseResolver(await Templates.render(
                     'core_course/activitychooser',
-                    templateDataBuilder(builtModuleData)
+                    templateDataBuilder(builtModuleData, chooserConfig)
                 ));
             }
         });
@@ -138,28 +151,57 @@ const sectionIdMapper = (webServiceData, id) => {
  *
  * @method templateDataBuilder
  * @param {Array} data our modules to manipulate into a Templatable object
+ * @param {Object} chooserConfig Any PHP config settings that we may need to reference
  * @return {Object} Our built object ready to render out
  */
-const templateDataBuilder = (data) => {
+const templateDataBuilder = (data, chooserConfig) => {
+    // Setup of various bits and pieces we need to mutate before throwing it to the wolves.
+    let activities = [];
+    let resources = [];
+    let showAll = true;
+    let showActivities = false;
+    let showResources = false;
+
+    // Tab mode can be the following [All, Resources & Activities, All & Activities & Resources].
+    const tabMode = parseInt(chooserConfig.tabmode);
+
     // Filter the incoming data to find favourite & recommended modules.
     const favourites = data.filter(mod => mod.favourite === true);
     const recommended = data.filter(mod => mod.recommended === true);
 
-    // Given the results of the above filters lets figure out what tab to set active.
+    // Both of these modes need Activity & Resource tabs.
+    if ((tabMode === ALLACTIVITIESRESOURCES || tabMode === ACTIVITIESRESOURCES) && tabMode !== ONLYALL) {
+        // Filter the incoming data to find activities then resources.
+        activities = data.filter(mod => mod.archetype === ACTIVITY);
+        resources = data.filter(mod => mod.archetype === RESOURCE);
+        showActivities = true;
+        showResources = true;
+
+        // We want all of the previous information but no 'All' tab.
+        if (tabMode === ACTIVITIESRESOURCES) {
+            showAll = false;
+        }
+    }
 
+    // Given the results of the above filters lets figure out what tab to set active.
     // We have some favourites.
     const favouritesFirst = !!favourites.length;
-    // Check if we have no favourites but have some recommended.
-    const recommendedFirst = !!(recommended.length && favouritesFirst === false);
+    // We are in tabMode 2 without any favourites.
+    const activitiesFirst = showAll === false && favouritesFirst === false;
     // We have nothing fallback to show all modules.
-    const fallback = favouritesFirst === false && recommendedFirst === false;
+    const fallback = showAll === true && favouritesFirst === false;
 
     return {
         'default': data,
+        showAll: showAll,
+        activities: activities,
+        showActivities: showActivities,
+        activitiesFirst: activitiesFirst,
+        resources: resources,
+        showResources: showResources,
         favourites: favourites,
         recommended: recommended,
         favouritesFirst: favouritesFirst,
-        recommendedFirst: recommendedFirst,
         fallback: fallback,
     };
 };
@@ -204,22 +246,22 @@ const nullFavouriteDomManager = (favouriteTabNav, modalBody) => {
         favouriteTabNav.setAttribute('aria-selected', 'false');
         const favouriteTab = modalBody.querySelector(selectors.regions.favouriteTab);
         favouriteTab.classList.remove('active');
-        const recommendedTabNav = modalBody.querySelector(selectors.regions.recommendedTabNav);
         const defaultTabNav = modalBody.querySelector(selectors.regions.defaultTabNav);
-        if (recommendedTabNav.classList.contains('d-none') === false) {
-            recommendedTabNav.classList.add('active');
-            recommendedTabNav.setAttribute('aria-selected', 'true');
-            recommendedTabNav.tabIndex = 0;
-            recommendedTabNav.focus();
-            const recommendedTab = modalBody.querySelector(selectors.regions.recommendedTab);
-            recommendedTab.classList.add('active');
-        } else {
+        const activitiesTabNav = modalBody.querySelector(selectors.regions.activityTabNav);
+        if (defaultTabNav.classList.contains('d-none') === false) {
             defaultTabNav.classList.add('active');
             defaultTabNav.setAttribute('aria-selected', 'true');
             defaultTabNav.tabIndex = 0;
             defaultTabNav.focus();
             const defaultTab = modalBody.querySelector(selectors.regions.defaultTab);
             defaultTab.classList.add('active');
+        } else {
+            activitiesTabNav.classList.add('active');
+            activitiesTabNav.setAttribute('aria-selected', 'true');
+            activitiesTabNav.tabIndex = 0;
+            activitiesTabNav.focus();
+            const activitiesTab = modalBody.querySelector(selectors.regions.activityTab);
+            activitiesTab.classList.add('active');
         }
 
     }
index 42a19b3..3cbc5e8 100644 (file)
@@ -194,9 +194,11 @@ const registerListenerEvents = (modal, mappedModules, partialFavourite) => {
         const activeSectionId = body.querySelector(selectors.elements.activetab).getAttribute("href");
         const sectionChooserOptions = body.querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
         const firstChooserOption = sectionChooserOptions.querySelector(selectors.regions.chooserOption.container);
+
         toggleFocusableChooserOption(firstChooserOption, true);
         initTabsKeyboardNavigation(body);
         initChooserOptionsKeyboardNavigation(body, mappedModules, sectionChooserOptions);
+
         return body;
     })
     .catch();
@@ -214,7 +216,9 @@ const initTabsKeyboardNavigation = (body) => {
     const favTabNav = body.querySelector(selectors.regions.favouriteTabNav);
     const recommendedTabNav = body.querySelector(selectors.regions.recommendedTabNav);
     const defaultTabNav = body.querySelector(selectors.regions.defaultTabNav);
-    const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav];
+    const activityTabNav = body.querySelector(selectors.regions.activityTabNav);
+    const resourceTabNav = body.querySelector(selectors.regions.resourceTabNav);
+    const tabNavArray = [favTabNav, recommendedTabNav, defaultTabNav, activityTabNav, resourceTabNav];
     tabNavArray.forEach((element) => {
         return element.addEventListener('keydown', (e) => {
             // The first visible navigation tab link.
@@ -556,6 +560,5 @@ export const displayChooser = (modalPromise, sectionModules, partialFavourite) =
         });
 
         return modal;
-    })
-    .catch();
+    }).catch();
 };
index 3a15f75..6b54bbc 100644 (file)
@@ -54,9 +54,13 @@ export default {
         favouriteTabNav: getDataSelector('region', 'favourite-tab-nav'),
         recommendedTabNav: getDataSelector('region', 'recommended-tab-nav'),
         defaultTabNav: getDataSelector('region', 'default-tab-nav'),
+        activityTabNav: getDataSelector('region', 'activity-tab-nav'),
+        resourceTabNav: getDataSelector('region', 'resources-tab-nav'),
         favouriteTab: getDataSelector('region', 'favourites'),
         recommendedTab: getDataSelector('region', 'recommended'),
         defaultTab: getDataSelector('region', 'default'),
+        activityTab: getDataSelector('region', 'activity'),
+        resourceTab: getDataSelector('region', 'resources'),
         getModuleSelector: modname => `[role="menuitem"][data-modname="${modname}"]`,
         searchResults: getDataSelector('region', 'search-results-container'),
         searchResultItems: getDataSelector('region', 'search-result-items-container'),
index 5812abd..a1e20c2 100644 (file)
@@ -171,6 +171,24 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         debugging('Can not unset core_course_category instance properties!', DEBUG_DEVELOPER);
     }
 
+    /**
+     * Get list of plugin callback functions.
+     *
+     * @param string $name Callback function name.
+     * @return [callable] $pluginfunctions
+     */
+    public function get_plugins_callback_function(string $name) : array {
+        $pluginfunctions = [];
+        if ($pluginsfunction = get_plugins_with_function($name)) {
+            foreach ($pluginsfunction as $plugintype => $plugins) {
+                foreach ($plugins as $pluginfunction) {
+                    $pluginfunctions[] = $pluginfunction;
+                }
+            }
+        }
+        return $pluginfunctions;
+    }
+
     /**
      * Create an iterator because magic vars can't be seen by 'foreach'.
      *
@@ -1900,13 +1918,12 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             return false;
         }
 
-        $context = $this->get_context();
-        if (!$this->is_uservisible() ||
-                !has_capability('moodle/category:manage', $context)) {
+        if (!$this->has_manage_capability()) {
             return false;
         }
 
         // Check all child categories (not only direct children).
+        $context = $this->get_context();
         $sql = context_helper::get_preload_record_columns_sql('ctx');
         $childcategories = $DB->get_records_sql('SELECT c.id, c.visible, '. $sql.
             ' FROM {context} ctx '.
@@ -1936,6 +1953,15 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
             }
         }
 
+        // Check if plugins permit deletion of category content.
+        $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete');
+        foreach ($pluginfunctions as $pluginfunction) {
+            // If at least one plugin does not permit deletion, stop and return false.
+            if (!$pluginfunction($this)) {
+                return false;
+            }
+        }
+
         return true;
     }
 
@@ -1961,13 +1987,9 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $settimeout = core_php_time_limit::raise();
 
         // Allow plugins to use this category before we completely delete it.
-        if ($pluginsfunction = get_plugins_with_function('pre_course_category_delete')) {
-            $category = $this->get_db_record();
-            foreach ($pluginsfunction as $plugintype => $plugins) {
-                foreach ($plugins as $pluginfunction) {
-                    $pluginfunction($category);
-                }
-            }
+        $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete');
+        foreach ($pluginfunctions as $pluginfunction) {
+            $pluginfunction($this->get_db_record());
         }
 
         $deletedcourses = array();
@@ -2076,25 +2098,35 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
     public function can_move_content_to($newcatid) {
         global $CFG;
         require_once($CFG->libdir . '/questionlib.php');
-        $context = $this->get_context();
-        if (!$this->is_uservisible() ||
-                !has_capability('moodle/category:manage', $context)) {
+
+        if (!$this->has_manage_capability()) {
             return false;
         }
+
         $testcaps = array();
         // If this category has courses in it, user must have 'course:create' capability in target category.
         if ($this->has_courses()) {
             $testcaps[] = 'moodle/course:create';
         }
         // If this category has subcategories or questions, user must have 'category:manage' capability in target category.
-        if ($this->has_children() || question_context_has_any_questions($context)) {
+        if ($this->has_children() || question_context_has_any_questions($this->get_context())) {
             $testcaps[] = 'moodle/category:manage';
         }
-        if (!empty($testcaps)) {
-            return has_all_capabilities($testcaps, context_coursecat::instance($newcatid));
+        if (!empty($testcaps) && !has_all_capabilities($testcaps, context_coursecat::instance($newcatid))) {
+            // No sufficient capabilities to perform this task.
+            return false;
+        }
+
+        // Check if plugins permit moving category content.
+        $pluginfunctions = $this->get_plugins_callback_function('can_course_category_delete_move');
+        $newparentcat = self::get($newcatid, MUST_EXIST, true);
+        foreach ($pluginfunctions as $pluginfunction) {
+            // If at least one plugin does not permit move on deletion, stop and return false.
+            if (!$pluginfunction($this, $newparentcat)) {
+                return false;
+            }
         }
 
-        // There is no content but still return true.
         return true;
     }
 
@@ -2124,6 +2156,12 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $coursesids = $DB->get_fieldset_select('course', 'id', 'category = :category ORDER BY sortorder ASC', $params);
         $context = $this->get_context();
 
+        // Allow plugins to make necessary changes before we move the category content.
+        $pluginfunctions = $this->get_plugins_callback_function('pre_course_category_delete_move');
+        foreach ($pluginfunctions as $pluginfunction) {
+            $pluginfunction($this, $newparentcat);
+        }
+
         if ($children) {
             foreach ($children as $childcat) {
                 $childcat->change_parent_raw($newparentcat);
@@ -2186,7 +2224,7 @@ class core_course_category implements renderable, cacheable_object, IteratorAggr
         $event = \core\event\course_category_deleted::create(array(
             'objectid' => $this->id,
             'context' => $context,
-            'other' => array('name' => $this->name)
+            'other' => array('name' => $this->name, 'contentmovedcategoryid' => $newparentid)
         ));
         $event->set_coursecat($this);
         $event->trigger();
index b75f19f..ddbd433 100644 (file)
@@ -76,14 +76,23 @@ class core_course_deletecategory_form extends moodleform {
         // Describe the contents of this category.
         $contents = '';
         if ($this->coursecat->has_children()) {
-            $contents .= '<li>' . get_string('subcategories') . '</li>';
+            $contents .= html_writer::tag('li', get_string('subcategories'));
         }
         if ($this->coursecat->has_courses()) {
-            $contents .= '<li>' . get_string('courses') . '</li>';
+            $contents .= html_writer::tag('li', get_string('courses'));
         }
         if (question_context_has_any_questions($categorycontext)) {
-            $contents .= '<li>' . get_string('questionsinthequestionbank') . '</li>';
+            $contents .= html_writer::tag('li', get_string('questionsinthequestionbank'));
         }
+
+        // Check if plugins can provide more info.
+        $pluginfunctions = $this->coursecat->get_plugins_callback_function('get_course_category_contents');
+        foreach ($pluginfunctions as $pluginfunction) {
+            if ($plugincontents = $pluginfunction($this->coursecat)) {
+                $contents .= html_writer::tag('li', $plugincontents);
+            }
+        }
+
         if (!empty($contents)) {
             $mform->addElement('static', 'emptymessage', get_string('thiscategorycontains'), html_writer::tag('ul', $contents));
         } else {
@@ -92,7 +101,9 @@ class core_course_deletecategory_form extends moodleform {
 
         // Give the options for what to do.
         $mform->addElement('select', 'fulldelete', get_string('whattodo'), $options);
+
         if (count($options) == 1) {
+            // Freeze selector if only one option available.
             $optionkeys = array_keys($options);
             $option = reset($optionkeys);
             $mform->hardFreeze('fulldelete');
@@ -111,10 +122,6 @@ class core_course_deletecategory_form extends moodleform {
         $mform->setType('categoryid', PARAM_ALPHANUM);
         $mform->addElement('hidden', 'action', 'deletecategory');
         $mform->setType('action', PARAM_ALPHANUM);
-        $mform->addElement('hidden', 'sure');
-        // This gets set by default to ensure that if the user changes it manually we can detect it.
-        $mform->setDefault('sure', md5(serialize($this->coursecat)));
-        $mform->setType('sure', PARAM_ALPHANUM);
 
         $this->add_action_buttons(true, get_string('delete'));
     }
@@ -131,10 +138,11 @@ class core_course_deletecategory_form extends moodleform {
         if (empty($data['fulldelete']) && empty($data['newparent'])) {
             // When they have chosen the move option, they must specify a destination.
             $errors['newparent'] = get_string('required');
+            return $errors;
         }
 
-        if ($data['sure'] !== md5(serialize($this->coursecat))) {
-            $errors['categorylabel'] = get_string('categorymodifiedcancel');
+        if (!empty($data['newparent']) && !$this->coursecat->can_move_content_to($data['newparent'])) {
+            $errors['newparent'] = get_string('movecatcontentstoselected', 'error');
         }
 
         return $errors;
index 979e6cb..7a71fa6 100644 (file)
@@ -277,6 +277,13 @@ function edit_module_post_actions($moduleinfo, $course) {
             if ($update) {
                 $item->update();
             }
+
+            if (!empty($moduleinfo->add)) {
+                $gradecategory = $item->get_parent_category();
+                if ($item->set_aggregation_fields_for_aggregation(0, $gradecategory->aggregation)) {
+                    $item->update();
+                }
+            }
         }
     }
 
@@ -298,8 +305,8 @@ function edit_module_post_actions($moduleinfo, $course) {
 
             if (property_exists($moduleinfo, $elname) and $moduleinfo->$elname) {
                 // Check if this is a new outcome grade item.
+                $outcomeexists = false;
                 if ($items) {
-                    $outcomeexists = false;
                     foreach($items as $item) {
                         if ($item->outcomeid == $outcome->id) {
                             $outcomeexists = true;
@@ -333,6 +340,13 @@ function edit_module_post_actions($moduleinfo, $course) {
                 } else if (isset($moduleinfo->gradecat)) {
                     $outcomeitem->set_parent($moduleinfo->gradecat);
                 }
+
+                if (!$outcomeexists) {
+                    $gradecategory = $outcomeitem->get_parent_category();
+                    if ($outcomeitem->set_aggregation_fields_for_aggregation(0, $gradecategory->aggregation)) {
+                        $outcomeitem->update();
+                    }
+                }
             }
         }
     }
index 8d58bc8..af5d289 100644 (file)
@@ -158,7 +158,11 @@ class core_course_renderer extends plugin_renderer_base {
             return '';
         }
 
-        $this->page->requires->js_call_amd('core_course/activitychooser', 'init', [$courseid]);
+        // Build an object of config settings that we can then hook into in the Activity Chooser.
+        $chooserconfig = (object) [
+            'tabmode' => get_config('core', 'activitychoosertabmode'),
+        ];
+        $this->page->requires->js_call_amd('core_course/activitychooser', 'init', [$courseid, $chooserconfig]);
 
         return '';
     }
index 3b2af23..8f2948c 100644 (file)
@@ -64,7 +64,7 @@
         }
     }
 }}
-<div class="mt-5 mb-1 activity-navigation">
+<div class="mt-5 mb-1 activity-navigation container-fluid">
 {{< core/columns-1to1to1}}
     {{$column1}}
         <div class="float-left">
index 45d9a25..fe5fd22 100644 (file)
                         >
                             {{#str}} favourites, core {{/str}}
                         </a>
-                        <a class="nav-item nav-link {{#recommendedFirst}}active{{/recommendedFirst}} {{^recommended}}d-none{{/recommended}}"
-                           id="recommended-tab-{{uniqid}}"
-                           data-region="recommended-tab-nav"
-                           data-toggle="tab"
-                           href="#recommended-{{uniqid}}"
-                           role="tab"
-                           aria-label="{{#str}} aria:recommendedtab, core_course {{/str}}"
-                           aria-controls="recommended-{{uniqid}}"
-                           aria-selected="{{#recommendedFirst}}true{{/recommendedFirst}}{{^recommendedFirst}}false{{/recommendedFirst}}"
-                           tabindex="{{#recommendedFirst}}0{{/recommendedFirst}}{{^recommendedFirst}}-1{{/recommendedFirst}}"
-                        >
-                            {{#str}} recommended, core {{/str}}
-                        </a>
-                        <a class="nav-item nav-link {{#fallback}}active{{/fallback}}"
+                        <a class="nav-item nav-link {{#fallback}}active{{/fallback}} {{^showAll}}d-none{{/showAll}}"
                            id="all-tab-{{uniqid}}"
                            data-toggle="tab"
                            data-region="default-tab-nav"
                            aria-controls="all-{{uniqid}}"
                            aria-selected="{{#fallback}}true{{/fallback}}{{^fallback}}false{{/fallback}}"
                            tabindex="{{#fallback}}0{{/fallback}}{{^fallback}}-1{{/fallback}}"
+                        >
+                            {{#str}} all, core {{/str}}
+                        </a>
+                        <a class="nav-item nav-link {{#activitiesFirst}}active{{/activitiesFirst}} {{^showActivities}}d-none{{/showActivities}}"
+                           id="activity-tab-{{uniqid}}"
+                           data-toggle="tab"
+                           data-region="activity-tab-nav"
+                           href="#activity-{{uniqid}}"
+                           role="tab"
+                           aria-label="{{#str}} activities, core {{/str}}"
+                           aria-controls="activity-{{uniqid}}"
+                           aria-selected="{{#activitiesFirst}}true{{/activitiesFirst}}{{^activitiesFirst}}false{{/activitiesFirst}}"
+                           tabindex="{{#activitiesFirst}}0{{/activitiesFirst}}{{^activitiesFirst}}-1{{/activitiesFirst}}"
                         >
                             {{#str}} activities, core {{/str}}
                         </a>
+                        <a class="nav-item nav-link {{^showResources}}d-none{{/showResources}}"
+                           id="resources-tab-{{uniqid}}"
+                           data-toggle="tab"
+                           data-region="resources-tab-nav"
+                           href="#resources-{{uniqid}}"
+                           role="tab"
+                           aria-label="{{#str}} resources, core {{/str}}"
+                           aria-controls="resources-{{uniqid}}"
+                           aria-selected="false"
+                           tabindex="-1"
+                        >
+                            {{#str}} resources, core {{/str}}
+                        </a>
+                        <a class="nav-item nav-link {{^recommended}}d-none{{/recommended}}"
+                           id="recommended-tab-{{uniqid}}"
+                           data-region="recommended-tab-nav"
+                           data-toggle="tab"
+                           href="#recommended-{{uniqid}}"
+                           role="tab"
+                           aria-label="{{#str}} aria:recommendedtab, core_course {{/str}}"
+                           aria-controls="recommended-{{uniqid}}"
+                           aria-selected="false"
+                           tabindex="-1"
+                        >
+                            {{#str}} recommended, core {{/str}}
+                        </a>
                     </div>
                     <div class="tab-content" id="tabbed-activities-{{uniqid}}">
                         <div class="tab-pane {{#favouritesFirst}}active{{/favouritesFirst}}" id="starred-{{uniqid}}" data-region="favourites" role="tabpanel" aria-labelledby="starred-tab-{{uniqid}}">
                                 {{>core_course/local/activitychooser/favourites}}
                             </div>
                         </div>
-                        <div class="tab-pane {{#recommendedFirst}}active{{/recommendedFirst}}" id="recommended-{{uniqid}}" data-region="recommended" role="tabpanel" aria-labelledby="recommended-tab-{{uniqid}}">
+                        <div class="tab-pane {{#fallback}}active{{/fallback}} {{^showAll}}d-none{{/showAll}}" id="all-{{uniqid}}" data-region="default" role="tabpanel" aria-labelledby="all-tab-{{uniqid}}">
                             <div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
-                                {{#recommended}}
+                                {{#default}}
                                     {{>core_course/local/activitychooser/item}}
-                                {{/recommended}}
+                                {{/default}}
                             </div>
                         </div>
-                        <div class="tab-pane {{#fallback}}active{{/fallback}}" id="all-{{uniqid}}" data-region="default" role="tabpanel" aria-labelledby="all-tab-{{uniqid}}">
+                        <div class="tab-pane {{#activitiesFirst}}active{{/activitiesFirst}}" id="activity-{{uniqid}}" data-region="activity" role="tabpanel" aria-labelledby="activity-tab-{{uniqid}}">
                             <div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
-                                {{#default}}
+                                {{#activities}}
                                     {{>core_course/local/activitychooser/item}}
-                                {{/default}}
+                                {{/activities}}
+                            </div>
+                        </div>
+                        <div class="tab-pane" id="resources-{{uniqid}}" data-region="resources" role="tabpanel" aria-labelledby="resources-tab-{{uniqid}}">
+                            <div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
+                                {{#resources}}
+                                    {{>core_course/local/activitychooser/item}}
+                                {{/resources}}
+                            </div>
+                        </div>
+                        <div class="tab-pane" id="recommended-{{uniqid}}" data-region="recommended" role="tabpanel" aria-labelledby="recommended-tab-{{uniqid}}">
+                            <div class="optionscontainer d-flex flex-wrap p-1 mw-100 position-relative" role="menubar" data-region="chooser-options-container">
+                                {{#recommended}}
+                                    {{>core_course/local/activitychooser/item}}
+                                {{/recommended}}
                             </div>
                         </div>
                     </div>
index 98ef06a..578e19c 100644 (file)
@@ -58,7 +58,7 @@ Feature: Display and choose from the available activities in course
     When I log out
     And I log in as "admin"
     And I am on site homepage
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     And I click on ".activity-recommend-checkbox" "css_element" in the "Book" "table_row"
     # Setup done, lets check it works with a teacher.
     And I log out
@@ -148,3 +148,40 @@ Feature: Display and choose from the available activities in course
     Then I should not see "Search query"
     And ".searchresultscontainer" "css_element" should not exist
     And ".optionscontainer" "css_element" should exist
+
+  Scenario: Teacher gets the base case for the Activity Chooser tab mode
+    Given I click on "Add an activity" "button" in the "Topic 1" "section"
+    And I should see "Activities" in the "Add an activity" "dialogue"
+    When I click on "Activities" "link" in the "Add an activity" "dialogue"
+    Then I should not see "Book" in the "activity" "core_course > Activity chooser tab"
+    And I click on "Resources" "link" in the "Add an activity" "dialogue"
+    And I should not see "Assignment" in the "resources" "core_course > Activity chooser tab"
+
+  Scenario: Teacher gets the simple case for the Activity Chooser tab mode
+    Given I log out
+    And I log in as "admin"
+    And I am on site homepage
+    When I navigate to "Courses > Activity chooser > Activity chooser settings" in site administration
+    And I select "Starred, All, Recommended" from the "Activity chooser tabs" singleselect
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher"
+    And I am on "Course" course homepage with editing mode on
+    And I click on "Add an activity" "button" in the "Topic 1" "section"
+    Then I should not see "Activities" in the "Add an activity" "dialogue"
+    And I should not see "Resources" in the "Add an activity" "dialogue"
+
+  Scenario: Teacher gets the final case for the Activity Chooser tab mode
+    Given I log out
+    And I log in as "admin"
+    And I am on site homepage
+    When I navigate to "Courses > Activity chooser > Activity chooser settings" in site administration
+    And I select "Starred, Activities, Resources, Recommended" from the "Activity chooser tabs" singleselect
+    And I press "Save changes"
+    And I log out
+    And I log in as "teacher"
+    And I am on "Course" course homepage with editing mode on
+    And I click on "Add an activity" "button" in the "Topic 1" "section"
+    Then I should not see "All" in the "Add an activity" "dialogue"
+    And I should see "Activities" in the "Add an activity" "dialogue"
+    And I should see "Resources" in the "Add an activity" "dialogue"
index 2b2e68d..818d935 100644 (file)
@@ -5,24 +5,24 @@ Feature: Recommending activities
   Scenario: As an admin I can recommend activities from an admin setting page.
     Given I log in as "admin"
     And I am on site homepage
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     And I click on ".activity-recommend-checkbox" "css" in the "Assignment" "table_row"
     And I navigate to "Courses > Add a new course" in site administration
-    When I navigate to "Courses > Recommended activities" in site administration
+    When I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     Then "input[aria-label=\"Recommend activity: Assignment\"][checked=checked]" "css_element" should exist
     And "input[aria-label=\"Recommend activity: Book\"]:not([checked=checked])" "css_element" should exist
 
   Scenario: As an admin I can remove recommend activities from an admin setting page.
     Given I log in as "admin"
     And I am on site homepage
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     And I click on ".activity-recommend-checkbox" "css" in the "Assignment" "table_row"
     And I navigate to "Courses > Add a new course" in site administration
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     And "input[aria-label=\"Recommend activity: Assignment\"][checked=checked]" "css_element" should exist
     And "input[aria-label=\"Recommend activity: Book\"]:not([checked=checked])" "css_element" should exist
     And I click on ".activity-recommend-checkbox" "css" in the "Assignment" "table_row"
     And I navigate to "Courses > Add a new course" in site administration
-    When I navigate to "Courses > Recommended activities" in site administration
+    When I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     Then "input[aria-label=\"Recommend activity: Assignment\"]:not([checked=checked])" "css_element" should exist
     And "input[aria-label=\"Recommend activity: Book\"]:not([checked=checked])" "css_element" should exist
index 3575aaf..202839d 100644 (file)
@@ -5,7 +5,7 @@ Feature: Search recommended activities
   Scenario: Search results are returned if the search query matches any activity names
     Given I log in as "admin"
     And I am on site homepage
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     When I set the field "search" to "assign"
     And I click on "Submit search" "button"
     Then I should see "Search results: 1"
@@ -15,7 +15,7 @@ Feature: Search recommended activities
   Scenario: Search results are not returned if the search query does not match with any activity names
     Given I log in as "admin"
     And I am on site homepage
-    And I navigate to "Courses > Recommended activities" in site administration
+    And I navigate to "Courses > Activity chooser > Recommended activities" in site administration
     When I set the field "search" to "random query"
     And I click on "Submit search" "button"
     Then I should see "Search results: 0"
diff --git a/course/tests/category_hooks_test.php b/course/tests/category_hooks_test.php
new file mode 100644 (file)
index 0000000..52af5f7
--- /dev/null
@@ -0,0 +1,215 @@
+<?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/>.
+
+/**
+ * Tests for class core_course_category methods invoking hooks.
+ *
+ * @package    core_course
+ * @category   test
+ * @copyright  2020 Ruslan Kabalin
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\core_course;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/course/tests/fixtures/mock_hooks.php');
+
+use PHPUnit\Framework\MockObject\MockObject;
+
+/**
+ * Functional test for class core_course_category methods invoking hooks.
+ */
+class core_course_category_hooks_testcase extends \advanced_testcase {
+
+    protected function setUp() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+    }
+
+    /**
+     * Provides mocked category configured for named callback function.
+     *
+     * get_plugins_callback_function will return callable prefixed with `tool_unittest_`,
+     * the actual callbacks are defined in mock_hooks.php fixture file.
+     *
+     * @param core_course_category $category Category to mock
+     * @param string $callback Callback function used in method we test.
+     * @return MockObject
+     */
+    public function get_mock_category(\core_course_category $category, string $callback = '') : MockObject {
+        // Setup mock object for \core_course_category.
+        // Disable original constructor, since we can't use it directly since it is private.
+        $mockcategory = $this->getMockBuilder(\core_course_category::class)
+            ->setMethods(['get_plugins_callback_function'])
+            ->disableOriginalConstructor()
+            ->getMock();
+
+        // Define get_plugins_callback_function use and return value.
+        if (!empty($callback)) {
+            $mockcategory->method('get_plugins_callback_function')
+                ->with($this->equalTo($callback))
+                ->willReturn(['tool_unittest_' . $callback]);
+        }
+
+        // Modify constructor visibility and invoke mock object with real object.
+        // This is used to overcome private constructor.
+        $reflected = new \ReflectionClass(\core_course_category::class);
+        $constructor = $reflected->getConstructor();
+        $constructor->setAccessible(true);
+        $constructor->invoke($mockcategory, $category->get_db_record());
+
+        return $mockcategory;
+    }
+
+    public function test_can_course_category_delete_hook() {
+        $category1 = \core_course_category::create(array('name' => 'Cat1'));
+        $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
+        $category3 = \core_course_category::create(array('name' => 'Cat3'));
+
+        $mockcategory2 = $this->get_mock_category($category2, 'can_course_category_delete');
+
+        // Add course to mocked clone of category2.
+        $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id));
+
+        // Now configure fixture to return false for the callback.
+        mock_hooks::set_can_course_category_delete_return(false);
+        $this->assertFalse($mockcategory2->can_delete_full($category3->id));
+
+        // Now configure fixture to return true for the callback.
+        mock_hooks::set_can_course_category_delete_return(true);
+        $this->assertTrue($mockcategory2->can_delete_full($category3->id));
+
+        // Verify passed arguments.
+        $arguments = mock_hooks::get_calling_arguments();
+        $this->assertCount(1, $arguments);
+
+        // Argument 1 is the same core_course_category instance.
+        $argument = array_shift($arguments);
+        $this->assertSame($mockcategory2, $argument);
+    }
+
+    public function test_can_course_category_delete_move_hook() {
+        $category1 = \core_course_category::create(array('name' => 'Cat1'));
+        $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
+        $category3 = \core_course_category::create(array('name' => 'Cat3'));
+
+        $mockcategory2 = $this->get_mock_category($category2, 'can_course_category_delete_move');
+
+        // Add course to mocked clone of category2.
+        $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id));
+
+        // Now configure fixture to return false for the callback.
+        mock_hooks::set_can_course_category_delete_move_return(false);
+        $this->assertFalse($mockcategory2->can_move_content_to($category3->id));
+
+        // Now configure fixture to return true for the callback.
+        mock_hooks::set_can_course_category_delete_move_return(true);
+        $this->assertTrue($mockcategory2->can_move_content_to($category3->id));
+
+        // Verify passed arguments.
+        $arguments = mock_hooks::get_calling_arguments();
+        $this->assertCount(2, $arguments);
+
+        // Argument 1 is the same core_course_category instance.
+        $argument = array_shift($arguments);
+        $this->assertSame($mockcategory2, $argument);
+
+        // Argument 2 is referring to category 3.
+        $argument = array_shift($arguments);
+        $this->assertInstanceOf(\core_course_category::class, $argument);
+        $this->assertEquals($category3->id, $argument->id);
+    }
+
+    public function test_pre_course_category_delete_hook() {
+        $category1 = \core_course_category::create(array('name' => 'Cat1'));
+        $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
+
+        $mockcategory2 = $this->get_mock_category($category2, 'pre_course_category_delete');
+        $mockcategory2->delete_full();
+
+        // Verify passed arguments.
+        $arguments = mock_hooks::get_calling_arguments();
+        $this->assertCount(1, $arguments);
+
+        // Argument 1 is the category object.
+        $argument = array_shift($arguments);
+        $this->assertEquals($mockcategory2->get_db_record(), $argument);
+    }
+
+    public function test_pre_course_category_delete_move_hook() {
+        $category1 = \core_course_category::create(array('name' => 'Cat1'));
+        $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
+        $category3 = \core_course_category::create(array('name' => 'Cat3'));
+
+        $mockcategory2 = $this->get_mock_category($category2, 'pre_course_category_delete_move');
+
+        // Add course to mocked clone of category2.
+        $course1 = $this->getDataGenerator()->create_course(array('category' => $mockcategory2->id));
+
+        $mockcategory2->delete_move($category3->id);
+
+        // Verify passed arguments.
+        $arguments = mock_hooks::get_calling_arguments();
+        $this->assertCount(2, $arguments);
+
+        // Argument 1 is the same core_course_category instance.
+        $argument = array_shift($arguments);
+        $this->assertSame($mockcategory2, $argument);
+
+        // Argument 2 is referring to category 3.
+        $argument = array_shift($arguments);
+        $this->assertInstanceOf(\core_course_category::class, $argument);
+        $this->assertEquals($category3->id, $argument->id);
+    }
+
+    public function test_get_course_category_contents_hook() {
+        $category1 = \core_course_category::create(array('name' => 'Cat1'));
+        $category2 = \core_course_category::create(array('name' => 'Cat2', 'parent' => $category1->id));
+
+        $mockcategory2 = $this->get_mock_category($category2);
+
+        // Define get_plugins_callback_function use in the mock, it is called twice for different callback in the form.
+        $mockcategory2->expects($this->exactly(2))
+            ->method('get_plugins_callback_function')
+            ->withConsecutive(
+                [$this->equalTo('can_course_category_delete')],
+                [$this->equalTo('get_course_category_contents')]
+            )
+            ->willReturn(
+                ['tool_unittest_can_course_category_delete'],
+                ['tool_unittest_get_course_category_contents']
+            );
+
+        // Now configure fixture to return string for the callback.
+        $content = 'Bunch of test artefacts';
+        mock_hooks::set_get_course_category_contents_return($content);
+
+        $mform = new \core_course_deletecategory_form(null, $mockcategory2);
+        $this->expectOutputRegex("/<li>$content<\/li>/");
+        $mform->display();
+
+        // Verify passed arguments.
+        $arguments = mock_hooks::get_calling_arguments();
+        $this->assertCount(1, $arguments);
+
+        // Argument 1 is the same core_course_category instance.
+        $argument = array_shift($arguments);
+        $this->assertSame($mockcategory2, $argument);
+    }
+}
diff --git a/course/tests/fixtures/mock_hooks.php b/course/tests/fixtures/mock_hooks.php
new file mode 100644 (file)
index 0000000..d5fc095
--- /dev/null
@@ -0,0 +1,184 @@
+<?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/>.
+
+/**
+ * Fixture for mocking callbacks used in \core_course_category
+ *
+ * @package    core_course
+ * @category   test
+ * @copyright  2020 Ruslan Kabalin
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\core_course {
+
+    /**
+     * Class mock_hooks
+     *
+     * @package    core_course
+     * @category   test
+     * @copyright  2020 Ruslan Kabalin
+     * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+     */
+    class mock_hooks {
+        /** @var bool $cancoursecategorydelete */
+        private static $cancoursecategorydelete = true;
+
+        /** @var bool $cancoursecategorydeletemove */
+        private static $cancoursecategorydeletemove = true;
+
+        /** @var string $getcoursecategorycontents */
+        private static $getcoursecategorycontents = '';
+
+        /** @var array $callingarguments */
+        private static $callingarguments = [];
+
+        /**
+         * Set calling arguments.
+         *
+         * This is supposed to be used in the callbacks to store arguments passed to callback.
+         *
+         * @param array $callingarguments
+         */
+        public static function set_calling_arguments($callingarguments) {
+            self::$callingarguments = $callingarguments;
+        }
+
+        /**
+         * Get calling arguments.
+         *
+         * This is supposed to be used in the test to verify arguments passed to callback.
+         * This method also reset stored calling arguments.
+         *
+         * @return array $callingarguments
+         */
+        public static function get_calling_arguments() {
+            $callingarguments = self::$callingarguments;
+            self::$callingarguments = [];
+            return $callingarguments;
+        }
+
+        /**
+         * Get can_course_category_delete callback return.
+         *
+         * @return bool
+         */
+        public static function get_can_course_category_delete_return() : bool {
+            return self::$cancoursecategorydelete;
+        }
+
+        /**
+         * Sets can_course_category_delete callback return.
+         *
+         * @param bool $return
+         */
+        public static function set_can_course_category_delete_return(bool $return) {
+            self::$cancoursecategorydelete = $return;
+        }
+
+        /**
+         * Get can_course_category_delete_move callback return.
+         *
+         * @return bool
+         */
+        public static function get_can_course_category_delete_move_return() : bool {
+            return self::$cancoursecategorydeletemove;
+        }
+
+        /**
+         * Sets can_course_category_delete_move callback return.
+         *
+         * @param bool $return
+         */
+        public static function set_can_course_category_delete_move_return(bool $return) {
+            self::$cancoursecategorydeletemove = $return;
+        }
+
+        /**
+         * Get get_course_category_contents callback return.
+         *
+         * @return string
+         */
+        public static function get_get_course_category_contents_return() : string {
+            return self::$getcoursecategorycontents;
+        }
+
+        /**
+         * Sets get_course_category_contents callback return.
+         *
+         * @param string $return
+         */
+        public static function set_get_course_category_contents_return(string $return) {
+            self::$getcoursecategorycontents = $return;
+        }
+    }
+}
+
+namespace {
+
+    /**
+     * Test pre_course_category_delete callback.
+     *
+     * @param object $category
+     */
+    function tool_unittest_pre_course_category_delete(object $category) {
+        \tests\core_course\mock_hooks::set_calling_arguments(func_get_args());
+    }
+
+    /**
+     * Test pre_course_category_delete_move callback.
+     *
+     * @param core_course_category $category
+     * @param core_course_category $newcategory
+     */
+    function tool_unittest_pre_course_category_delete_move(core_course_category $category, core_course_category $newcategory) {
+        \tests\core_course\mock_hooks::set_calling_arguments(func_get_args());
+    }
+
+    /**
+     * Test can_course_category_delete callback.
+     *
+     * @param core_course_category $category
+     * @return bool
+     */
+    function tool_unittest_can_course_category_delete(core_course_category $category) {
+        \tests\core_course\mock_hooks::set_calling_arguments(func_get_args());
+        return \tests\core_course\mock_hooks::get_can_course_category_delete_return();
+    }
+
+    /**
+     * Test can_course_category_delete_move callback.
+     *
+     * @param core_course_category $category
+     * @param core_course_category $newcategory
+     * @return bool
+     */
+    function tool_unittest_can_course_category_delete_move(core_course_category $category, core_course_category $newcategory) {
+        \tests\core_course\mock_hooks::set_calling_arguments(func_get_args());
+        return \tests\core_course\mock_hooks::get_can_course_category_delete_move_return();
+    }
+
+    /**
+     * Test get_course_category_contents callback.
+     *
+     * @param core_course_category $category
+     * @return string
+     */
+    function tool_unittest_get_course_category_contents(core_course_category $category) {
+        \tests\core_course\mock_hooks::set_calling_arguments(func_get_args());
+        return \tests\core_course\mock_hooks::get_get_course_category_contents_return();
+    }
+}
\ No newline at end of file
index ba73f21..29cb40e 100644 (file)
@@ -58,6 +58,7 @@ $outcome = new stdClass();
 $outcome->success = true;
 $outcome->response = new stdClass();
 $outcome->error = '';
+$outcome->count = 0;
 
 $searchanywhere = get_user_preferences('userselector_searchanywhere', false);
 
@@ -157,14 +158,10 @@ switch ($action) {
             foreach ($users as $user) {
                 $plugin->enrol_user($instance, $user->id, $roleid, $timestart, $timeend, null, $recovergrades);
             }
-            $counter = count($users);
+            $outcome->count += count($users);
             foreach ($cohorts as $cohort) {
                 $totalenrolledusers = $plugin->enrol_cohort($instance, $cohort->id, $roleid, $timestart, $timeend, null, $recovergrades);
-                $counter += $totalenrolledusers;
-            }
-            // Display a notification message after the bulk user enrollment.
-            if ($counter > 0) {
-                \core\notification::info(get_string('totalenrolledusers', 'enrol', $counter));
+                $outcome->count += $totalenrolledusers;
             }
         } else {
             throw new enrol_ajax_exception('enrolnotpermitted');
index a08ba79..ee9d82b 100644 (file)
Binary files a/enrol/manual/amd/build/quickenrolment.min.js and b/enrol/manual/amd/build/quickenrolment.min.js differ
index d8e65c3..a413c22 100644 (file)
Binary files a/enrol/manual/amd/build/quickenrolment.min.js.map and b/enrol/manual/amd/build/quickenrolment.min.js.map differ
index 74b9e21..087e4c4 100644 (file)
  * @copyright  2016 Damyon Wiese <damyon@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-define(['core/templates',
-         'jquery',
-         'core/str',
-         'core/config',
-         'core/notification',
-         'core/modal_factory',
-         'core/modal_events',
-         'core/fragment',
-         'core/pending',
-       ],
-       function(Template, $, Str, Config, Notification, ModalFactory, ModalEvents, Fragment, Pending) {
-
-    /** @type {Object} The list of selectors for the quick enrolment modal. */
-    var SELECTORS = {
-        COHORTSELECT: "#id_cohortlist",
-        TRIGGERBUTTONS: ".enrolusersbutton.enrol_manual_plugin [type='submit']",
-        UNWANTEDHIDDENFIELDS: ":input[value='_qf__force_multiselect_submission']"
-    };
-
-    /**
-     * Constructor
-     *
-     * @param {Object} options Object containing options. The only valid option at this time is contextid.
-     * Each call to templates.render gets it's own instance of this class.
-     */
-    var QuickEnrolment = function(options) {
-        this.contextid = options.contextid;
-
-        this.initModal();
-    };
-    // Class variables and functions.
-
-    /** @var {number} courseid - */
-    QuickEnrolment.prototype.courseid = 0;
-
-    /** @var {Modal} modal */
-    QuickEnrolment.prototype.modal = null;
-
-    /**
-     * Private method
-     *
-     * @method initModal
-     * @private
-     */
-    QuickEnrolment.prototype.initModal = function() {
-        var triggerButtons = $(SELECTORS.TRIGGERBUTTONS);
-
-        $.when(
-            Str.get_strings([
-                {key: 'enroluserscohorts', component: 'enrol_manual'},
-                {key: 'enrolusers', component: 'enrol_manual'},
-            ]),
-            ModalFactory.create({
-                type: ModalFactory.types.SAVE_CANCEL,
-                large: true,
-            }, triggerButtons)
-        )
-        .then(function(strings, modal) {
-            this.modal = modal;
-
-            modal.setTitle(strings[1]);
-            modal.setSaveButtonText(strings[1]);
-
-            modal.getRoot().on(ModalEvents.save, this.submitForm.bind(this));
-            modal.getRoot().on('submit', 'form', this.submitFormAjax.bind(this));
-
-            // We want the reset the form every time it is opened.
-            modal.getRoot().on(ModalEvents.hidden, function() {
-                modal.setBody('');
-            });
-
-            modal.getRoot().on(ModalEvents.shown, function() {
-                var pendingPromise = new Pending('enrol_manual/quickenrolment:initModal:shown');
-                var bodyPromise = this.getBody();
-                bodyPromise.then(function(html) {
-                    var stringIndex = $(html).find(SELECTORS.COHORTSELECT).length ? 0 : 1;
-                    modal.setSaveButtonText(strings[stringIndex]);
-
-                    return;
-                })
-                .then(pendingPromise.resolve)
-                .catch(Notification.exception);
-
-                modal.setBody(bodyPromise);
-            }.bind(this));
+import * as DynamicTable from 'core_table/dynamic';
+import * as Str from 'core/str';
+import * as Toast from 'core/toast';
+import Config from 'core/config';
+import Fragment from 'core/fragment';
+import ModalEvents from 'core/modal_events';
+import ModalFactory from 'core/modal_factory';
+import Notification from 'core/notification';
+import jQuery from 'jquery';
+import Prefetch from 'core/prefetch';
+
+const Selectors = {
+    cohortSelector: "#id_cohortlist",
+    triggerButtons: ".enrolusersbutton.enrol_manual_plugin [type='submit']",
+    unwantedHiddenFields: "input[value='_qf__force_multiselect_submission']",
+    buttonWrapper: '[data-region="wrapper"]',
+};
+
+/**
+ * Get the content of the body for the specified context.
+ *
+ * @param {Number} contextId
+ * @returns {Promise}
+ */
+const getBodyForContext = contextId => {
+    return Fragment.loadFragment('enrol_manual', 'enrol_users_form', contextId, {});
+};
+
+/**
+ * Get the dynamic table for the button.
+ *
+ * @param {HTMLElement} element
+ * @returns {HTMLElement}
+ */
+const getDynamicTableForElement = element => {
+    const wrapper = element.closest(Selectors.buttonWrapper);
+
+    return DynamicTable.getTableFromId(wrapper.dataset.tableUniqueid);
+};
+
+/**
+ * Register the event listeners for this contextid.
+ *
+ * @param {Number} contextId
+ */
+const registerEventListeners = contextId => {
+    document.addEventListener('click', e => {
+        if (e.target.closest(Selectors.triggerButtons)) {
+            e.preventDefault();
+
+            showModal(getDynamicTableForElement(e.target), contextId);
 
             return;
-        }.bind(this))
-        .fail(Notification.exception);
-    };
-
-    /**
-     * This triggers a form submission, so that any mform elements can do final tricks before the form submission is processed.
-     *
-     * @method submitForm
-     * @param {Event} e Form submission event.
-     * @private
-     */
-    QuickEnrolment.prototype.submitForm = function(e) {
-        e.preventDefault();
-        this.modal.getRoot().find('form').submit();
-    };
-
-    /**
-     * Private method
-     *
-     * @method submitForm
-     * @private
-     * @param {Event} e Form submission event.
-     */
-    QuickEnrolment.prototype.submitFormAjax = function(e) {
-        // We don't want to do a real form submission.
-        e.preventDefault();
-
-        var form = this.modal.getRoot().find('form');
-
-        // Before send the data through AJAX, we need to parse and remove some unwanted hidden fields.
-        // This hidden fields are added automatically by mforms and when it reaches the AJAX we get an error.
-        var hidden = form.find(SELECTORS.UNWANTEDHIDDENFIELDS);
-        hidden.each(function() {
-            $(this).remove();
+        }
+    });
+};
+
+/**
+ * Display the modal for this contextId.
+ *
+ * @param {HTMLElement} dynamicTable The table to beb refreshed when changes are made
+ * @param {Number} contextId
+ * @returns {Promise}
+ */
+const showModal = (dynamicTable, contextId) => {
+    return ModalFactory.create({
+        type: ModalFactory.types.SAVE_CANCEL,
+        large: true,
+        title: Str.get_string('enrolusers', 'enrol_manual'),
+        body: getBodyForContext(contextId),
+    })
+    .then(modal => {
+        modal.getRoot().on(ModalEvents.save, e => {
+            // Trigger a form submission, so that any mform elements can do final tricks before the form submission
+            // is processed.
+            // The actual submit even tis captured in the next handler.
+
+            e.preventDefault();
+            modal.getRoot().find('form').submit();
+        });
+
+        modal.getRoot().on('submit', 'form', e => {
+            e.preventDefault();
+
+            submitFormAjax(dynamicTable, modal);
+        });
+
+        modal.getRoot().on(ModalEvents.hidden, () => {
+            modal.destroy();
         });
 
-        var formData = form.serialize();
+        return modal;
+    })
+    .then(modal => {
+        modal.show();
+
+        return modal;
+    })
+    .then(modal => {
+        modal.setSaveButtonText(Str.get_string('enrolusers', 'enrol_manual'));
+
+        modal.getBodyPromise().then(body => {
+            if (body.get(0).querySelector(Selectors.cohortSelector)) {
+                modal.setSaveButtonText(Str.get_string('enroluserscohorts', 'enrol_manual'));
+            }
+
+            return body;
+        })
+        .catch();
+
+        return modal;
+    })
+    .catch(Notification.exception);
+};
+
+/**
+ * Submit the form via ajax.
+ *
+ * @param {HTMLElement} dynamicTable
+ * @param {Object} modal
+ */
+const submitFormAjax = (dynamicTable, modal) => {
+    // Note: We use a jQuery object here so that we can use its serialize functionality.
+    const form = modal.getRoot().find('form');
 
-        this.modal.hide();
+    // Before send the data through AJAX, we need to parse and remove some unwanted hidden fields.
+    // This hidden fields are added automatically by mforms and when it reaches the AJAX we get an error.
+    form.get(0).querySelectorAll(Selectors.unwantedHiddenFields).forEach(hiddenField => hiddenField.remove());
 
-        var settings = {
+    modal.hide();
+    modal.destroy();
+
+    jQuery.ajax(
+        `${Config.wwwroot}/enrol/manual/ajax.php?${form.serialize()}`,
+        {
             type: 'GET',
             processData: false,
-            contentType: "application/json"
-        };
-
-        var script = Config.wwwroot + '/enrol/manual/ajax.php?' + formData;
-        $.ajax(script, settings)
-            .then(function(response) {
-
-                if (response.error) {
-                    Notification.addNotification({
-                        message: response.error,
-                        type: "error"
-                    });
-                } else {
-                    // Reload the page, don't show changed data warnings.
-                    if (typeof window.M.core_formchangechecker !== "undefined") {
-                        window.M.core_formchangechecker.reset_form_dirty_state();
-                    }
-                    window.location.reload();
-                }
-                return;
-            })
-            .fail(Notification.exception);
-    };
-
-    /**
-     * Private method
-     *
-     * @method getBody
-     * @private
-     * @return {Promise}
-     */
-    QuickEnrolment.prototype.getBody = function() {
-        return Fragment.loadFragment('enrol_manual', 'enrol_users_form', this.contextid, {}).fail(Notification.exception);
-    };
-
-    /**
-     * Private method
-     *
-     * @method getFooter
-     * @private
-     * @return {Promise}
-     */
-    QuickEnrolment.prototype.getFooter = function() {
-        return Template.render('enrol_manual/enrol_modal_footer', {});
-    };
-
-    return /** @alias module:enrol_manual/quickenrolment */ {
-        // Public variables and functions.
-        /**
-         * Every call to init creates a new instance of the class with it's own event listeners etc.
-         *
-         * @method init
-         * @public
-         * @param {object} config - config variables for the module.
-         */
-        init: function(config) {
-            (new QuickEnrolment(config));
+            contentType: "application/json",
+        }
+    )
+    .then(response => {
+        if (response.error) {
+            throw new Error(response.error);
         }
-    };
-});
+
+        DynamicTable.refreshTableContent(dynamicTable);
+        return Str.get_string('totalenrolledusers', 'enrol', response.count);
+    })
+    .then(notificationBody => Toast.add(notificationBody))
+    .catch(error => {
+        Notification.addNotification({
+            message: error.message,
+            type: 'error',
+        });
+    });
+};
+
+/**
+ * Set up quick enrolment for the manual enrolment plugin.
+ *
+ * @param {Number} contextid The context id to setup for
+ */
+export const init = ({contextid}) => {
+    registerEventListeners(contextid);
+
+    Prefetch.prefetchStrings('enrol_manual', [
+        'enrolusers',
+        'enroluserscohorts',
+    ]);
+
+    Prefetch.prefetchString('enrol', 'totalenrolledusers');
+};
index f2f3f6c..49e51ba 100644 (file)
@@ -124,7 +124,9 @@ Feature: Teacher can search and enrol users one by one into the course
     And I should see "Student 001"
     And I click on "Enrol users" "button" in the "Enrol users" "dialogue"
     Then I should see "Active" in the "Student 001" "table_row"
-    And I should see "1 enrolled users"
+    # The following line is commented out as auto-hidden toasts fire events in the wrong place.
+    # TODO Uncomment this when we upgrade Bootstrap. This issue is fixed in v4.4.0 - see MDL-67386.
+    #And I should see "1 enrolled users"
 
   @javascript
   Scenario: Searching for a non-existing user
@@ -171,7 +173,7 @@ Feature: Teacher can search and enrol users one by one into the course
     When I log in as "admin"
     Then the following "users" exist:
       | username    | firstname | lastname | email                   | phone1     | phone2     | department | institution | city    | country  |
-      | student100  | Student   | 100      | student100@example.com  | 1234567892 | 1234567893 | ABC1       | ABC2        | CITY1   | UK       |
+      | student100  | Student   | 100      | student100@example.com  | 1234567892 | 1234567893 | ABC1       | ABC2        | CITY1   | GB       |
     And the following config values are set as admin:
       | showuseridentity | idnumber,email,city,country,phone1,phone2,department,institution |
     When I am on "Course 001" course homepage
@@ -179,7 +181,7 @@ Feature: Teacher can search and enrol users one by one into the course
     And I press "Enrol users"
     When I set the field "Select users" to "student100@example.com"
     And I click on ".form-autocomplete-downarrow" "css_element" in the "Select users" "form_row"
-    Then I should see "student100@example.com, CITY1, UK, 1234567892, 1234567893, ABC1, ABC2"
+    Then I should see "student100@example.com, CITY1, GB, 1234567892, 1234567893, ABC1, ABC2"
     # Remove identity field in setting User policies
     And the following config values are set as admin:
       | showuseridentity | idnumber,email,phone1,phone2,department,institution |
index 152fc3b..d617f48 100644 (file)
@@ -47,7 +47,7 @@ class core_grade_report_graderlib_testcase extends advanced_testcase {
         $course = $this->getDataGenerator()->create_course();
 
         // Create and enrol a student.
-        $student = $this->getDataGenerator()->create_user(array('username' => 'Student Sam'));
+        $student = $this->getDataGenerator()->create_user(array('username' => 'student_sam'));
         $role = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
         $this->getDataGenerator()->enrol_user($student->id, $course->id, $role->id);
 
index 94ae292..301b6bd 100644 (file)
@@ -56,11 +56,11 @@ class core_grade_reportuserlib_testcase extends advanced_testcase {
         $coursecontext = context_course::instance($course->id);
 
         // Create and enrol test users.
-        $student = $this->getDataGenerator()->create_user(array('username' => 'Student Sam'));
+        $student = $this->getDataGenerator()->create_user(array('username' => 'student_sam'));
         $role = $DB->get_record('role', array('shortname' => 'student'), '*', MUST_EXIST);
         $this->getDataGenerator()->enrol_user($student->id, $course->id, $role->id);
 
-        $teacher = $this->getDataGenerator()->create_user(array('username' => 'Teacher T'));
+        $teacher = $this->getDataGenerator()->create_user(array('username' => 'teacher_t'));
         $role = $DB->get_record('role', array('shortname' => 'editingteacher'), '*', MUST_EXIST);
         $this->getDataGenerator()->enrol_user($teacher->id, $course->id, $role->id);
 
index ef6f96b..fa6690d 100644 (file)
@@ -98,6 +98,7 @@ The only URL required for verification is [your-site-url]/badges/assertion.php s
 $string['backpackbadgessummary'] = 'You have {$a->totalbadges} badge(s) displayed from {$a->totalcollections} collection(s).';
 $string['backpackbadgessettings'] = 'Change backpack settings';
 $string['backpackcannotsendverification'] = 'Cannot send verification email';
+$string['backpackconnected'] = 'Backpack has been connected';
 $string['backpackconnection'] = 'Backpack connection';
 $string['backpackconnection_help'] = 'Connecting to a backpack enables you to share your badges from this site, and display public badge collections from your backpack on your profile page on this site.';
 $string['backpackconnectioncancelattempt'] = 'Connect using a different email address';
@@ -106,6 +107,7 @@ $string['backpackconnectionresendemail'] = 'Resend verification email';
 $string['backpackconnectionunexpectedresult'] = 'There was a problem connecting to your backpack. Please check the credentials and try again.';
 $string['backpackconnectionunexpectedmessage'] = 'The backpack returned the error: "{$a}".';
 $string['backpackdetails'] = 'Backpack settings';
+$string['backpackdisconnected'] = 'Backpack has been disconnected';
 $string['backpackemail'] = 'Email address';
 $string['backpackemail_help'] = 'The email address associated with your backpack. While you are connected, any badges earned on this site will be associated with this email address.';
 $string['backpackemailverificationpending'] = 'Verification pending';
@@ -125,6 +127,7 @@ $string['backpackemailverifyemailsubject'] = '{$a}: Badges backpack email verifi
 $string['backpackemailverifypending'] = 'A verification email has been sent to <strong>{$a}</strong>. Click on the verification link in the email to activate your Backpack connection.';
 $string['backpackemailverifysuccess'] = 'Thanks for verifying your email address. You are now connected to your backpack.';
 $string['backpackemailverifytokenmismatch'] = 'The token in the link you clicked does not match the stored token. Make sure you clicked the link in most recent email you received.';
+$string['backpackexporterror'] = 'Can\'t export the badge to backpack';
 $string['backpackimport'] = 'Badge import settings';
 $string['backpackimport_help'] = 'After the backpack connection is successfully established, badges from your backpack can be displayed on your badges page and your profile page.
 
@@ -442,8 +445,10 @@ $string['notifyweekly'] = 'Weekly';
 $string['numawards'] = 'This badge has been issued to <a href="{$a->link}">{$a->count}</a> user(s).';
 $string['numawardstat'] = 'This badge has been issued {$a} user(s).';
 $string['overallcrit'] = 'of the selected criteria are complete.';
+$string['oauth2issuer'] = 'OAuth 2 services';
 $string['openbadgesv1'] = 'Open Badges v1.0';
 $string['openbadgesv2'] = 'Open Badges v2.0';
+$string['openbadgesv2p1'] = 'Open Badges v2.1';
 $string['potentialrecipients'] = 'Potential badge recipients';
 $string['preferences'] = 'Badge preferences';
 $string['privacy:metadata:backpack'] = 'A record of user\'s backpacks';
@@ -465,6 +470,12 @@ $string['privacy:metadata:external:backpacks:description'] = 'The description of
 $string['privacy:metadata:external:backpacks:image'] = 'The image of the badge';
 $string['privacy:metadata:external:backpacks:issuer'] = 'Some information about the issuer';
 $string['privacy:metadata:external:backpacks:url'] = 'The Moodle URL where the issued badge information can be seen';
+$string['privacy:metadata:backpackoauth2'] = 'Information oauthorization when user connect to an external backpack';
+$string['privacy:metadata:backpackoauth2:userid'] = 'The ID of the user connect to backpack';
+$string['privacy:metadata:backpackoauth2:usermodified'] = 'The ID of the user modified connect';
+$string['privacy:metadata:backpackoauth2:token'] = 'The token of backpack connect';
+$string['privacy:metadata:backpackoauth2:issuerid'] = 'The ID of Oauth2 service';
+$string['privacy:metadata:backpackoauth2:scope'] = 'List scope of backpack connect';
 $string['privacy:metadata:issued'] = 'A record of badges awarded';
 $string['privacy:metadata:issued:dateexpire'] = 'The date when the badge expires';
 $string['privacy:metadata:issued:dateissued'] = 'The date of the award';
index cea9d7e..6f1c4f9 100644 (file)
@@ -153,6 +153,7 @@ $string['eventtypecategory'] = 'category';
 $string['eventtypecourse'] = 'course';
 $string['eventtypemodule'] = 'module';
 $string['eventtypegroup'] = 'group';
+$string['eventtypeother'] = 'other';
 $string['eventtypeuser'] = 'user';
 $string['hideeventtype'] = 'Hide {$a} events';
 $string['showeventtype'] = 'Show {$a} events';
index c6a8d9a..0a19d43 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['activitychoosercategory'] = 'Activity chooser';
 $string['activitychooserrecommendations'] = 'Recommended activities';
+$string['activitychoosersettings'] = 'Activity chooser settings';
+$string['activitychoosertabmode'] = 'Activity chooser tabs';
+$string['activitychoosertabmode_desc'] = "The activity chooser enables a teacher to easily select activities and resources to add to their course. This setting determines which tabs should be displayed in it. Note that the starred tab is only displayed for a user if they have starred one or more activities and the recommended tab is only displayed if a site administrator has specified some recommended activities.";
+$string['activitychoosertabmodeone'] = 'Starred, All, Activities, Resources, Recommended';
+$string['activitychoosertabmodetwo'] = 'Starred, All, Recommended';
+$string['activitychoosertabmodethree'] = 'Starred, Activities, Resources, Recommended';
 $string['aria:coursecategory'] = 'Course category';
 $string['aria:courseimage'] = 'Course image';
 $string['aria:courseshortname'] = 'Course short name';
index cf78c4c..5a6c463 100644 (file)
@@ -405,6 +405,7 @@ $string['moduledoesnotexist'] = 'This module does not exist';
 $string['moduleinstancedoesnotexist'] = 'The instance of this module does not exist';
 $string['modulemissingcode'] = 'Module {$a} is missing the code needed to perform this function';
 $string['movecatcontentstoroot'] = 'Moving the category content to root is not allowed. You must move the contents to an existing category!';
+$string['movecatcontentstoselected'] = 'Some of the category content can not be moved into selected category.';
 $string['movecategorynotpossible'] = 'You cannot move category \'{$a}\' into the selected category.';
 $string['movecategoryownparent'] = 'You cannot make category \'{$a}\' a parent of itself.';
 $string['movecategoryparentconflict'] = 'You cannot make category \'{$a}\' a subcategory of one of its own subcategories.';
index afa6330..b75e4e1 100644 (file)
@@ -206,6 +206,7 @@ function uninstall_plugin($type, $name) {
 
     // delete calendar events
     $DB->delete_records('event', array('modulename' => $pluginname));
+    $DB->delete_records('event', ['component' => $component]);
 
     // Delete scheduled tasks.
     $DB->delete_records('task_scheduled', array('component' => $component));
index 4fc5e65..6790227 100644 (file)
@@ -115,6 +115,7 @@ define('BADGE_BACKPACKWEBURL', 'https://backpack.openbadges.org');
  */
 define('OPEN_BADGES_V1', 1);
 define('OPEN_BADGES_V2', 2);
+define('OPEN_BADGES_V2P1', 2.1);
 
 /*
  * Only use for Open Badges 2.0 specification
@@ -800,7 +801,8 @@ function badges_update_site_backpack($id, $data) {
         $backpack->apiversion = $data->apiversion;
         $backpack->backpackweburl = $data->backpackweburl;
         $backpack->backpackapiurl = $data->backpackapiurl;
-        $backpack->password = $data->password;
+        $backpack->password = !empty($data->password) ? $data->password : '';
+        $backpack->oauth2_issuerid = !empty($data->oauth2_issuerid) ? $data->oauth2_issuerid : '';
         $DB->update_record('badge_external_backpack', $backpack);
         return true;
     }
@@ -815,7 +817,6 @@ function badges_open_badges_backpack_api() {
     global $CFG;
 
     $backpack = badges_get_site_backpack($CFG->badges_site_backpack);
-
     if (empty($backpack->apiversion)) {
         return OPEN_BADGES_V2;
     }
@@ -861,8 +862,9 @@ function badges_get_site_backpacks() {
  */
 function badges_get_badge_api_versions() {
     return [
-        OPEN_BADGES_V1 => get_string('openbadgesv1', 'badges'),
-        OPEN_BADGES_V2 => get_string('openbadgesv2', 'badges')
+        (string)OPEN_BADGES_V1 => get_string('openbadgesv1', 'badges'),
+        (string)OPEN_BADGES_V2 => get_string('openbadgesv2', 'badges'),
+        (string)OPEN_BADGES_V2P1 => get_string('openbadgesv2p1', 'badges')
     ];
 }
 
@@ -1180,3 +1182,21 @@ function badges_verify_site_backpack() {
     }
     return '';
 }
+
+/**
+ * Get OAuth2 services for the external backpack.
+ *
+ * @return array
+ * @throws coding_exception
+ */
+function badges_get_oauth2_service_options() {
+    global $DB;
+
+    $issuers = core\oauth2\api::get_all_issuers();
+    $options = ['' => 'None'];
+    foreach ($issuers as $issuer) {
+        $options[$issuer->get('id')] = $issuer->get('name');
+    }
+
+    return $options;
+}
index 2aa6049..8b6ca62 100644 (file)
@@ -222,6 +222,15 @@ class behat_core_generator extends behat_generator_base {
                 'required' => array('contextlevel', 'reference', 'contenttype', 'user', 'contentname'),
                 'switchids' => array('user' => 'userid')
             ],
+            'badge external backpack' => [
+                'datagenerator' => 'badge_external_backpack',
+                'required' => ['backpackapiurl', 'backpackweburl', 'apiversion']
+            ],
+            'setup backpack connected' => [
+                'datagenerator' => 'setup_backpack_connected',
+                'required' => ['user', 'externalbackpack'],
+                'switchids' => ['user' => 'userid', 'externalbackpack' => 'externalbackpackid']
+            ]
         ];
     }
 
@@ -869,4 +878,56 @@ class behat_core_generator extends behat_generator_base {
             throw new Exception('The specified "' . $data['contenttype'] . '" contenttype does not exist');
         }
     }
+
+    /**
+     * Create a exetrnal backpack.
+     *
+     * @param array $data
+     */
+    protected function process_badge_external_backpack(array $data) {
+        global $DB;
+        $DB->insert_record('badge_external_backpack', $data, true);
+    }
+
+    /**
+     * Setup a backpack connected for user.
+     *
+     * @param array $data
+     * @throws dml_exception
+     */
+    protected function process_setup_backpack_connected(array $data) {
+        global $DB;
+
+        if (empty($data['userid'])) {
+            throw new Exception('\'setup backpack connected\' requires the field \'user\' to be specified');
+        }
+        if (empty($data['externalbackpackid'])) {
+            throw new Exception('\'setup backpack connected\' requires the field \'externalbackpack\' to be specified');
+        }
+        // Dummy badge_backpack_oauth2 data.
+        $timenow = time();
+        $backpackoauth2 = new stdClass();
+        $backpackoauth2->usermodified = $data['userid'];
+        $backpackoauth2->timecreated = $timenow;
+        $backpackoauth2->timemodified = $timenow;
+        $backpackoauth2->userid = $data['userid'];
+        $backpackoauth2->issuerid = 1;
+        $backpackoauth2->externalbackpackid = $data['externalbackpackid'];
+        $backpackoauth2->token = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
+        $backpackoauth2->refreshtoken = '0123456789abcdefghijk';
+        $backpackoauth2->expires = $timenow + 3600;
+        $backpackoauth2->scope = 'https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.create';
+        $backpackoauth2->scope .= ' https://purl.imsglobal.org/spec/ob/v2p1/scope/assertion.readonly offline_access';
+        $DB->insert_record('badge_backpack_oauth2', $backpackoauth2);
+
+        // Dummy badge_backpack data.
+        $backpack = new stdClass();
+        $backpack->userid = $data['userid'];
+        $backpack->email = 'student@behat.moodle';
+        $backpack->backpackuid = 0;
+        $backpack->autosync = 0;
+        $backpack->password = '';
+        $backpack->externalbackpackid = $data['externalbackpackid'];
+        $DB->insert_record('badge_backpack', $backpack);
+    }
 }
index 98d16bc..107c165 100644 (file)
@@ -524,4 +524,18 @@ abstract class behat_generator_base {
         }
         return $id;
     }
+
+    /**
+     * Gets the external backpack id from it's backpackweburl.
+     * @param string $backpackweburl
+     * @return mixed
+     * @throws dml_exception
+     */
+    protected function get_externalbackpack_id($backpackweburl) {
+        global $DB;
+        if (!$id = $DB->get_field('badge_external_backpack', 'id', ['backpackweburl' => $backpackweburl])) {
+            throw new Exception('The specified external backpack with backpackweburl "' . $username . '" does not exist');
+        }
+        return $id;
+    }
 }
index 2e5c73a..bac3029 100644 (file)
@@ -33,6 +33,7 @@ defined('MOODLE_INTERNAL') || die();
  *      Extra information about event.
  *
  *      - string name: category name.
+ *      - string contentmovedcategoryid: (optional) category id where content was moved on deletion
  * }
  *
  * @package    core
@@ -71,7 +72,11 @@ class course_category_deleted extends base {
      * @return string
      */
     public function get_description() {
-        return "The user with id '$this->userid' deleted the course category with id '$this->objectid'.";
+        $descr = "The user with id '$this->userid' deleted the course category with id '$this->objectid'.";
+        if (!empty($this->other['contentmovedcategoryid'])) {
+            $descr .= " Its content has been moved to category with id '{$this->other['contentmovedcategoryid']}'.";
+        }
+        return $descr;
     }
 
     /**
index 6c1f4d5..2389dc4 100644 (file)
@@ -288,6 +288,7 @@ class icon_system_fontawesome extends icon_system_font {
             'core:i/nosubcat' => 'fa-plus-square-o',
             'core:i/notifications' => 'fa-bell',
             'core:i/open' => 'fa-folder-open',
+            'core:i/otherevent' => 'fa-calendar',
             'core:i/outcomes' => 'fa-tasks',
             'core:i/payment' => 'fa-money',
             'core:i/permissionlock' => 'fa-lock',
index f70bfbd..40639c2 100644 (file)
@@ -52,6 +52,8 @@ class redis extends handler {
     protected $prefix = '';
     /** @var int $acquiretimeout how long to wait for session lock in seconds */
     protected $acquiretimeout = 120;
+    /** @var int $lockretry how long to wait between session lock attempts in ms */
+    protected $lockretry = 100;
     /** @var int $serializer The serializer to use */
     protected $serializer = \Redis::SERIALIZER_PHP;
     /**
@@ -99,6 +101,10 @@ class redis extends handler {
             $this->acquiretimeout = (int)$CFG->session_redis_acquire_lock_timeout;
         }
 
+        if (isset($CFG->session_redis_acquire_lock_retry)) {
+            $this->lockretry = (int)$CFG->session_redis_acquire_lock_retry;
+        }
+
         if (!empty($CFG->session_redis_serializer_use_igbinary) && defined('\Redis::SERIALIZER_IGBINARY')) {
             $this->serializer = \Redis::SERIALIZER_IGBINARY; // Set igbinary serializer if phpredis supports it.
         }
@@ -393,7 +399,21 @@ class redis extends handler {
                 throw new exception("Unable to obtain session lock");
             }
 
-            usleep(rand(100000, 1000000));
+            if ($this->time() < $startlocktime + 5) {
+                // We want a random delay to stagger the polling load. Ideally
+                // this delay should be a fraction of the average response
+                // time. If it is too small we will poll too much and if it is
+                // too large we will waste time waiting for no reason. 100ms is
+                // the default starting point.
+                $delay = rand($this->lockretry, $this->lockretry * 1.1);
+            } else {
+                // If we don't get a lock within 5 seconds then there must be a
+                // very long lived process holding the lock so throttle back to
+                // just polling roughly once a second.
+                $delay = rand(1000, 1100);
+            }
+
+            usleep($delay * 1000);
         }
     }
 
index 0fd662a..2bc9085 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20200415" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200504" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="groupid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="repeatid" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="component" TYPE="char" LENGTH="100" NOTNULL="false" SEQUENCE="false" COMMENT="Component that created this event, if specified, only component itself can edit and delete it"/>
         <FIELD NAME="modulename" TYPE="char" LENGTH="20" NOTNULL="true" SEQUENCE="false"/>
         <FIELD NAME="instance" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="type" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <INDEX NAME="groupid-courseid-categoryid-visible-userid" UNIQUE="false" FIELDS="groupid, courseid, categoryid, visible, userid" COMMENT="used for calendar view"/>
         <INDEX NAME="eventtype" UNIQUE="false" FIELDS="eventtype"/>
         <INDEX NAME="modulename-instance" UNIQUE="false" FIELDS="modulename, instance"/>
+        <INDEX NAME="component" UNIQUE="false" FIELDS="component, eventtype, instance"/>
       </INDEXES>
     </TABLE>
     <TABLE NAME="cache_filters" COMMENT="For keeping information about cached data">
         <KEY NAME="externalbackpack" TYPE="foreign" FIELDS="externalbackpackid" REFTABLE="badge_external_backpack" REFFIELDS="id"/>
       </KEYS>
     </TABLE>
+    <TABLE NAME="badge_backpack_oauth2" COMMENT="Default comment for the table, please edit me">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="usermodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="timemodified" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="issuerid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="externalbackpackid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="token" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="refreshtoken" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="expires" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="scope" TYPE="text" NOTNULL="false" SEQUENCE="false"/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="usermodified" TYPE="foreign" FIELDS="usermodified" REFTABLE="user" REFFIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="foreign (userid) references user (id)"/>
+        <KEY NAME="issuerid" TYPE="foreign" FIELDS="issuerid" REFTABLE="oauth2_issuer" REFFIELDS="id" COMMENT="foreign (issuerid) references oauth2_issuer (id)"/>
+        <KEY NAME="externalbackpackid" TYPE="foreign" FIELDS="externalbackpackid" REFTABLE="badge_external_backpack" REFFIELDS="id" COMMENT="foreign (externalbackpackid) references badge_external_backpack(id)"/>
+      </KEYS>
+    </TABLE>
     <TABLE NAME="badge_external" COMMENT="Setting for external badges display">
       <FIELDS>
         <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
         <FIELD NAME="backpackid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="ID of a backpack"/>
         <FIELD NAME="collectionid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="Badge collection id in the backpack"/>
         <FIELD NAME="entityid" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="assertion" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Assertion of external badge"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="apiversion" TYPE="char" LENGTH="12" NOTNULL="true" DEFAULT="1.0" SEQUENCE="false"/>
         <FIELD NAME="sortorder" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="password" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Password to login into external backpack and issue badges."/>
+        <FIELD NAME="oauth2_issuerid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="OAuth 2 Issuer"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <KEY NAME="backpackapiurlkey" TYPE="unique" FIELDS="backpackapiurl"/>
         <KEY NAME="backpackweburlkey" TYPE="unique" FIELDS="backpackweburl"/>
+        <KEY NAME="backpackoauth2key" TYPE="foreign" FIELDS="oauth2_issuerid" REFTABLE="oauth2_issuer" REFFIELDS="id" COMMENT="foreign (oauth2_issuerid) references oauth2_issuer (id)"/>
       </KEYS>
     </TABLE>
     <TABLE NAME="user_devices" COMMENT="This table stores user's mobile devices information in order to send PUSH notifications">
index 0b5f9e4..1f5796e 100644 (file)
@@ -2314,5 +2314,83 @@ function xmldb_main_upgrade($oldversion) {
 
         upgrade_main_savepoint(true, 2020042800.01);
     }
+
+    if ($oldversion < 2020051900.01) {
+        // Define field component to be added to event.
+        $table = new xmldb_table('event');
+        $field = new xmldb_field('component', XMLDB_TYPE_CHAR, '100', null, null, null, null, 'repeatid');
+
+        // Conditionally launch add field component.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define index component (not unique) to be added to event.
+        $table = new xmldb_table('event');
+        $index = new xmldb_index('component', XMLDB_INDEX_NOTUNIQUE, ['component', 'eventtype', 'instance']);
+
+        // Conditionally launch add index component.
+        if (!$dbman->index_exists($table, $index)) {
+            $dbman->add_index($table, $index);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020051900.01);
+    }
+
+    if ($oldversion < 2020052000.00) {
+        // Define table badge_backpack_oauth2 to be created.
+        $table = new xmldb_table('badge_backpack_oauth2');
+
+        // Adding fields to table badge_backpack_oauth2.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('usermodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('timemodified', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('issuerid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('externalbackpackid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('token', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('refreshtoken', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('expires', XMLDB_TYPE_INTEGER, '10', null, null, null, null);
+        $table->add_field('scope', XMLDB_TYPE_TEXT, null, null, null, null, null);
+
+        // Adding keys to table badge_backpack_oauth2.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('usermodified', XMLDB_KEY_FOREIGN, ['usermodified'], 'user', ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+        $table->add_key('issuerid', XMLDB_KEY_FOREIGN, ['issuerid'], 'oauth2_issuer', ['id']);
+        $table->add_key('externalbackpackid', XMLDB_KEY_FOREIGN, ['externalbackpackid'], 'badge_external_backpack', ['id']);
+        // Conditionally launch create table for badge_backpack_oauth2.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+
+        // Define field oauth2_issuerid to be added to badge_external_backpack.
+        $tablebadgeexternalbackpack = new xmldb_table('badge_external_backpack');
+        $fieldoauth2issuerid = new xmldb_field('oauth2_issuerid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'password');
+        $keybackpackoauth2key = new xmldb_key('backpackoauth2key', XMLDB_KEY_FOREIGN, ['oauth2_issuerid'], 'oauth2_issuer', ['id']);
+
+        // Conditionally launch add field oauth2_issuerid.
+        if (!$dbman->field_exists($tablebadgeexternalbackpack, $fieldoauth2issuerid)) {
+            $dbman->add_field($tablebadgeexternalbackpack, $fieldoauth2issuerid);
+
+            // Launch add key backpackoauth2key.
+            $dbman->add_key($tablebadgeexternalbackpack, $keybackpackoauth2key);
+        }
+
+        // Define field assertion to be added to badge_external.
+        $tablebadgeexternal = new xmldb_table('badge_external');
+        $fieldassertion = new xmldb_field('assertion', XMLDB_TYPE_TEXT, null, null, null, null, null, 'entityid');
+
+        // Conditionally launch add field assertion.
+        if (!$dbman->field_exists($tablebadgeexternal, $fieldassertion)) {
+            $dbman->add_field($tablebadgeexternal, $fieldassertion);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2020052000.00);
+    }
+
     return true;
 }
index f181ca3..c304197 100644 (file)
@@ -67,5 +67,40 @@ function xmldb_editor_atto_upgrade($oldversion) {
     // Automatically generated Moodle v3.8.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2020052100) {
+        // The old default toolbar config for 38 and below.
+        $oldtoolbar = 'collapse = collapse
+style1 = title, bold, italic
+list = unorderedlist, orderedlist
+links = link
+files = image, media, recordrtc, managefiles, h5p
+style2 = underline, strike, subscript, superscript
+align = align
+indent = indent
+insert = equation, charmap, table, clear
+undo = undo
+accessibility = accessibilitychecker, accessibilityhelper
+other = html';
+
+        // Check if the current toolbar config matches the old toolbar config.
+        if (get_config('editor_atto', 'toolbar') === $oldtoolbar) {
+            // If the site is still using the old defaults, upgrade to the new default.
+            $newtoolbar = 'collapse = collapse
+style1 = title, bold, italic
+list = unorderedlist, orderedlist, indent
+links = link
+files = emojipicker, image, media, recordrtc, managefiles, h5p
+style2 = underline, strike, subscript, superscript
+align = align
+insert = equation, charmap, table, clear
+undo = undo
+accessibility = accessibilitychecker, accessibilityhelper
+other = html';
+            set_config('toolbar', $newtoolbar, 'editor_atto');
+        }
+
+        upgrade_plugin_savepoint(true, 2020052100, 'editor', 'atto');
+    }
+
     return true;
 }
index 58508c4..13a1c7a 100644 (file)
@@ -34,12 +34,11 @@ if ($ADMIN->fulltree) {
     $desc = new lang_string('toolbarconfig_desc', 'editor_atto');
     $default = 'collapse = collapse
 style1 = title, bold, italic
-list = unorderedlist, orderedlist
+list = unorderedlist, orderedlist, indent
 links = link
 files = emojipicker, image, media, recordrtc, managefiles, h5p
 style2 = underline, strike, subscript, superscript
 align = align
-indent = indent
 insert = equation, charmap, table, clear
 undo = undo
 accessibility = accessibilitychecker, accessibilityhelper
index 904c882..1086322 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2020051200;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2020052100;        // 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 c1feb3e..d0453e2 100644 (file)
@@ -32,7 +32,7 @@
     <div class="col-12 pt-3 pb-3">
         <div class="card {{^contextheader}}border-0 bg-transparent{{/contextheader}}">
             <div class="card-body {{^contextheader}}p-2{{/contextheader}}">
-                <div class="d-flex align-items-center">
+                <div class="d-sm-flex align-items-center">
                     {{#contextheader}}
                     <div class="mr-auto">
                         {{{contextheader}}}
index 7c15f6d..ba4376d 100644 (file)
@@ -64,8 +64,8 @@
     }
 }}
 <nav aria-label="{{#str}}breadcrumb, access{{/str}}">
-    <ol class="breadcrumb">
-        {{#get_items}}
+    <ol class="breadcrumb">{{!
+        }}{{#get_items}}
             {{#has_action}}
                 <li class="breadcrumb-item{{#is_hidden}} dimmed_text{{/is_hidden}}">
                     <a href="{{{action}}}" {{#is_last}}aria-current="page"{{/is_last}} {{#get_title}}title="{{get_title}}"{{/get_title}}>{{{get_content}}}</a>
@@ -74,6 +74,6 @@
             {{^has_action}}
                 <li class="breadcrumb-item{{#is_hidden}} dimmed_text{{/is_hidden}}">{{{text}}}</li>
             {{/has_action}}
-        {{/get_items}}
-    </ol>
+        {{/get_items}}{{!
+    }}</ol>
 </nav>
index 676bd60..6b6566e 100644 (file)
@@ -28,6 +28,7 @@
         "get_content": "Edit profile"
     }
 }}
+<div class="container-fluid p-sm-0">
     <div class="row">
         {{#groups}}
             <div class="col-md-4">
@@ -44,3 +45,4 @@
             </div>
         {{/groups}}
     </div>
+</div>
index 2e46b8b..c8e031d 100644 (file)
@@ -142,6 +142,7 @@ EOD;
      */
     public function create_user($record=null, array $options=null) {
         global $DB, $CFG;
+        require_once($CFG->dirroot.'/user/lib.php');
 
         $this->usercounter++;
         $i = $this->usercounter;
@@ -206,10 +207,6 @@ EOD;
 
         if (isset($record['password'])) {
             $record['password'] = hash_internal_user_password($record['password']);
-        } else {
-            // The auth plugin may not fully support this,
-            // but it is still better/faster than hashing random stuff.
-            $record['password'] = AUTH_PASSWORD_NOT_CACHED;
         }
 
         if (!isset($record['email'])) {
@@ -220,71 +217,41 @@ EOD;
             $record['confirmed'] = 1;
         }
 
-        if (!isset($record['lang'])) {
-            $record['lang'] = 'en';
-        }
-
-        if (!isset($record['maildisplay'])) {
-            $record['maildisplay'] = $CFG->defaultpreference_maildisplay;
-        }
-
-        if (!isset($record['mailformat'])) {
-            $record['mailformat'] = $CFG->defaultpreference_mailformat;
-        }
-
-        if (!isset($record['maildigest'])) {
-            $record['maildigest'] = $CFG->defaultpreference_maildigest;
-        }
-
-        if (!isset($record['autosubscribe'])) {
-            $record['autosubscribe'] = $CFG->defaultpreference_autosubscribe;
-        }
-
-        if (!isset($record['trackforums'])) {
-            $record['trackforums'] = $CFG->defaultpreference_trackforums;
-        }
-
-        if (!isset($record['deleted'])) {
-            $record['deleted'] = 0;
-        }
-
-        if (!isset($record['timecreated'])) {
-            $record['timecreated'] = time();
-        }
-
-        $record['timemodified'] = $record['timecreated'];
-
         if (!isset($record['lastip'])) {
             $record['lastip'] = '0.0.0.0';
         }
 
-        if ($record['deleted']) {
-            $delname = $record['email'].'.'.time();
-            while ($DB->record_exists('user', array('username'=>$delname))) {
-                $delname++;
-            }
-            $record['idnumber'] = '';
-            $record['email']    = md5($record['username']);
-            $record['username'] = $delname;
-            $record['picture']  = 0;
-        }
+        $tobedeleted = !empty($record['deleted']);
+        unset($record['deleted']);
 
-        $userid = $DB->insert_record('user', $record);
+        $userid = user_create_user($record, false, false);
 
-        if (!$record['deleted']) {
-            context_user::instance($userid);
+        if ($extrafields = array_intersect_key($record, ['password' => 1, 'timecreated' => 1])) {
+            $DB->update_record('user', ['id' => $userid] + $extrafields);
+        }
 
+        if (!$tobedeleted) {
             // All new not deleted users must have a favourite self-conversation.
             $selfconversation = \core_message\api::create_conversation(
                 \core_message\api::MESSAGE_CONVERSATION_TYPE_SELF,
                 [$userid]
             );
             \core_message\api::set_favourite_conversation($selfconversation->id, $userid);
+
+            // Save custom profile fields data.
+            $hasprofilefields = array_filter($record, function($key){
+                return strpos($key, 'profile_field_') === 0;
+            }, ARRAY_FILTER_USE_KEY);
+            if ($hasprofilefields) {
+                require_once($CFG->dirroot.'/user/profile/lib.php');
+                $usernew = (object)(['id' => $userid] + $record);
+                profile_save_data($usernew);
+            }
         }
 
         $user = $DB->get_record('user', array('id' => $userid), '*', MUST_EXIST);
 
-        if (!$record['deleted'] && isset($record['interests'])) {
+        if (!$tobedeleted && isset($record['interests'])) {
             require_once($CFG->dirroot . '/user/editlib.php');
             if (!is_array($record['interests'])) {
                 $record['interests'] = preg_split('/\s*,\s*/', trim($record['interests']), -1, PREG_SPLIT_NO_EMPTY);
@@ -292,6 +259,12 @@ EOD;
             useredit_update_interests($user, $record['interests']);
         }
 
+        \core\event\user_created::create_from_userid($userid)->trigger();
+
+        if ($tobedeleted) {
+            delete_user($user);
+            $user = $DB->get_record('user', array('id' => $userid));
+        }
         return $user;
     }
 
index d22a910..2d9990f 100644 (file)
@@ -70,7 +70,6 @@ class core_test_generator_testcase extends advanced_testcase {
         $this->assertEquals($count + 1, $DB->count_records('user'));
         $this->assertSame($user->username, core_user::clean_field($user->username, 'username'));
         $this->assertSame($user->email, core_user::clean_field($user->email, 'email'));
-        $this->assertSame(AUTH_PASSWORD_NOT_CACHED, $user->password);
         $this->assertNotEmpty($user->firstnamephonetic);
         $this->assertNotEmpty($user->lastnamephonetic);
         $this->assertNotEmpty($user->alternatename);
@@ -97,7 +96,6 @@ class core_test_generator_testcase extends advanced_testcase {
             'password' => 'password1',
             'email' => 'email@example.com',
             'confirmed' => '1',
-            'lang' => 'cs',
             'maildisplay' => '1',
             'mailformat' => '0',
             'maildigest' => '1',
@@ -128,7 +126,7 @@ class core_test_generator_testcase extends advanced_testcase {
         $this->assertEquals($count + 3, $DB->count_records('user'));
         $this->assertSame('', $user->idnumber);
         $this->assertSame(md5($record['username']), $user->email);
-        $this->assertFalse(context_user::instance($user->id, IGNORE_MISSING));
+        $this->assertEquals(1, $user->deleted);
 
         // Test generating user with interests.
         $user = $generator->create_user(array('interests' => 'Cats, Dogs'));
index dcf3646..0382f72 100644 (file)
@@ -2749,8 +2749,6 @@ class core_accesslib_testcase extends advanced_testcase {
             $bi = $generator->create_block('online_users', array('parentcontextid'=>$usercontext->id));
             $testblocks[] = $bi->id;
         }
-        // Deleted user - should be ignored everywhere, can not have context.
-        $generator->create_user(array('deleted'=>1));
 
         // Add block to frontpage.
         $bi = $generator->create_block('online_users', array('parentcontextid'=>$frontpagecontext->id));
index acc7e53..3cf66de 100644 (file)
@@ -47,17 +47,18 @@ class contentbank_content_viewed_testcase extends \advanced_testcase {
     }
 
     /**
-     * Test the content updated event.
+     * Test the content viewed event.
      *
      * @covers ::create_from_record
      */
-    public function test_content_updated() {
+    public function test_content_viewed() {
 
         $this->resetAfterTest();
         $this->setAdminUser();
 
         // Save the system context.
         $systemcontext = \context_system::instance();
+        $contenttype = new \contenttype_testable\contenttype();
 
         // Create a content bank content.
         $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
@@ -66,8 +67,8 @@ class contentbank_content_viewed_testcase extends \advanced_testcase {
 
         // Trigger and capture the content viewed event.
         $sink = $this->redirectEvents();
-        $eventtotrigger = \core\event\contentbank_content_viewed::create_from_record($content->get_content());
-        $eventtotrigger->trigger();
+        $result = $contenttype->get_view_content($content);
+        $this->assertEmpty($result);
 
         $events = $sink->get_events();
         $event = reset($events);
@@ -75,5 +76,6 @@ class contentbank_content_viewed_testcase extends \advanced_testcase {
         // Check that the event data is valid.
         $this->assertInstanceOf('\core\event\contentbank_content_viewed', $event);
         $this->assertEquals($systemcontext, $event->get_context());
+        $this->assertEquals($content->get_id(), $event->objectid);
     }
 }
index fbfbbb6..1648a8f 100644 (file)
@@ -2862,10 +2862,12 @@ class core_moodlelib_testcase extends advanced_testcase {
      * the user table and fire event.
      */
     public function test_update_internal_user_password_no_cache() {
+        global $DB;
         $this->resetAfterTest();
 
         $user = $this->getDataGenerator()->create_user(array('auth' => 'cas'));
-        $this->assertEquals(AUTH_PASSWORD_NOT_CACHED, $user->password);
+        $DB->update_record('user', ['id' => $user->id, 'password' => AUTH_PASSWORD_NOT_CACHED]);
+        $user->password = AUTH_PASSWORD_NOT_CACHED;
 
         $sink = $this->redirectEvents();
         update_internal_user_password($user, 'wonkawonka');
@@ -3594,7 +3596,7 @@ class core_moodlelib_testcase extends advanced_testcase {
         $user = $this->getDataGenerator()->create_user(
             [
                 "username" => $username,
-                "confirmed" => false,
+                "confirmed" => 0,
                 "email" => 'test@example.com',
             ]
         );
@@ -3623,7 +3625,7 @@ class core_moodlelib_testcase extends advanced_testcase {
         $user = $this->getDataGenerator()->create_user(
             [
                 "username" => "many_-.@characters@_@-..-..",
-                "confirmed" => false,
+                "confirmed" => 0,
                 "email" => 'test@example.com',
             ]
         );
index 0a631aa..a5e1515 100644 (file)
@@ -140,11 +140,17 @@ class core_outputcomponents_testcase extends advanced_testcase {
         $user2 = $this->getDataGenerator()->create_user(array('picture'=>0, 'email'=>'user2@example.com'));
         $context2 = context_user::instance($user2->id);
 
+        // User 3 is deleted.
         $user3 = $this->getDataGenerator()->create_user(array('picture'=>1, 'deleted'=>1, 'email'=>'user3@example.com'));
-        $context3 = context_user::instance($user3->id, IGNORE_MISSING);
+        $this->assertNotEmpty(context_user::instance($user3->id));
         $this->assertEquals(0, $user3->picture);
         $this->assertNotEquals('user3@example.com', $user3->email);
-        $this->assertFalse($context3);
+
+        // User 4 is incorrectly deleted with its context deleted as well (testing legacy code).
+        $user4 = $this->getDataGenerator()->create_user(['picture' => 1, 'deleted' => 1, 'email' => 'user4@example.com']);
+        context_helper::delete_instance(CONTEXT_USER, $user4->id);
+        $this->assertEquals(0, $user4->picture);
+        $this->assertNotEquals('user4@example.com', $user4->email);
 
         // Try legacy picture == 1.
         $user1->picture = 1;
@@ -186,13 +192,14 @@ class core_outputcomponents_testcase extends advanced_testcase {
         $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up3->get_url($page, $renderer)->out(false));
         $this->assertEquals($reads, $DB->perf_get_reads());
 
-        // Try incorrectly deleted users (with valid email and pciture flag) - some DB reads expected.
-        $user3->email = 'user3@example.com';
-        $user3->picture = 1;
+        // Try incorrectly deleted users (with valid email and picture flag, but user context removed) - some DB reads expected.
+        unset($user4->deleted);
+        $user4->email = 'user4@example.com';
+        $user4->picture = 1;
         $reads = $DB->perf_get_reads();
-        $up3 = new user_picture($user3);
+        $up4 = new user_picture($user4);
         $this->assertEquals($reads, $DB->perf_get_reads());
-        $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up3->get_url($page, $renderer)->out(false));
+        $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up4->get_url($page, $renderer)->out(false));
         $this->assertGreaterThan($reads, $DB->perf_get_reads());
 
         // Test gravatar.
@@ -203,6 +210,10 @@ class core_outputcomponents_testcase extends advanced_testcase {
         $user3->picture = 0;
         $up3 = new user_picture($user3);
         $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up3->get_url($page, $renderer)->out(false));
+        $user4->email = 'deleted';
+        $user4->picture = 0;
+        $up4 = new user_picture($user4);
+        $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up4->get_url($page, $renderer)->out(false));
 
         // Http version.
         $CFG->wwwroot = str_replace('https:', 'http:', $CFG->wwwroot);
@@ -237,11 +248,14 @@ class core_outputcomponents_testcase extends advanced_testcase {
         $up1 = new user_picture($user1);
         $this->assertSame($CFG->wwwroot.'/pluginfile.php/'.$context1->id.'/user/icon/boost/f2?rev=11', $up1->get_url($page, $renderer)->out(false));
 
+        $up2 = new user_picture($user2);
+        $this->assertSame('https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?s=35&d=https%3A%2F%2Fwww.example.com%2Fmoodle%2Fpix%2Fu%2Ff2.png', $up2->get_url($page, $renderer)->out(false));
+
         $up3 = new user_picture($user3);
         $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up3->get_url($page, $renderer)->out(false));
 
-        $up2 = new user_picture($user2);
-        $this->assertSame('https://secure.gravatar.com/avatar/ab53a2911ddf9b4817ac01ddcd3d975f?s=35&d=https%3A%2F%2Fwww.example.com%2Fmoodle%2Fpix%2Fu%2Ff2.png', $up2->get_url($page, $renderer)->out(false));
+        $up4 = new user_picture($user4);
+        $this->assertSame($CFG->wwwroot.'/theme/image.php/boost/core/1/u/f2', $up4->get_url($page, $renderer)->out(false));
 
         // TODO MDL-44792 Rewrite those tests to use a fixture.
         // Now test gravatar with one theme having own images (afterburner).
index c6e0d0d..4e79b93 100644 (file)
@@ -71,6 +71,21 @@ information provided here is intended especially for developers.
   - cron_execute_plugin_type()
   - cron_bc_hack_plugin_functions()
   Please, use the Task API instead: https://docs.moodle.org/dev/Task_API
+* Introduce new hooks for plugin developers:
+    - <component>_can_course_category_delete($category)
+    - <component>_can_course_category_delete_move($category, $newcategory)
+  These hooks allow plugin developers greater control over category deletion. Plugin can return false in those
+  functions if category deletion or deletion with content move to the new parent category is not permitted.
+  Both $category and $newcategory params are instances of core_course_category class.
+    - <component>_pre_course_category_delete_move($category, $newcategory)
+  This hook is expanding functionality of existing <component>_pre_course_category_delete hook and allow plugin developers
+  to execute code prior to category deletion when its content is moved to another category.
+  Both $category and $newcategory params are instances of core_course_category class.
+    - <component>_get_course_category_contents($category)
+  This hook allow plugin developers to add information that is displayed on category deletion form. Function should
+  return string, which will be added to the list of category contents shown on the form. $category param is an instance
+  of core_course_category class.
+* Data generator create_user in both unittests and behat now validates user fields and triggers user_created event
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index f76d054..bcb430f 100644 (file)
Binary files a/message/amd/build/message_drawer.min.js and b/message/amd/build/message_drawer.min.js differ
index ee1a5a9..fad7113 100644 (file)
Binary files a/message/amd/build/message_drawer.min.js.map and b/message/amd/build/message_drawer.min.js.map differ
index ae7ebac..c36f8b0 100644 (file)
@@ -73,6 +73,7 @@ function(
         HEADER_CONTAINER: '[data-region="header-container"]',
         BODY_CONTAINER: '[data-region="body-container"]',
         FOOTER_CONTAINER: '[data-region="footer-container"]',
+        CLOSE_BUTTON: '[data-action="closedrawer"]'
     };
 
     /**
@@ -295,6 +296,11 @@ function(
             Router.go(namespace, Routes.VIEW_CONVERSATION, args.conversationid);
         });
 
+        var closebutton = root.find(SELECTORS.CLOSE_BUTTON);
+        closebutton.on(CustomEvents.events.activate, function() {
+            PubSub.publish(Events.TOGGLE_VISIBILITY);
+        });
+
         PubSub.subscribe(Events.CREATE_CONVERSATION_WITH_USER, function(args) {
             setJumpFrom(args.buttonid);
             show(namespace, root);
index 5b8e46a..215cf15 100644 (file)
 {{< core/drawer}}
     {{$drawercontent}}
         <div id="message-drawer-{{uniqid}}" class="message-app" data-region="message-drawer" role="region">
+            <div class="closewidget bg-light border-bottom text-right">
+                <a class="text-dark" data-action="closedrawer" href="#">
+                     {{#pix}} i/window_close, core, {{#str}} closebuttontitle {{/str}} {{/pix}}
+                </a>
+            </div>
             <div class="header-container position-relative" data-region="header-container">
                 {{> core_message/message_drawer_view_contacts_header }}
                 {{> core_message/message_drawer_view_conversation_header }}
index 722a75b..c70611b 100644 (file)
     data-region="view-conversation"
     data-enter-to-send="{{settings.entertosend}}"
 >
-    <div class="hidden p-2" data-region="content-messages-footer-container">
+    <div class="hidden p-sm-2" data-region="content-messages-footer-container">
         {{> core_message/message_drawer_view_conversation_footer_content }}
     </div>
-    <div class="hidden p-2" data-region="content-messages-footer-edit-mode-container">
+    <div class="hidden p-sm-2" data-region="content-messages-footer-edit-mode-container">
         {{> core_message/message_drawer_view_conversation_footer_edit_mode }}
     </div>
-    <div class="hidden bg-secondary p-3" data-region="content-messages-footer-require-contact-container">
+    <div class="hidden bg-secondary p-sm-3" data-region="content-messages-footer-require-contact-container">
         {{> core_message/message_drawer_view_conversation_footer_require_contact }}
     </div>
-    <div class="hidden bg-secondary p-3" data-region="content-messages-footer-require-unblock-container">
+    <div class="hidden bg-secondary p-sm-3" data-region="content-messages-footer-require-unblock-container">
         {{> core_message/message_drawer_view_conversation_footer_require_unblock }}
     </div>
-    <div class="hidden bg-secondary p-3" data-region="content-messages-footer-unable-to-message">
+    <div class="hidden bg-secondary p-sm-3" data-region="content-messages-footer-unable-to-message">
         {{> core_message/message_drawer_view_conversation_footer_unable_to_message }}
     </div>
-    <div class="p-2" data-region="placeholder-container">
+    <div class="p-sm-2" data-region="placeholder-container">
         {{> core_message/message_drawer_view_conversation_footer_placeholder }}
     </div>
     <div
index 4c83458..1098c03 100644 (file)
@@ -44,7 +44,7 @@
     >
     </div>
 {{/showemojipicker}}
-<div class="d-flex mt-1">
+<div class="d-flex mt-sm-1">
     <textarea
         dir="auto"
         data-region="send-message-txt"
index 66f84bf..8a2d001 100644 (file)
@@ -35,7 +35,7 @@
 }}
 
 <div
-    class="hidden bg-white position-relative border-bottom px-2 py-2"
+    class="hidden bg-white position-relative border-bottom p-1 p-sm-2"
     aria-hidden="true"
     data-region="view-conversation"
 >
index e7ce405..6ba0f0f 100644 (file)
@@ -55,7 +55,7 @@
     {{#subname}}<h3 class="mt-2 mb-0 text-center text-truncate h5">{{.}}</h3>{{/subname}}
 </div>
 <h3 class="border-bottom h6 mt-3 px-3 py-2 mb-0 font-weight-bold">{{#str}} otherparticipants, core_message {{/str}}</h3>
-<div class="pt-1 bg-white" data-region="members-list-container" style="overflow-y: auto">
+<div class="pt-1 bg-white overflow-y" data-region="members-list-container">
     {{< core_message/message_drawer_lazy_load_list }}
         {{$rootattributes}}
             data-region="members-list"
index 306759f..8d4b9a4 100644 (file)
@@ -33,7 +33,7 @@
     {}
 
 }}
-<div class="border-bottom px-2 py-3" aria-hidden="false" {{^isdrawer}}data-in-panel="true"{{/isdrawer}} data-region="view-overview">
+<div class="border-bottom  p-1 px-sm-2 py-sm-3" aria-hidden="false" {{^isdrawer}}data-in-panel="true"{{/isdrawer}} data-region="view-overview">
     <div class="d-flex align-items-center">
         <div class="input-group">
             <div class="input-group-prepend">
@@ -61,7 +61,7 @@
             </a>
         </div>
     </div>
-    <div class="text-right mt-3">
+    <div class="text-right mt-sm-3">
         <a href="#" data-route="view-contacts" role="button">
             {{#pix}} i/user, core {{/pix}}
             {{#str}} contacts, core_message {{/str}}
index bd1a087..9330fe6 100644 (file)
@@ -41,7 +41,7 @@
 >
     <div id="{{$region}}{{/region}}-toggle" class="card-header p-0" data-region="toggle">
         <button
-            class="btn btn-link w-100 text-left p-2 d-flex align-items-center overview-section-toggle {{^expanded}}collapsed{{/expanded}}"
+            class="btn btn-link w-100 text-left p-1 p-sm-2 d-flex align-items-center overview-section-toggle {{^expanded}}collapsed{{/expanded}}"
             data-toggle="collapse"
             data-target="#{{$region}}{{/region}}-target-{{uniqid}}"
             aria-expanded="{{#expanded}}true{{/expanded}}{{^expanded}}false{{/expanded}}"
diff --git a/mod/h5pactivity/classes/external/get_attempts.php b/mod/h5pactivity/classes/external/get_attempts.php
new file mode 100644 (file)
index 0000000..d1f9aec
--- /dev/null
@@ -0,0 +1,245 @@
+<?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 is the external method for getting the information needed to present an 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\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->libdir . '/externallib.php');
+
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\local\report\attempts as report_attempts;
+use external_api;
+use external_function_parameters;
+use external_value;
+use external_multiple_structure;
+use external_single_structure;
+use external_warnings;
+use moodle_exception;
+use context_module;
+use stdClass;
+
+/**
+ * This is the external method for getting the information needed to present an attempts report.
+ *
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_attempts extends external_api {
+
+    /**
+     * Webservice parameters.
+     *
+     * @return external_function_parameters
+     */
+    public static function execute_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'h5pactivityid' => new external_value(PARAM_INT, 'h5p activity instance id'),
+                'userids' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'The user ids to get attempts (null means only current user)', VALUE_DEFAULT),
+                    'User ids', VALUE_DEFAULT, []
+                ),
+            ]
+        );
+    }
+
+    /**
+     * Return user attempts information in a h5p activity.
+     *
+     * @throws  moodle_exception if the user cannot see the report
+     * @param  int $h5pactivityid The h5p activity id
+     * @param  int[]|null $userids The user ids (if no provided $USER will be used)
+     * @return stdClass report data
+     */
+    public static function execute(int $h5pactivityid, ?array $userids = []): stdClass {
+        global $USER;
+
+        $params = external_api::validate_parameters(self::execute_parameters(), [
+            'h5pactivityid' => $h5pactivityid,
+            'userids' => $userids,
+        ]);
+        $h5pactivityid = $params['h5pactivityid'];
+        $userids = $params['userids'];
+
+        if (empty($userids)) {
+            $userids = [$USER->id];
+        }
+
+        $warnings = [];
+
+        // Request and permission validation.
+        list ($course, $cm) = get_course_and_cm_from_instance($h5pactivityid, 'h5pactivity');
+
+        $context = context_module::instance($cm->id);
+        self::validate_context($context);
+
+        $manager = manager::create_from_coursemodule($cm);
+
+        $instance = $manager->get_instance();
+
+        $usersattempts = [];
+        foreach ($userids as $userid) {
+            $report = $manager->get_report($userid);
+            if ($report && $report instanceof report_attempts) {
+                $usersattempts[] = self::export_user_attempts($report, $userid);
+            } else {
+                $warnings[] = [
+                    'item' => 'user',
+                    'itemid' => $userid,
+                    'warningcode' => '1',
+                    'message' => "Cannot access user attempts",
+                ];
+            }
+        }
+
+        $result = (object)[
+            'activityid' => $instance->id,
+            'usersattempts' => $usersattempts,
+            'warnings' => $warnings,
+        ];
+
+        return $result;
+    }
+
+    /**
+     * Export attempts data for a specific user.
+     *
+     * @param report_attempts $report the report attempts object
+     * @param int $userid the user id
+     * @return stdClass
+     */
+    public static function export_user_attempts(report_attempts $report, int $userid): stdClass {
+
+        $scored = $report->get_scored();
+        $attempts = $report->get_attempts();
+
+        $result = (object)[
+            'userid' => $userid,
+            'attempts' => [],
+        ];
+
+        foreach ($attempts as $attempt) {
+            $result->attempts[] = self::export_attempt($attempt);
+        }
+
+        if (!empty($scored)) {
+            $result->scored = (object)[
+                'title' => $scored->title,
+                'grademethod' => $scored->grademethod,
+                'attempts' => [self::export_attempt($scored->attempt)],
+            ];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Return a data object from an attempt.
+     *
+     * @param attempt $attempt the attempt object
+     * @return stdClass a WS compatible version of the attempt
+     */
+    private static function export_attempt(attempt $attempt): stdClass {
+        $result = (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' => $attempt->get_duration(),
+            'scaled' => $attempt->get_scaled(),
+        ];
+        if ($attempt->get_completion() !== null) {
+            $result->completion = $attempt->get_completion();
+        }
+        if ($attempt->get_success() !== null) {
+            $result->success = $attempt->get_success();
+        }
+        return $result;
+    }
+
+    /**
+     * Describes the get_h5pactivity_access_information return value.
+     *
+     * @return external_single_structure
+     */
+    public static function execute_returns(): external_single_structure {
+        return new external_single_structure([
+            'activityid' => new external_value(PARAM_INT, 'Activity course module ID'),
+            'usersattempts' => new external_multiple_structure(
+                self::get_user_attempts_returns(), 'The complete users attempts list'
+            ),
+            'warnings' => new external_warnings(),
+        ], 'Activity attempts data');
+    }
+
+    /**
+     * Describes the get_h5pactivity_access_information return value.
+     *
+     * @return external_single_structure
+     */
+    public static function get_user_attempts_returns(): external_single_structure {
+        $structure = [
+            'userid' => new external_value(PARAM_INT, 'The user id'),
+            'attempts' => new external_multiple_structure(self::get_attempt_returns(), 'The complete attempts list'),
+            'scored' => new external_single_structure([
+                'title' => new external_value(PARAM_NOTAGS, 'Scored attempts title'),
+                'grademethod' => new external_value(PARAM_NOTAGS, 'Scored attempts title'),
+                'attempts' => new external_multiple_structure(self::get_attempt_returns(), 'List of the grading attempts'),
+            ], 'Attempts used to grade the activity', VALUE_OPTIONAL),
+        ];
+        return new external_single_structure($structure);
+    }
+
+    /**
+     * Return the external structure of an attempt.
+     *
+     * @return external_single_structure
+     */
+    private static function get_attempt_returns(): external_single_structure {
+
+        $result = new external_single_structure([
+            'id' => new external_value(PARAM_INT, 'ID of the context'),
+            'h5pactivityid' => new external_value(PARAM_INT, 'ID of the H5P activity'),
+            'userid' => new external_value(PARAM_INT, 'ID of the user'),
+            'timecreated' => new external_value(PARAM_INT, 'Attempt creation'),
+            'timemodified' => new external_value(PARAM_INT, 'Attempt modified'),
+            'attempt' => new external_value(PARAM_INT, 'Attempt number'),
+            'rawscore' => new external_value(PARAM_INT, 'Attempt score value'),
+            'maxscore' => new external_value(PARAM_INT, 'Attempt max score'),
+            'duration' => new external_value(PARAM_INT, 'Attempt duration in seconds'),
+            'completion' => new external_value(PARAM_INT, 'Attempt completion', VALUE_OPTIONAL),
+            'success' => new external_value(PARAM_INT, 'Attempt success', VALUE_OPTIONAL),
+            'scaled' => new external_value(PARAM_FLOAT, 'Attempt scaled'),
+        ]);
+        return $result;
+    }
+}
index d38c84b..db62e5e 100644 (file)
@@ -44,4 +44,13 @@ $functions = [
         'capabilities'  => 'mod/h5pactivity:view',
         'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
     ],
+    'mod_h5pactivity_get_attempts' => [
+        'classname'     => 'mod_h5pactivity\external\get_attempts',
+        'methodname'    => 'execute',
+        'classpath'     => '',
+        'description'   => 'Return the information needed to list a user attempts.',
+        'type'          => 'read',
+        'capabilities'  => 'mod/h5pactivity:view',
+        'services'      => [MOODLE_OFFICIAL_MOBILE_SERVICE],
+    ],
 ];
index b3cb285..36d0515 100644 (file)
@@ -334,7 +334,41 @@ function h5pactivity_page_type_list(string $pagetype, stdClass $parentcontext, s
  * @return stdClass an object with the different type of areas indicating if they were updated or not
  */
 function h5pactivity_check_updates_since(cm_info $cm, int $from, array $filter = []): stdClass {
+    global $DB, $USER;
+
     $updates = course_check_module_updates_since($cm, $from, ['package'], $filter);
+
+    $updates->tracks = (object) ['updated' => false];
+    $select = 'h5pactivityid = ? AND userid = ? AND timemodified > ?';
+    $params = [$cm->instance, $USER->id, $from];
+    $tracks = $DB->get_records_select('h5pactivity_attempts', $select, $params, '', 'id');
+    if (!empty($tracks)) {
+        $updates->tracks->updated = true;
+        $updates->tracks->itemids = array_keys($tracks);
+    }
+
+    // Now, teachers should see other students updates.
+    if (has_capability('mod/h5pactivity:reviewattempts', $cm->context)) {
+        $select = 'h5pactivityid = ? AND timemodified > ?';
+        $params = [$cm->instance, $from];
+
+        if (groups_get_activity_groupmode($cm) == SEPARATEGROUPS) {
+            $groupusers = array_keys(groups_get_activity_shared_group_members($cm));
+            if (empty($groupusers)) {
+                return $updates;
+            }
+            list($insql, $inparams) = $DB->get_in_or_equal($groupusers);
+            $select .= ' AND userid ' . $insql;
+            $params = array_merge($params, $inparams);
+        }
+
+        $updates->usertracks = (object) ['updated' => false];
+        $tracks = $DB->get_records_select('h5pactivity_attempts', $select, $params, '', 'id');
+        if (!empty($tracks)) {
+            $updates->usertracks->updated = true;
+            $updates->usertracks->itemids = array_keys($tracks);
+        }
+    }
     return $updates;
 }
 
diff --git a/mod/h5pactivity/tests/external/get_attempts_test.php b/mod/h5pactivity/tests/external/get_attempts_test.php
new file mode 100644 (file)
index 0000000..bfc6408
--- /dev/null
@@ -0,0 +1,488 @@
+<?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/>.
+
+/**
+ * External function test for get_attempts.
+ *
+ * @package    mod_h5pactivity
+ * @category   external
+ * @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\external;
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+
+use mod_h5pactivity\local\manager;
+use external_api;
+use externallib_advanced_testcase;
+
+/**
+ * External function test for get_attempts.
+ *
+ * @package    mod_h5pactivity
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class get_attempts_testcase extends externallib_advanced_testcase {
+
+    /**
+     * Test the behaviour of get_attempts.
+     *
+     * @dataProvider execute_data
+     * @param int $grademethod the activity grading method
+     * @param string $loginuser the user which calls the webservice
+ &nbs